aidevops 3.5.892 → 3.8.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,9 +1,14 @@
1
1
  #!/usr/bin/env bash
2
+ # SPDX-License-Identifier: MIT
3
+ # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
2
4
  # Scheduler setup functions: supervisor pulse, stats wrapper, process guard,
3
5
  # memory pressure monitor, screen time snapshot, contribution watch,
4
6
  # profile README, OAuth token refresh.
5
7
  # Part of aidevops setup.sh modularization (GH#5793)
6
8
 
9
+ # Keep pulse workers alive long enough for opus-tier dispatches.
10
+ PULSE_STALE_THRESHOLD_SECONDS=1800
11
+
7
12
  # Shell safety baseline
8
13
  set -Eeuo pipefail
9
14
  IFS=$'\n\t'
@@ -105,27 +110,237 @@ _determine_pulse_install() {
105
110
  return 0
106
111
  }
107
112
 
113
+ # GH#17769: These functions are deprecated — model routing is now derived
114
+ # from the OAuth pool + routing table at runtime. Kept as no-ops for one
115
+ # release cycle in case external scripts call them.
108
116
  _resolve_headless_models_override() {
109
- local configured="${AIDEVOPS_HEADLESS_MODELS:-}"
110
- if [[ -z "$configured" ]] && type config_get &>/dev/null; then
111
- configured=$(config_get "orchestration.headless_models" "")
112
- if [[ "$configured" == "null" ]]; then
113
- configured=""
117
+ printf '%s' ""
118
+ return 0
119
+ }
120
+
121
+ _resolve_pulse_model_override() {
122
+ printf '%s' ""
123
+ return 0
124
+ }
125
+
126
+ _is_pulse_installed() {
127
+ local pulse_label="$1"
128
+
129
+ if _scheduler_detect_installed \
130
+ "Supervisor pulse" \
131
+ "$pulse_label" \
132
+ "" \
133
+ "pulse-wrapper" \
134
+ "" \
135
+ "" \
136
+ "" \
137
+ "aidevops-supervisor-pulse"; then
138
+ return 0
139
+ fi
140
+
141
+ return 1
142
+ }
143
+
144
+ _resolve_pulse_runtime_binary() {
145
+ # GH#18439 Bug 2: Persist the resolved binary path across setup.sh
146
+ # invocations. aidevops-auto-update.timer runs setup.sh under systemd's
147
+ # minimal PATH, so re-resolving from live `$PATH` alone yields the
148
+ # legacy macOS-biased `/opt/homebrew/bin/opencode` fallback on Linux.
149
+ # Reading from persistence first (populated during an interactive
150
+ # setup.sh run with a rich `$PATH`) prevents the auto-update cycle
151
+ # from silently degrading the service file.
152
+ local _persisted_file="$HOME/.config/aidevops/scheduler-runtime-bin"
153
+ local opencode_bin=""
154
+
155
+ # 1. Prefer persisted path if it still points at an executable file.
156
+ if [[ -f "$_persisted_file" ]]; then
157
+ local _persisted
158
+ _persisted=$(head -n1 "$_persisted_file" 2>/dev/null || true)
159
+ if [[ -n "$_persisted" ]] && [[ -x "$_persisted" ]]; then
160
+ printf '%s' "$_persisted"
161
+ return 0
114
162
  fi
115
163
  fi
116
- printf '%s' "$configured"
164
+
165
+ # 2. Try runtime-registry lookup via live PATH.
166
+ if type rt_list_headless &>/dev/null; then
167
+ local _sched_rt_id=""
168
+ local _sched_bin=""
169
+ while IFS= read -r _sched_rt_id; do
170
+ _sched_bin=$(rt_binary "$_sched_rt_id") || continue
171
+ if [[ -n "$_sched_bin" ]] && command -v "$_sched_bin" &>/dev/null; then
172
+ opencode_bin=$(command -v "$_sched_bin")
173
+ break
174
+ fi
175
+ done < <(rt_list_headless)
176
+ fi
177
+
178
+ # 3. Direct PATH lookup for the default runtime.
179
+ if [[ -z "$opencode_bin" ]]; then
180
+ opencode_bin=$(command -v opencode 2>/dev/null || true)
181
+ fi
182
+
183
+ # 4. OS-aware common-install-location sweep. Used when live `$PATH` is
184
+ # minimal (systemd-spawned setup.sh) and persistence hasn't been
185
+ # seeded yet. Covers Homebrew (macOS + Linuxbrew), /usr/local, npm
186
+ # global, Python/uv pipx-style `.local/bin`, and bun.
187
+ if [[ -z "$opencode_bin" ]]; then
188
+ local _candidate
189
+ for _candidate in \
190
+ /opt/homebrew/bin/opencode \
191
+ /usr/local/bin/opencode \
192
+ /home/linuxbrew/.linuxbrew/bin/opencode \
193
+ "$HOME/.npm-global/bin/opencode" \
194
+ "$HOME/.local/bin/opencode" \
195
+ "$HOME/.bun/bin/opencode" \
196
+ /opt/homebrew/bin/claude \
197
+ /usr/local/bin/claude \
198
+ "$HOME/.local/bin/claude"; do
199
+ if [[ -x "$_candidate" ]]; then
200
+ opencode_bin="$_candidate"
201
+ break
202
+ fi
203
+ done
204
+ fi
205
+
206
+ # 5. Last-resort legacy fallback (preserves pre-GH#18439 behaviour so
207
+ # setup.sh never exits the resolver empty-handed).
208
+ [[ -z "$opencode_bin" ]] && opencode_bin="/opt/homebrew/bin/opencode"
209
+
210
+ # Persist the resolved path for subsequent non-interactive invocations
211
+ # (auto-update timer, cron regeneration). Only write when we actually
212
+ # found a real executable — don't persist the legacy fallback.
213
+ if [[ -x "$opencode_bin" ]]; then
214
+ mkdir -p "$(dirname "$_persisted_file")" 2>/dev/null || true
215
+ printf '%s\n' "$opencode_bin" >"$_persisted_file" 2>/dev/null || true
216
+ fi
217
+
218
+ printf '%s' "$opencode_bin"
117
219
  return 0
118
220
  }
119
221
 
120
- _resolve_pulse_model_override() {
121
- local configured="${PULSE_MODEL:-}"
122
- if [[ -z "$configured" ]] && type config_get &>/dev/null; then
123
- configured=$(config_get "orchestration.pulse_model" "")
124
- if [[ "$configured" == "null" ]]; then
125
- configured=""
222
+ _build_pulse_linux_env() {
223
+ # GH#17546/GH#17769: Model config is derived from pool + routing table at
224
+ # runtime. No model env vars embedded in cron/systemd.
225
+ local opencode_bin="${1:-}"
226
+ local _pulse_env="PULSE_DIR=${HOME}/.aidevops/.agent-workspace
227
+ PULSE_STALE_THRESHOLD=${PULSE_STALE_THRESHOLD_SECONDS}"
228
+
229
+ # GH#18439 Bug 2: embed resolved runtime binary path so pulse-wrapper.sh
230
+ # and headless-runtime-helper.sh find the correct binary under systemd's
231
+ # minimal PATH (e.g. when aidevops-auto-update.timer regenerates the
232
+ # service file). Mirrors the macOS launchd <OPENCODE_BIN> key.
233
+ if [[ -n "$opencode_bin" ]]; then
234
+ _pulse_env+=$'\n'"OPENCODE_BIN=${opencode_bin}"
235
+ fi
236
+
237
+ printf '%s' "$_pulse_env"
238
+ return 0
239
+ }
240
+
241
+ # Read supervisor.pulse_interval_seconds from settings.json.
242
+ # Falls back to 120 if the file is missing, the key is absent, or jq is unavailable.
243
+ # Clamps to the validated range [30, 3600].
244
+ # GH#18018: previously this was hardcoded as "120" in _install_supervisor_pulse.
245
+ _read_pulse_interval_seconds() {
246
+ local _settings_file="$HOME/.config/aidevops/settings.json"
247
+ local _interval=120
248
+
249
+ if command -v jq >/dev/null 2>&1 && [[ -f "$_settings_file" ]]; then
250
+ local _raw
251
+ _raw=$(jq -r '.supervisor.pulse_interval_seconds // empty' "$_settings_file" 2>/dev/null) || _raw=""
252
+ if [[ -n "$_raw" ]] && [[ "$_raw" =~ ^[0-9]+$ ]]; then
253
+ _interval="$_raw"
126
254
  fi
127
255
  fi
128
- printf '%s' "$configured"
256
+
257
+ # Clamp to validated range (mirrors settings-helper.sh validation: 30-3600)
258
+ if [[ "$_interval" -lt 30 ]]; then
259
+ _interval=30
260
+ elif [[ "$_interval" -gt 3600 ]]; then
261
+ _interval=3600
262
+ fi
263
+
264
+ printf '%d' "$_interval"
265
+ return 0
266
+ }
267
+
268
+ # Convert an interval in seconds to a cron schedule expression (e.g. "*/2 * * * *").
269
+ # Minimum granularity is 1 minute. Intervals that don't divide evenly into minutes
270
+ # are rounded down to whole minutes with a warning.
271
+ # Args: $1 = interval_seconds
272
+ _seconds_to_cron_schedule() {
273
+ local _interval_sec="$1"
274
+ local _minutes=$((_interval_sec / 60))
275
+ local _remainder=$((_interval_sec % 60))
276
+
277
+ # Clamp to at least 1 minute
278
+ if [[ "$_minutes" -lt 1 ]]; then
279
+ _minutes=1
280
+ fi
281
+
282
+ # Warn if interval doesn't divide evenly into minutes
283
+ if [[ "$_remainder" -ne 0 ]]; then
284
+ 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
285
+ fi
286
+
287
+ # cron step values must be 1-59; */60 is invalid. Use @hourly for exactly 60 min,
288
+ # clamp anything above 59 to 59 (the _read_pulse_interval_seconds cap is 3600s=60min).
289
+ if [[ "$_minutes" -ge 60 ]]; then
290
+ printf '@hourly'
291
+ else
292
+ printf '*/%d * * * *' "$_minutes"
293
+ fi
294
+ return 0
295
+ }
296
+
297
+ _install_supervisor_pulse() {
298
+ local _os="$1"
299
+ local pulse_label="$2"
300
+ local wrapper_script="$3"
301
+ local opencode_bin="$4"
302
+ local _pulse_installed="$5"
303
+
304
+ mkdir -p "$HOME/.aidevops/logs"
305
+
306
+ if [[ "$_os" == "Darwin" ]]; then
307
+ _install_pulse_launchd "$pulse_label" "$wrapper_script" "$opencode_bin" "$_pulse_installed"
308
+ return 0
309
+ fi
310
+
311
+ # GH#18018: read user-configured interval instead of hardcoding 120s / */2 cron
312
+ local _pulse_interval_sec
313
+ _pulse_interval_sec=$(_read_pulse_interval_seconds)
314
+ local _pulse_cron_schedule
315
+ _pulse_cron_schedule=$(_seconds_to_cron_schedule "$_pulse_interval_sec")
316
+ # Build a human-readable interval label: show seconds when < 60s, minutes otherwise
317
+ local _pulse_interval_label
318
+ if [[ "$_pulse_interval_sec" -lt 60 ]]; then
319
+ _pulse_interval_label="${_pulse_interval_sec}s"
320
+ else
321
+ _pulse_interval_label="$((_pulse_interval_sec / 60))min"
322
+ fi
323
+
324
+ local _pulse_timeout_sec=$((PULSE_STALE_THRESHOLD_SECONDS + 60))
325
+ local _pulse_env=""
326
+ # GH#18439 Bug 2: thread resolved runtime binary path through to the
327
+ # Linux env builder so OPENCODE_BIN is embedded in the systemd service
328
+ # file (parity with the macOS launchd plist at line 415).
329
+ _pulse_env=$(_build_pulse_linux_env "$opencode_bin")
330
+ _install_scheduler_linux \
331
+ "aidevops-supervisor-pulse" \
332
+ "aidevops: supervisor-pulse" \
333
+ "${_pulse_cron_schedule}" \
334
+ "\"${wrapper_script}\"" \
335
+ "${_pulse_interval_sec}" \
336
+ "$HOME/.aidevops/logs/pulse-wrapper.log" \
337
+ "$_pulse_env" \
338
+ "Supervisor pulse enabled (every ${_pulse_interval_label})" \
339
+ "Failed to install supervisor pulse scheduler. See runners.md for manual setup." \
340
+ "true" \
341
+ "false" \
342
+ "" \
343
+ "${_pulse_timeout_sec}"
129
344
  return 0
130
345
  }
131
346
 
@@ -162,44 +377,18 @@ setup_supervisor_pulse() {
162
377
  _pulse_lower=$(echo "$_pulse_user_config" | tr '[:upper:]' '[:lower:]')
163
378
 
164
379
  # Detect if pulse is already installed (for upgrade messaging)
165
- # Uses shared helper to check both launchd and cron consistently
380
+ # Uses shared helper to check launchd, cron, and systemd (GH#17381)
166
381
  local _pulse_installed=false
167
- if _scheduler_detect_installed \
168
- "Supervisor pulse" \
169
- "$pulse_label" \
170
- "" \
171
- "pulse-wrapper" \
172
- "" \
173
- "" \
174
- ""; then
382
+ if _is_pulse_installed "$pulse_label"; then
175
383
  _pulse_installed=true
176
384
  fi
177
385
 
178
386
  # Detect dispatch backend binary location (t1665.5 — registry-driven)
179
- local opencode_bin
180
- if type rt_list_headless &>/dev/null; then
181
- local _sched_rt_id _sched_bin
182
- while IFS= read -r _sched_rt_id; do
183
- _sched_bin=$(rt_binary "$_sched_rt_id") || continue
184
- if [[ -n "$_sched_bin" ]] && command -v "$_sched_bin" &>/dev/null; then
185
- opencode_bin=$(command -v "$_sched_bin")
186
- break
187
- fi
188
- done < <(rt_list_headless)
189
- fi
190
- # Fallback if registry not loaded or no runtime found
191
- opencode_bin="${opencode_bin:-$(command -v opencode 2>/dev/null || echo "/opt/homebrew/bin/opencode")}"
387
+ local opencode_bin=""
388
+ opencode_bin=$(_resolve_pulse_runtime_binary)
192
389
 
193
390
  if [[ "$_do_install" == "true" ]]; then
194
- mkdir -p "$HOME/.aidevops/logs"
195
-
196
- if [[ "$_os" == "Darwin" ]]; then
197
- _install_pulse_launchd "$pulse_label" "$wrapper_script" "$opencode_bin" "$_pulse_installed"
198
- elif _systemd_user_available; then
199
- _install_pulse_systemd "aidevops-supervisor-pulse" "$wrapper_script"
200
- else
201
- _install_pulse_cron "$wrapper_script"
202
- fi
391
+ _install_supervisor_pulse "$_os" "$pulse_label" "$wrapper_script" "$opencode_bin" "$_pulse_installed"
203
392
  elif [[ "$_pulse_lower" == "false" && "$_pulse_installed" == "true" ]]; then
204
393
  # User explicitly disabled but pulse is still installed — clean up
205
394
  _uninstall_pulse "$_os" "$pulse_label"
@@ -239,40 +428,12 @@ _cleanup_old_pulse_plists() {
239
428
  }
240
429
 
241
430
  # Build XML environment variable fragment for headless model overrides.
242
- # Reads configured overrides and emits XML key/string pairs for plist embedding.
243
- # Prints the XML fragment to stdout (may be empty if no overrides configured).
431
+ # GH#17546: Model config was removed from plist embedding.
432
+ # GH#17769: Model routing is now derived from pool + routing table at runtime.
433
+ # No env vars needed — pulse-wrapper.sh reads the routing table directly.
244
434
  _build_pulse_headless_env_xml() {
245
- local _headless_xml_env=""
246
- local _configured_headless_models _configured_pulse_model
247
- _configured_headless_models=$(_resolve_headless_models_override)
248
- _configured_pulse_model=$(_resolve_pulse_model_override)
249
-
250
- if [[ -n "$_configured_headless_models" ]]; then
251
- local _xml_headless_models
252
- _xml_headless_models=$(_xml_escape "$_configured_headless_models")
253
- _headless_xml_env+=$'\n'
254
- _headless_xml_env+=$'\t\t<key>AIDEVOPS_HEADLESS_MODELS</key>'
255
- _headless_xml_env+=$'\n'
256
- _headless_xml_env+=$'\t\t'"<string>${_xml_headless_models}</string>"
257
- fi
258
- if [[ -n "$_configured_pulse_model" ]]; then
259
- local _xml_pulse_model
260
- _xml_pulse_model=$(_xml_escape "$_configured_pulse_model")
261
- _headless_xml_env+=$'\n'
262
- _headless_xml_env+=$'\t\t<key>PULSE_MODEL</key>'
263
- _headless_xml_env+=$'\n'
264
- _headless_xml_env+=$'\t\t'"<string>${_xml_pulse_model}</string>"
265
- fi
266
- if [[ -n "${AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST:-}" ]]; then
267
- local _xml_headless_allowlist
268
- _xml_headless_allowlist=$(_xml_escape "$AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST")
269
- _headless_xml_env+=$'\n'
270
- _headless_xml_env+=$'\t\t<key>AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST</key>'
271
- _headless_xml_env+=$'\n'
272
- _headless_xml_env+=$'\t\t'"<string>${_xml_headless_allowlist}</string>"
273
- fi
274
-
275
- printf '%s' "$_headless_xml_env"
435
+ # Intentionally empty — model config read from credentials.sh at runtime.
436
+ printf '%s' ""
276
437
  return 0
277
438
  }
278
439
 
@@ -327,7 +488,7 @@ _generate_pulse_plist_content() {
327
488
  <key>PULSE_DIR</key>
328
489
  <string>${_xml_pulse_dir}</string>
329
490
  <key>PULSE_STALE_THRESHOLD</key>
330
- <string>1800</string>
491
+ <string>${PULSE_STALE_THRESHOLD_SECONDS}</string>
331
492
  ${_headless_xml_env}
332
493
  </dict>
333
494
  <key>RunAtLoad</key>
@@ -365,49 +526,6 @@ _install_pulse_launchd() {
365
526
  return 0
366
527
  }
367
528
 
368
- # Install supervisor pulse via cron (Linux)
369
- _install_pulse_cron() {
370
- local wrapper_script="$1"
371
- # Shell-escape all interpolated paths to prevent command injection
372
- # via $(…) or backticks if paths contain shell metacharacters
373
- # PATH is managed globally by _ensure_cron_path() — do NOT set inline
374
- # PATH= here, it overrides the global line and breaks nvm/bun/cargo.
375
- # OPENCODE_BIN removed — resolved from PATH at runtime via command -v.
376
- # See #4099 and #4240 for history.
377
- local _cron_pulse_dir _cron_wrapper_script _cron_headless_env=""
378
- local _configured_headless_models _configured_pulse_model
379
- _configured_headless_models=$(_resolve_headless_models_override)
380
- _configured_pulse_model=$(_resolve_pulse_model_override)
381
- # Use neutral workspace path for PULSE_DIR (GH#5136)
382
- _cron_pulse_dir=$(_cron_escape "${HOME}/.aidevops/.agent-workspace")
383
- _cron_wrapper_script=$(_cron_escape "$wrapper_script")
384
- if [[ -n "$_configured_headless_models" ]]; then
385
- local _cron_headless_models
386
- _cron_headless_models=$(_cron_escape "$_configured_headless_models")
387
- _cron_headless_env+=" AIDEVOPS_HEADLESS_MODELS=${_cron_headless_models}"
388
- fi
389
- if [[ -n "$_configured_pulse_model" ]]; then
390
- local _cron_pulse_model
391
- _cron_pulse_model=$(_cron_escape "$_configured_pulse_model")
392
- _cron_headless_env+=" PULSE_MODEL=${_cron_pulse_model}"
393
- fi
394
- if [[ -n "${AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST:-}" ]]; then
395
- local _cron_headless_allowlist
396
- _cron_headless_allowlist=$(_cron_escape "$AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST")
397
- _cron_headless_env+=" AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST=${_cron_headless_allowlist}"
398
- fi
399
- (
400
- crontab -l 2>/dev/null | grep -v 'aidevops: supervisor-pulse' || true
401
- echo "*/2 * * * * PULSE_DIR=${_cron_pulse_dir}${_cron_headless_env} /bin/bash ${_cron_wrapper_script} >> \"\$HOME/.aidevops/logs/pulse-wrapper.log\" 2>&1 # aidevops: supervisor-pulse"
402
- ) | crontab - || true
403
- if crontab -l 2>/dev/null | grep -qF "aidevops: supervisor-pulse"; then
404
- print_info "Supervisor pulse enabled (cron, every 2 min). Disable: crontab -e and remove the supervisor-pulse line"
405
- else
406
- print_warning "Failed to install supervisor pulse cron entry. See runners.md for manual setup."
407
- fi
408
- return 0
409
- }
410
-
411
529
  # Check if systemd user services are available on this Linux system.
412
530
  # Returns 0 if systemd --user is functional, 1 otherwise.
413
531
  _systemd_user_available() {
@@ -416,54 +534,153 @@ _systemd_user_available() {
416
534
  return 0
417
535
  }
418
536
 
419
- # Install supervisor pulse via systemd user service (Linux with systemd)
420
- # Args: $1=service_name (e.g. "aidevops-supervisor-pulse"), $2=wrapper_script
421
- _install_pulse_systemd() {
537
+ # Escape a value for safe embedding in a systemd unit Environment= directive.
538
+ # systemd interprets % as specifiers (%h, %n, %t, etc.) and spaces as
539
+ # key-value separators. This helper:
540
+ # 1. Escapes \ → \\ (must be first to avoid double-escaping)
541
+ # 2. Doubles % → %% (escape specifiers)
542
+ # 3. Escapes embedded " → \"
543
+ # 4. Wraps the result in "..." (handles spaces and other shell metacharacters)
544
+ # Usage: escaped=$(_systemd_escape "$value")
545
+ _systemd_escape() {
546
+ local _val="$1"
547
+ # Step 1: escape backslashes
548
+ _val="${_val//\\/\\\\}"
549
+ # Step 2: escape % specifiers
550
+ _val="${_val//%/%%}"
551
+ # Step 3: escape embedded double-quotes
552
+ _val="${_val//\"/\\\"}"
553
+ # Step 4: wrap in double-quotes
554
+ printf '"%s"' "$_val"
555
+ return 0
556
+ }
557
+
558
+ # Build systemd Environment= lines from newline-separated KEY=VALUE pairs.
559
+ # Always appends HOME and PATH for parity with launchd and cron execution.
560
+ _scheduler_systemd_env_lines() {
561
+ local env_vars="$1"
562
+ local _env_lines=""
563
+
564
+ if [[ -n "$env_vars" ]]; then
565
+ while IFS= read -r _kv; do
566
+ [[ -z "$_kv" ]] && continue
567
+ local _key="${_kv%%=*}"
568
+ local _raw_val="${_kv#*=}"
569
+ local _escaped_val
570
+ _escaped_val=$(_systemd_escape "$_raw_val")
571
+ _env_lines+="Environment=${_key}=${_escaped_val}"$'\n'
572
+ done <<<"$env_vars"
573
+ fi
574
+
575
+ _env_lines+="Environment=HOME=$(_systemd_escape "$HOME")"$'\n'
576
+ _env_lines+="Environment=PATH=$(_systemd_escape "$PATH")"$'\n'
577
+ printf '%s' "$_env_lines"
578
+ return 0
579
+ }
580
+
581
+ # Build inline cron environment assignments from newline-separated KEY=VALUE pairs.
582
+ _scheduler_cron_env_prefix() {
583
+ local env_vars="$1"
584
+ local _env_prefix=""
585
+
586
+ if [[ -n "$env_vars" ]]; then
587
+ while IFS= read -r _kv; do
588
+ [[ -z "$_kv" ]] && continue
589
+ local _key="${_kv%%=*}"
590
+ local _raw_val="${_kv#*=}"
591
+ local _escaped_val
592
+ _escaped_val=$(_cron_escape "$_raw_val")
593
+ _env_prefix+="${_key}=${_escaped_val} "
594
+ done <<<"$env_vars"
595
+ fi
596
+
597
+ printf '%s' "$_env_prefix"
598
+ return 0
599
+ }
600
+
601
+ # Install a generic scheduler via systemd user timer (Linux with systemd).
602
+ # Args:
603
+ # $1 = service_name (e.g. "aidevops-stats-wrapper")
604
+ # $2 = exec_command (shell command run via /bin/bash -lc)
605
+ # $3 = interval_sec (OnUnitActiveSec interval in seconds; may be empty for calendar-only)
606
+ # $4 = log_file (absolute path to log file)
607
+ # $5 = env_vars (newline-separated KEY=VALUE pairs, may be empty)
608
+ # $6 = run_at_load ("true" or "false")
609
+ # $7 = low_priority ("true" or "false")
610
+ # $8 = on_calendar (optional systemd OnCalendar spec)
611
+ # $9 = timeout_sec (optional TimeoutStartSec; defaults to interval_sec)
612
+ # Returns 0 on success, 1 if systemd enable fails (caller should fall back to cron).
613
+ _install_scheduler_systemd() {
422
614
  local service_name="$1"
423
- local wrapper_script="$2"
615
+ local exec_command="$2"
616
+ local interval_sec="$3"
617
+ local log_file="$4"
618
+ local env_vars="$5"
619
+ local run_at_load="$6"
620
+ local low_priority="$7"
621
+ local on_calendar="$8"
622
+ local timeout_sec="$9"
424
623
  local service_dir="$HOME/.config/systemd/user"
425
624
  local service_file="${service_dir}/${service_name}.service"
426
625
  local timer_file="${service_dir}/${service_name}.timer"
427
626
 
428
627
  mkdir -p "$service_dir"
429
628
 
430
- # Build environment overrides for the service
431
- local _env_lines=""
432
- local _configured_headless_models _configured_pulse_model
433
- _configured_headless_models=$(_resolve_headless_models_override)
434
- _configured_pulse_model=$(_resolve_pulse_model_override)
435
- if [[ -n "$_configured_headless_models" ]]; then
436
- _env_lines+="Environment=AIDEVOPS_HEADLESS_MODELS=${_configured_headless_models}\n"
629
+ # GH#18439 Bug 1: command substitution strips trailing newlines, which
630
+ # would run the final Environment=PATH=... into the following
631
+ # StandardOutput=... directive on the same line. Use a sentinel ('x')
632
+ # to preserve the trailing newline that _scheduler_systemd_env_lines
633
+ # always emits.
634
+ local _env_lines
635
+ _env_lines=$(
636
+ _scheduler_systemd_env_lines "$env_vars"
637
+ printf 'x'
638
+ )
639
+ _env_lines="${_env_lines%x}"
640
+
641
+ if [[ -z "$timeout_sec" ]]; then
642
+ timeout_sec="$interval_sec"
437
643
  fi
438
- if [[ -n "$_configured_pulse_model" ]]; then
439
- _env_lines+="Environment=PULSE_MODEL=${_configured_pulse_model}\n"
644
+ if [[ -z "$timeout_sec" ]]; then
645
+ timeout_sec="3600"
440
646
  fi
441
- if [[ -n "${AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST:-}" ]]; then
442
- _env_lines+="Environment=AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST=${AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST}\n"
647
+
648
+ local _service_extra=""
649
+ if [[ "$low_priority" == "true" ]]; then
650
+ _service_extra+="Nice=10"$'\n'
651
+ _service_extra+="IOSchedulingClass=idle"$'\n'
443
652
  fi
444
- _env_lines+="Environment=PULSE_DIR=${HOME}/.aidevops/.agent-workspace\n"
445
- _env_lines+="Environment=HOME=${HOME}\n"
446
653
 
447
- # Write the service unit
448
654
  printf '%s' "[Unit]
449
- Description=aidevops Supervisor Pulse
655
+ Description=aidevops ${service_name}
450
656
  After=network.target
451
657
 
452
658
  [Service]
453
659
  Type=oneshot
454
- ExecStart=/bin/bash ${wrapper_script}
455
- ${_env_lines}StandardOutput=append:${HOME}/.aidevops/logs/pulse-wrapper.log
456
- StandardError=append:${HOME}/.aidevops/logs/pulse-wrapper.log
660
+ KillMode=process
661
+ ExecStart=/bin/bash -lc $(_systemd_escape "$exec_command")
662
+ TimeoutStartSec=${timeout_sec}
663
+ ${_service_extra}${_env_lines}StandardOutput=$(_systemd_escape "append:${log_file}")
664
+ StandardError=$(_systemd_escape "append:${log_file}")
457
665
  " >"$service_file"
458
666
 
459
- # Write the timer unit (every 2 minutes)
667
+ local _timer_lines=""
668
+ if [[ "$run_at_load" == "true" ]]; then
669
+ _timer_lines+="OnActiveSec=10s"$'\n'
670
+ fi
671
+ if [[ -n "$interval_sec" ]]; then
672
+ _timer_lines+="OnBootSec=${interval_sec}"$'\n'
673
+ _timer_lines+="OnUnitActiveSec=${interval_sec}"$'\n'
674
+ fi
675
+ if [[ -n "$on_calendar" ]]; then
676
+ _timer_lines+="OnCalendar=${on_calendar}"$'\n'
677
+ fi
678
+
460
679
  printf '%s' "[Unit]
461
- Description=aidevops Supervisor Pulse Timer
680
+ Description=aidevops ${service_name} Timer
462
681
 
463
682
  [Timer]
464
- OnBootSec=2min
465
- OnUnitActiveSec=2min
466
- Persistent=true
683
+ ${_timer_lines}Persistent=true
467
684
 
468
685
  [Install]
469
686
  WantedBy=timers.target
@@ -471,11 +688,148 @@ WantedBy=timers.target
471
688
 
472
689
  systemctl --user daemon-reload 2>/dev/null || true
473
690
  if systemctl --user enable --now "${service_name}.timer" 2>/dev/null; then
474
- print_info "Supervisor pulse enabled (systemd user timer, every 2 min)"
475
- print_info "Disable: systemctl --user disable --now ${service_name}.timer"
691
+ return 0
692
+ fi
693
+ return 1
694
+ }
695
+
696
+ # Install a generic cron entry.
697
+ # Args: $1=cron_tag, $2=cron_schedule, $3=exec_command, $4=log_file, $5=env_vars
698
+ _install_scheduler_cron() {
699
+ local cron_tag="$1"
700
+ local cron_schedule="$2"
701
+ local exec_command="$3"
702
+ local log_file="$4"
703
+ local env_vars="$5"
704
+ local _cron_exec
705
+ local _cron_log
706
+ local _env_prefix
707
+
708
+ _env_prefix=$(_scheduler_cron_env_prefix "$env_vars")
709
+ _cron_exec=$(_cron_escape "$exec_command")
710
+ _cron_log=$(_cron_escape "$log_file")
711
+
712
+ (
713
+ crontab -l 2>/dev/null | grep -vF "${cron_tag}" || true
714
+ echo "${cron_schedule} ${_env_prefix}/bin/bash -lc ${_cron_exec} >> ${_cron_log} 2>&1 # ${cron_tag}"
715
+ ) | crontab - 2>/dev/null || true
716
+ return 0
717
+ }
718
+
719
+ # Dispatcher: install a scheduler on Linux, preferring systemd over cron.
720
+ # Args:
721
+ # $1 = service_name (systemd service name, e.g. "aidevops-stats-wrapper")
722
+ # $2 = cron_tag (comment tag for cron line, e.g. "aidevops: stats-wrapper")
723
+ # $3 = cron_schedule (cron schedule expression, e.g. "*/15 * * * *")
724
+ # $4 = exec_command (shell command run via /bin/bash -lc)
725
+ # $5 = interval_sec (systemd OnUnitActiveSec in seconds; may be empty for calendar-only)
726
+ # $6 = log_file (absolute path to log file)
727
+ # $7 = env_vars (newline-separated KEY=VALUE pairs for systemd/cron, may be empty)
728
+ # $8 = success_msg (message to print on success)
729
+ # $9 = fail_msg (message to print on failure)
730
+ # $10 = run_at_load ("true" or "false")
731
+ # $11 = low_priority ("true" or "false")
732
+ # $12 = on_calendar (optional systemd OnCalendar spec)
733
+ # $13 = timeout_sec (optional TimeoutStartSec)
734
+ # Returns 0 always (failures are warnings, not fatal).
735
+ _install_scheduler_linux() {
736
+ local service_name="$1"
737
+ local cron_tag="$2"
738
+ local cron_schedule="$3"
739
+ local exec_command="$4"
740
+ local interval_sec="$5"
741
+ local log_file="$6"
742
+ local env_vars="$7"
743
+ local success_msg="$8"
744
+ local fail_msg="$9"
745
+ local run_at_load="${10}"
746
+ local low_priority="${11}"
747
+ local on_calendar="${12:-}"
748
+ local timeout_sec="${13:-}"
749
+
750
+ if _systemd_user_available; then
751
+ if _install_scheduler_systemd \
752
+ "$service_name" \
753
+ "$exec_command" \
754
+ "$interval_sec" \
755
+ "$log_file" \
756
+ "$env_vars" \
757
+ "$run_at_load" \
758
+ "$low_priority" \
759
+ "$on_calendar" \
760
+ "$timeout_sec"; then
761
+ print_info "${success_msg} (systemd user timer)"
762
+ # After systemd install succeeds, remove any pre-existing cron entry
763
+ # to prevent dual-execution (GH#17695 Finding A)
764
+ if command -v crontab >/dev/null 2>&1; then
765
+ local current_cron
766
+ current_cron=$(crontab -l 2>/dev/null) || current_cron=""
767
+ if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "$cron_tag"; then
768
+ echo "$current_cron" | grep -vF "$cron_tag" | crontab -
769
+ echo "[schedulers] Removed pre-existing cron entry for $cron_tag (migrated to systemd)"
770
+ fi
771
+ fi
772
+ else
773
+ print_warning "systemd enable failed for ${service_name} — falling back to cron"
774
+ _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
775
+ if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
776
+ print_info "${success_msg} (cron fallback)"
777
+ else
778
+ print_warning "${fail_msg}"
779
+ fi
780
+ fi
781
+ else
782
+ _install_scheduler_cron "$cron_tag" "$cron_schedule" "$exec_command" "$log_file" "$env_vars"
783
+ if crontab -l 2>/dev/null | grep -qF "${cron_tag}" 2>/dev/null; then
784
+ print_info "${success_msg} (cron)"
785
+ else
786
+ print_warning "${fail_msg}"
787
+ fi
788
+ fi
789
+ return 0
790
+ }
791
+
792
+ # Uninstall a scheduler across all backends (launchd/systemd/cron).
793
+ # Args:
794
+ # $1 = os (output of uname -s)
795
+ # $2 = launchd_label (e.g. "sh.aidevops.stats-wrapper")
796
+ # $3 = systemd_name (e.g. "aidevops-stats-wrapper")
797
+ # $4 = cron_tag (grep pattern for cron line, e.g. "aidevops: stats-wrapper")
798
+ # $5 = success_msg (message to print on removal)
799
+ # Returns 0 always.
800
+ _uninstall_scheduler() {
801
+ local _os="$1"
802
+ local launchd_label="$2"
803
+ local systemd_name="$3"
804
+ local cron_tag="$4"
805
+ local success_msg="$5"
806
+
807
+ if [[ "$_os" == "Darwin" ]]; then
808
+ local _plist="$HOME/Library/LaunchAgents/${launchd_label}.plist"
809
+ if _launchd_has_agent "$launchd_label"; then
810
+ launchctl unload "$_plist" 2>/dev/null || true
811
+ rm -f "$_plist"
812
+ print_info "${success_msg} (launchd agent removed)"
813
+ fi
476
814
  else
477
- print_warning "Failed to enable systemd timer falling back to cron"
478
- _install_pulse_cron "$wrapper_script"
815
+ # Check and remove from ALL backends sequentially, not just the first
816
+ # match. Prevents orphan entries when migrating between systemd and cron
817
+ # (GH#17695 Finding A).
818
+ if _systemd_user_available && systemctl --user is-enabled "${systemd_name}.timer" >/dev/null 2>&1; then
819
+ systemctl --user disable --now "${systemd_name}.timer" 2>/dev/null || true
820
+ rm -f "$HOME/.config/systemd/user/${systemd_name}.service"
821
+ rm -f "$HOME/.config/systemd/user/${systemd_name}.timer"
822
+ systemctl --user daemon-reload 2>/dev/null || true
823
+ print_info "${success_msg} (systemd timer removed)"
824
+ fi
825
+ if command -v crontab >/dev/null 2>&1; then
826
+ local current_cron
827
+ current_cron=$(crontab -l 2>/dev/null) || current_cron=""
828
+ if [[ -n "$current_cron" ]] && echo "$current_cron" | grep -qF "${cron_tag}"; then
829
+ echo "$current_cron" | grep -vF "${cron_tag}" | crontab - 2>/dev/null || true
830
+ print_info "${success_msg} (cron entry removed)"
831
+ fi
832
+ fi
479
833
  fi
480
834
  return 0
481
835
  }
@@ -513,6 +867,7 @@ _uninstall_pulse() {
513
867
  # Setup stats-wrapper scheduler — runs quality sweep and health issue updates
514
868
  # separately from the pulse (t1429). Only installed when the supervisor
515
869
  # pulse is enabled (stats are useless without it).
870
+ # macOS: launchd plist (every 15 min) | Linux: systemd timer or cron (every 15 min)
516
871
  setup_stats_wrapper() {
517
872
  local _pulse_lower="$1"
518
873
  # Use effective pulse state (PULSE_ENABLED) if available; fall back to consent string.
@@ -520,6 +875,8 @@ setup_stats_wrapper() {
520
875
  local _pulse_effective="${PULSE_ENABLED:-$_pulse_lower}"
521
876
  local stats_script="$HOME/.aidevops/agents/scripts/stats-wrapper.sh"
522
877
  local stats_label="com.aidevops.aidevops-stats-wrapper"
878
+ local stats_systemd="aidevops-stats-wrapper"
879
+ local stats_log="$HOME/.aidevops/logs/stats.log"
523
880
  if [[ -x "$stats_script" ]] && [[ "$_pulse_effective" == "true" ]]; then
524
881
  # Always regenerate to pick up config/format changes (matches pulse behavior)
525
882
  if [[ "$(uname -s)" == "Darwin" ]]; then
@@ -570,31 +927,27 @@ PLIST
570
927
  print_warning "Failed to load stats wrapper LaunchAgent"
571
928
  fi
572
929
  else
573
- local _cron_stats_script
574
- _cron_stats_script=$(_cron_escape "$stats_script")
575
- (
576
- crontab -l 2>/dev/null | grep -v 'aidevops: stats-wrapper' || true
577
- echo "*/15 * * * * /bin/bash ${_cron_stats_script} >> \"\$HOME/.aidevops/logs/stats.log\" 2>&1 # aidevops: stats-wrapper"
578
- ) | crontab - || true
579
- if crontab -l 2>/dev/null | grep -qF "aidevops: stats-wrapper"; then
580
- print_info "Stats wrapper enabled (cron, every 15 min)"
581
- fi
930
+ _install_scheduler_linux \
931
+ "$stats_systemd" \
932
+ "aidevops: stats-wrapper" \
933
+ "*/15 * * * *" \
934
+ "\"${stats_script}\"" \
935
+ "900" \
936
+ "$stats_log" \
937
+ "" \
938
+ "Stats wrapper enabled (every 15 min)" \
939
+ "Failed to install stats wrapper scheduler" \
940
+ "true" \
941
+ "false"
582
942
  fi
583
943
  elif [[ "$_pulse_effective" == "false" ]]; then
584
944
  # Remove stats scheduler if pulse is disabled
585
- if [[ "$(uname -s)" == "Darwin" ]]; then
586
- local stats_plist="$HOME/Library/LaunchAgents/${stats_label}.plist"
587
- if _launchd_has_agent "$stats_label"; then
588
- launchctl unload "$stats_plist" || true
589
- rm -f "$stats_plist"
590
- print_info "Stats wrapper disabled (launchd agent removed — pulse is off)"
591
- fi
592
- else
593
- if crontab -l 2>/dev/null | grep -qF "aidevops: stats-wrapper"; then
594
- crontab -l 2>/dev/null | grep -v 'aidevops: stats-wrapper' | crontab - || true
595
- print_info "Stats wrapper disabled (cron entry removed — pulse is off)"
596
- fi
597
- fi
945
+ _uninstall_scheduler \
946
+ "$(uname -s)" \
947
+ "$stats_label" \
948
+ "$stats_systemd" \
949
+ "aidevops: stats-wrapper" \
950
+ "Stats wrapper disabled (pulse is off)"
598
951
  fi
599
952
  return 0
600
953
  }
@@ -602,27 +955,22 @@ PLIST
602
955
  # Setup failure miner — mines GitHub CI failure notifications for systemic patterns
603
956
  # and auto-files root-cause issues. Runs as a pure bash script (no LLM needed).
604
957
  # Installed when pulse is enabled and the helper script exists.
605
- # macOS: launchd plist (hourly at :15) | Linux: cron (hourly at :15)
958
+ # macOS: launchd plist (hourly at :15) | Linux: systemd timer or cron (hourly at :15)
606
959
  setup_failure_miner() {
607
960
  local _pulse_lower="$1"
608
961
  local _pulse_effective="${PULSE_ENABLED:-$_pulse_lower}"
609
962
  local miner_script="$HOME/.aidevops/agents/scripts/gh-failure-miner-helper.sh"
610
963
  local miner_label="sh.aidevops.routine-gh-failure-miner"
964
+ local miner_systemd="aidevops-gh-failure-miner"
965
+ local miner_log="$HOME/.aidevops/logs/routine-gh-failure-miner.log"
611
966
  if [[ ! -x "$miner_script" ]] || [[ "$_pulse_effective" != "true" ]]; then
612
967
  # Remove scheduler if pulse is disabled or script missing
613
- if [[ "$(uname -s)" == "Darwin" ]]; then
614
- local miner_plist="$HOME/Library/LaunchAgents/${miner_label}.plist"
615
- if _launchd_has_agent "$miner_label"; then
616
- launchctl unload "$miner_plist" 2>/dev/null || true
617
- rm -f "$miner_plist"
618
- print_info "Failure miner disabled (pulse is off or script missing)"
619
- fi
620
- else
621
- if crontab -l 2>/dev/null | grep -qF "aidevops: gh-failure-miner"; then
622
- crontab -l 2>/dev/null | grep -v 'aidevops: gh-failure-miner' | crontab - || true
623
- print_info "Failure miner disabled (cron entry removed)"
624
- fi
625
- fi
968
+ _uninstall_scheduler \
969
+ "$(uname -s)" \
970
+ "$miner_label" \
971
+ "$miner_systemd" \
972
+ "aidevops: gh-failure-miner" \
973
+ "Failure miner disabled (pulse is off or script missing)"
626
974
  return 0
627
975
  fi
628
976
 
@@ -635,7 +983,7 @@ setup_failure_miner() {
635
983
  _xml_miner_script=$(_xml_escape "$miner_script")
636
984
  _xml_miner_home=$(_xml_escape "$HOME")
637
985
  _xml_miner_path=$(_xml_escape "/bin:/usr/bin:/usr/local/bin:/opt/homebrew/bin:${PATH}")
638
- _xml_miner_log=$(_xml_escape "${HOME}/.aidevops/logs/routine-gh-failure-miner.log")
986
+ _xml_miner_log=$(_xml_escape "$miner_log")
639
987
 
640
988
  local miner_plist_content
641
989
  miner_plist_content=$(
@@ -692,15 +1040,19 @@ MINER_PLIST
692
1040
  print_warning "Failed to load failure miner LaunchAgent"
693
1041
  fi
694
1042
  else
695
- local _cron_miner_script
696
- _cron_miner_script=$(_cron_escape "$miner_script")
697
- (
698
- crontab -l 2>/dev/null | grep -v 'aidevops: gh-failure-miner' || true
699
- echo "15 * * * * /bin/bash ${_cron_miner_script} create-issues --since-hours 24 --pulse-repos --systemic-threshold 2 --max-issues 3 --label auto-dispatch >> \"\$HOME/.aidevops/logs/routine-gh-failure-miner.log\" 2>&1 # aidevops: gh-failure-miner"
700
- ) | crontab - || true
701
- if crontab -l 2>/dev/null | grep -qF "aidevops: gh-failure-miner"; then
702
- print_info "Failure miner enabled (cron, hourly at :15)"
703
- fi
1043
+ _install_scheduler_linux \
1044
+ "$miner_systemd" \
1045
+ "aidevops: gh-failure-miner" \
1046
+ "15 * * * *" \
1047
+ "\"${miner_script}\" create-issues --since-hours 24 --pulse-repos --systemic-threshold 2 --max-issues 3 --label auto-dispatch" \
1048
+ "3600" \
1049
+ "$miner_log" \
1050
+ "" \
1051
+ "Failure miner enabled (hourly at :15)" \
1052
+ "Failed to install failure miner scheduler" \
1053
+ "false" \
1054
+ "false" \
1055
+ "*-*-* *:15:00"
704
1056
  fi
705
1057
  return 0
706
1058
  }
@@ -708,10 +1060,12 @@ MINER_PLIST
708
1060
  # Setup process guard — kills runaway AI processes (ShellCheck bloat, stuck workers)
709
1061
  # before they exhaust memory and cause kernel panics. Always installed when the
710
1062
  # script exists; no consent needed (safety net, not autonomous action).
711
- # macOS: launchd plist (30s interval, RunAtLoad=true) | Linux: cron (every minute)
1063
+ # macOS: launchd plist (30s interval, RunAtLoad=true) | Linux: systemd timer or cron (every minute)
712
1064
  setup_process_guard() {
713
1065
  local guard_script="$HOME/.aidevops/agents/scripts/process-guard-helper.sh"
714
1066
  local guard_label="sh.aidevops.process-guard"
1067
+ local guard_systemd="aidevops-process-guard"
1068
+ local guard_log="$HOME/.aidevops/logs/process-guard.log"
715
1069
  if [[ ! -x "$guard_script" ]]; then
716
1070
  return 0
717
1071
  fi
@@ -779,20 +1133,22 @@ GUARD_PLIST
779
1133
  print_warning "Failed to load process guard LaunchAgent"
780
1134
  fi
781
1135
  else
782
- # Linux: cron entry (every minute — cron minimum granularity)
783
- # Always regenerate to pick up config changes (matches macOS behavior)
784
- # Shell-escape path to prevent command injection via metacharacters
785
- local _cron_guard_script
786
- _cron_guard_script=$(_cron_escape "$guard_script")
787
- (
788
- crontab -l 2>/dev/null | grep -v 'aidevops: process-guard' || true
789
- echo "* * * * * SHELLCHECK_RSS_LIMIT_KB=524288 SHELLCHECK_RUNTIME_LIMIT=120 CHILD_RSS_LIMIT_KB=8388608 CHILD_RUNTIME_LIMIT=7200 /bin/bash ${_cron_guard_script} kill-runaways >> \"\$HOME/.aidevops/logs/process-guard.log\" 2>&1 # aidevops: process-guard"
790
- ) | crontab - || true
791
- if crontab -l 2>/dev/null | grep -qF "aidevops: process-guard"; then
792
- print_info "Process guard enabled (cron, every minute)"
793
- else
794
- print_warning "Failed to install process guard cron entry"
795
- fi
1136
+ # Linux: systemd timer (30s) or cron fallback (every minute — cron minimum granularity)
1137
+ _install_scheduler_linux \
1138
+ "$guard_systemd" \
1139
+ "aidevops: process-guard" \
1140
+ "* * * * *" \
1141
+ "\"${guard_script}\" kill-runaways" \
1142
+ "30" \
1143
+ "$guard_log" \
1144
+ "SHELLCHECK_RSS_LIMIT_KB=524288
1145
+ SHELLCHECK_RUNTIME_LIMIT=120
1146
+ CHILD_RSS_LIMIT_KB=8388608
1147
+ CHILD_RUNTIME_LIMIT=7200" \
1148
+ "Process guard enabled (every 30s)" \
1149
+ "Failed to install process guard scheduler" \
1150
+ "true" \
1151
+ "false"
796
1152
  fi
797
1153
  return 0
798
1154
  }
@@ -801,10 +1157,12 @@ GUARD_PLIST
801
1157
  # Monitors individual process RSS, runtime, session count, and aggregate memory.
802
1158
  # Auto-kills runaway ShellCheck (language server respawns them). Always installed
803
1159
  # when the script exists; no consent needed (safety net, not autonomous action).
804
- # macOS: launchd plist (60s interval, RunAtLoad=true) | Linux: cron (every minute)
1160
+ # macOS: launchd plist (60s interval, RunAtLoad=true) | Linux: systemd timer or cron (every minute)
805
1161
  setup_memory_pressure_monitor() {
806
1162
  local monitor_script="$HOME/.aidevops/agents/scripts/memory-pressure-monitor.sh"
807
1163
  local monitor_label="sh.aidevops.memory-pressure-monitor"
1164
+ local monitor_systemd="aidevops-memory-pressure-monitor"
1165
+ local monitor_log="$HOME/.aidevops/logs/memory-pressure-launchd.log"
808
1166
  if [[ ! -x "$monitor_script" ]]; then
809
1167
  return 0
810
1168
  fi
@@ -867,16 +1225,19 @@ MONITOR_PLIST
867
1225
  print_warning "Failed to load memory pressure monitor LaunchAgent"
868
1226
  fi
869
1227
  else
870
- # Linux: cron entry (every minute — cron minimum granularity)
871
- (
872
- crontab -l 2>/dev/null | grep -v 'aidevops: memory-pressure-monitor' || true
873
- echo "* * * * * /bin/bash \"${monitor_script}\" >> \"\$HOME/.aidevops/logs/memory-pressure-launchd.log\" 2>&1 # aidevops: memory-pressure-monitor"
874
- ) | crontab - 2>/dev/null || true
875
- if crontab -l 2>/dev/null | grep -qF "aidevops: memory-pressure-monitor" 2>/dev/null; then
876
- print_info "Memory pressure monitor enabled (cron, every minute)"
877
- else
878
- print_warning "Failed to install memory pressure monitor cron entry"
879
- fi
1228
+ # Linux: systemd timer (60s) or cron fallback (every minute — cron minimum granularity)
1229
+ _install_scheduler_linux \
1230
+ "$monitor_systemd" \
1231
+ "aidevops: memory-pressure-monitor" \
1232
+ "* * * * *" \
1233
+ "\"${monitor_script}\"" \
1234
+ "60" \
1235
+ "$monitor_log" \
1236
+ "" \
1237
+ "Memory pressure monitor enabled (every 60s)" \
1238
+ "Failed to install memory pressure monitor scheduler" \
1239
+ "true" \
1240
+ "true"
880
1241
  fi
881
1242
  return 0
882
1243
  }
@@ -884,10 +1245,12 @@ MONITOR_PLIST
884
1245
  # Setup screen time snapshot — captures daily screen time for contributor stats.
885
1246
  # Accumulates data in screen-time.jsonl (macOS Knowledge DB retains only ~28 days).
886
1247
  # Always installed when the script exists; no consent needed (data collection only).
887
- # macOS: launchd plist (every 6h, RunAtLoad=true) | Linux: cron (every 6h)
1248
+ # macOS: launchd plist (every 6h, RunAtLoad=true) | Linux: systemd timer or cron (every 6h)
888
1249
  setup_screen_time_snapshot() {
889
1250
  local st_script="$HOME/.aidevops/agents/scripts/screen-time-helper.sh"
890
1251
  local st_label="sh.aidevops.screen-time-snapshot"
1252
+ local st_systemd="aidevops-screen-time-snapshot"
1253
+ local st_log="$HOME/.aidevops/.agent-workspace/logs/screen-time-snapshot.log"
891
1254
  if [[ ! -x "$st_script" ]]; then
892
1255
  return 0
893
1256
  fi
@@ -951,18 +1314,19 @@ ST_PLIST
951
1314
  print_warning "Failed to load screen time snapshot LaunchAgent"
952
1315
  fi
953
1316
  else
954
- # Linux: cron entry (every 6 hours)
955
- local _cron_st_script
956
- _cron_st_script=$(_cron_escape "$st_script")
957
- (
958
- crontab -l 2>/dev/null | grep -v 'aidevops: screen-time-snapshot' || true
959
- echo "0 */6 * * * /bin/bash ${_cron_st_script} snapshot >> \"\$HOME/.aidevops/.agent-workspace/logs/screen-time-snapshot.log\" 2>&1 # aidevops: screen-time-snapshot"
960
- ) | crontab - 2>/dev/null || true
961
- if crontab -l 2>/dev/null | grep -qF "aidevops: screen-time-snapshot" 2>/dev/null; then
962
- print_info "Screen time snapshot enabled (cron, every 6h)"
963
- else
964
- print_warning "Failed to install screen time snapshot cron entry"
965
- fi
1317
+ # Linux: systemd timer (every 6h) or cron fallback
1318
+ _install_scheduler_linux \
1319
+ "$st_systemd" \
1320
+ "aidevops: screen-time-snapshot" \
1321
+ "0 */6 * * *" \
1322
+ "\"${st_script}\" snapshot" \
1323
+ "21600" \
1324
+ "$st_log" \
1325
+ "" \
1326
+ "Screen time snapshot enabled (every 6h)" \
1327
+ "Failed to install screen time snapshot scheduler" \
1328
+ "true" \
1329
+ "true"
966
1330
  fi
967
1331
  return 0
968
1332
  }
@@ -1057,29 +1421,30 @@ CW_PLIST
1057
1421
  return 0
1058
1422
  }
1059
1423
 
1060
- # Install contribution watch via cron (Linux).
1424
+ # Install contribution watch via systemd or cron (Linux).
1061
1425
  # Args: $1=script path, $2=log dir
1062
- _install_cw_cron() {
1426
+ _install_cw_linux() {
1063
1427
  local cw_script="$1"
1064
1428
  local _cw_log_dir="$2"
1065
- local _cron_cw_script _cron_cw_log_dir
1066
- _cron_cw_script=$(_cron_escape "$cw_script")
1067
- _cron_cw_log_dir=$(_cron_escape "$_cw_log_dir")
1068
- (
1069
- crontab -l 2>/dev/null | grep -v 'aidevops: contribution-watch' || true
1070
- echo "0 * * * * /bin/bash ${_cron_cw_script} scan >> \"${_cron_cw_log_dir}/contribution-watch.log\" 2>&1 # aidevops: contribution-watch"
1071
- ) | crontab - 2>/dev/null || true
1072
- if crontab -l 2>/dev/null | grep -qF "aidevops: contribution-watch" 2>/dev/null; then
1073
- print_info "Contribution watch enabled (cron, hourly scan)"
1074
- else
1075
- print_warning "Failed to install contribution watch cron entry"
1076
- fi
1429
+ local cw_systemd="aidevops-contribution-watch"
1430
+ _install_scheduler_linux \
1431
+ "$cw_systemd" \
1432
+ "aidevops: contribution-watch" \
1433
+ "0 * * * *" \
1434
+ "\"${cw_script}\" scan" \
1435
+ "3600" \
1436
+ "${_cw_log_dir}/contribution-watch.log" \
1437
+ "" \
1438
+ "Contribution watch enabled (hourly scan)" \
1439
+ "Failed to install contribution watch scheduler" \
1440
+ "false" \
1441
+ "true"
1077
1442
  return 0
1078
1443
  }
1079
1444
 
1080
1445
  # Setup contribution watch — monitors external issues/PRs for new activity (t1554).
1081
1446
  # Auto-seeds on first run (discovers authored/commented issues/PRs), then installs
1082
- # a launchd/cron job to scan periodically. Requires gh CLI authenticated.
1447
+ # a launchd/systemd/cron job to scan periodically. Requires gh CLI authenticated.
1083
1448
  # No consent needed — this is passive monitoring (read-only notifications API),
1084
1449
  # not autonomous action. Comment bodies are never processed by LLM in automated context.
1085
1450
  # Respects config: aidevops config set orchestration.contribution_watch false
@@ -1110,7 +1475,7 @@ setup_contribution_watch() {
1110
1475
  if [[ "$(uname -s)" == "Darwin" ]]; then
1111
1476
  _install_cw_launchd "$cw_label" "$cw_script" "$_cw_log_dir"
1112
1477
  else
1113
- _install_cw_cron "$cw_script" "$_cw_log_dir"
1478
+ _install_cw_linux "$cw_script" "$_cw_log_dir"
1114
1479
  fi
1115
1480
  return 0
1116
1481
  }
@@ -1134,43 +1499,42 @@ setup_draft_responses() {
1134
1499
  # Setup profile README — auto-create repo and seed README if not already set up.
1135
1500
  # Requires gh CLI authenticated. Creates username/username repo, seeds README
1136
1501
  # with stat markers, registers in repos.json with priority: "profile".
1137
- setup_profile_readme() {
1138
- local pr_script="$HOME/.aidevops/agents/scripts/profile-readme-helper.sh"
1139
- local pr_label="sh.aidevops.profile-readme-update"
1140
- if ! [[ -x "$pr_script" ]] || ! command -v gh &>/dev/null || ! gh auth status &>/dev/null; then
1141
- return 0
1502
+ _profile_readme_ready() {
1503
+ local pr_script="$1"
1504
+ if ! [[ -x "$pr_script" ]]; then
1505
+ return 1
1142
1506
  fi
1507
+ if ! command -v gh &>/dev/null; then
1508
+ return 1
1509
+ fi
1510
+ if ! gh auth status &>/dev/null; then
1511
+ return 1
1512
+ fi
1513
+ return 0
1514
+ }
1143
1515
 
1144
- # Initialize profile repo if not already set up.
1145
- # Always run init — it's idempotent and handles:
1146
- # - Fresh installs (no profile repo)
1147
- # - Missing markers (injects them into existing README)
1148
- # - Diverged history (repo deleted and recreated on GitHub)
1149
- # - Already-initialized repos (returns early with no changes)
1516
+ _run_profile_readme_init() {
1517
+ local pr_script="$1"
1150
1518
  print_info "Checking GitHub profile README..."
1151
1519
  if bash "$pr_script" init; then
1152
1520
  print_info "Profile README ready."
1153
1521
  else
1154
1522
  print_warning "Profile README setup failed (non-fatal, skipping)"
1155
1523
  fi
1524
+ return 0
1525
+ }
1156
1526
 
1157
- # Profile README auto-update scheduled job.
1158
- # Installed whenever gh CLI is available — the update script self-heals
1159
- # (discovers/creates the profile repo on first run via _resolve_profile_repo).
1160
- # macOS: launchd plist (hourly) | Linux: cron (hourly)
1161
- mkdir -p "$HOME/.aidevops/.agent-workspace/logs"
1162
-
1163
- if [[ "$(uname -s)" == "Darwin" ]]; then
1164
- local pr_plist="$HOME/Library/LaunchAgents/${pr_label}.plist"
1165
-
1166
- # XML-escape paths for safe plist embedding
1167
- local _xml_pr_script _xml_pr_home
1168
- _xml_pr_script=$(_xml_escape "$pr_script")
1169
- _xml_pr_home=$(_xml_escape "$HOME")
1170
-
1171
- local pr_plist_content
1172
- pr_plist_content=$(
1173
- cat <<PR_PLIST
1527
+ _install_profile_readme_launchd() {
1528
+ local pr_label="$1"
1529
+ local pr_script="$2"
1530
+ local pr_plist="$HOME/Library/LaunchAgents/${pr_label}.plist"
1531
+ local _xml_pr_script _xml_pr_home
1532
+ _xml_pr_script=$(_xml_escape "$pr_script")
1533
+ _xml_pr_home=$(_xml_escape "$HOME")
1534
+
1535
+ local pr_plist_content
1536
+ pr_plist_content=$(
1537
+ cat <<PR_PLIST
1174
1538
  <?xml version="1.0" encoding="UTF-8"?>
1175
1539
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1176
1540
  <plist version="1.0">
@@ -1209,27 +1573,66 @@ setup_profile_readme() {
1209
1573
  </dict>
1210
1574
  </plist>
1211
1575
  PR_PLIST
1212
- )
1576
+ )
1213
1577
 
1214
- if _launchd_install_if_changed "$pr_label" "$pr_plist" "$pr_plist_content"; then
1215
- print_info "Profile README update enabled (launchd, hourly)"
1216
- else
1217
- print_warning "Failed to load profile README update LaunchAgent"
1218
- fi
1578
+ if _launchd_install_if_changed "$pr_label" "$pr_plist" "$pr_plist_content"; then
1579
+ print_info "Profile README update enabled (launchd, hourly)"
1219
1580
  else
1220
- # Linux: cron entry (hourly)
1221
- local _cron_pr_script
1222
- _cron_pr_script=$(_cron_escape "$pr_script")
1223
- (
1224
- crontab -l 2>/dev/null | grep -v 'aidevops: profile-readme-update' || true
1225
- echo "0 * * * * /bin/bash ${_cron_pr_script} update >> \"\$HOME/.aidevops/.agent-workspace/logs/profile-readme-update.log\" 2>&1 # aidevops: profile-readme-update"
1226
- ) | crontab - 2>/dev/null || true
1227
- if crontab -l 2>/dev/null | grep -qF "aidevops: profile-readme-update" 2>/dev/null; then
1228
- print_info "Profile README update enabled (cron, hourly)"
1229
- else
1230
- print_warning "Failed to install profile README update cron entry"
1231
- fi
1581
+ print_warning "Failed to load profile README update LaunchAgent"
1582
+ fi
1583
+ return 0
1584
+ }
1585
+
1586
+ _install_profile_readme_scheduler() {
1587
+ local pr_label="$1"
1588
+ local pr_systemd="$2"
1589
+ local pr_script="$3"
1590
+ local pr_log="$4"
1591
+
1592
+ if [[ "$(uname -s)" == "Darwin" ]]; then
1593
+ _install_profile_readme_launchd "$pr_label" "$pr_script"
1594
+ return 0
1595
+ fi
1596
+
1597
+ _install_scheduler_linux \
1598
+ "$pr_systemd" \
1599
+ "aidevops: profile-readme-update" \
1600
+ "0 * * * *" \
1601
+ "\"${pr_script}\" update" \
1602
+ "3600" \
1603
+ "$pr_log" \
1604
+ "" \
1605
+ "Profile README update enabled (hourly)" \
1606
+ "Failed to install profile README update scheduler" \
1607
+ "false" \
1608
+ "true"
1609
+ return 0
1610
+ }
1611
+
1612
+ setup_profile_readme() {
1613
+ local pr_script="$HOME/.aidevops/agents/scripts/profile-readme-helper.sh"
1614
+ local pr_label="sh.aidevops.profile-readme-update"
1615
+ if ! _profile_readme_ready "$pr_script"; then
1616
+ return 0
1232
1617
  fi
1618
+
1619
+ # Initialize profile repo if not already set up.
1620
+ # Always run init — it's idempotent and handles:
1621
+ # - Fresh installs (no profile repo)
1622
+ # - Missing markers (injects them into existing README)
1623
+ # - Diverged history (repo deleted and recreated on GitHub)
1624
+ # - Already-initialized repos (returns early with no changes)
1625
+ _run_profile_readme_init "$pr_script"
1626
+
1627
+ # Profile README auto-update scheduled job.
1628
+ # Installed whenever gh CLI is available — the update script self-heals
1629
+ # (discovers/creates the profile repo on first run via _resolve_profile_repo).
1630
+ # macOS: launchd plist (hourly) | Linux: systemd timer or cron (hourly)
1631
+ local pr_systemd="aidevops-profile-readme-update"
1632
+ local pr_log="$HOME/.aidevops/.agent-workspace/logs/profile-readme-update.log"
1633
+ mkdir -p "$HOME/.aidevops/.agent-workspace/logs"
1634
+
1635
+ _install_profile_readme_scheduler "$pr_label" "$pr_systemd" "$pr_script" "$pr_log"
1233
1636
  return 0
1234
1637
  }
1235
1638
 
@@ -1315,27 +1718,29 @@ _uninstall_token_refresh_schtasks() {
1315
1718
  # Refreshes expired/expiring tokens every 30 min so sessions never hit
1316
1719
  # "invalid x-api-key". Also runs at load to catch tokens that expired
1317
1720
  # while the machine was off.
1318
- # macOS: launchd plist | Linux/WSL: cron | Windows Git Bash: schtasks
1319
- setup_oauth_token_refresh() {
1320
- local tr_script="$HOME/.aidevops/agents/scripts/oauth-pool-helper.sh"
1321
- local tr_label="sh.aidevops.token-refresh"
1322
- if ! [[ -x "$tr_script" ]] || ! [[ -f "$HOME/.aidevops/oauth-pool.json" ]]; then
1323
- return 0
1721
+ # macOS: launchd plist | Linux/WSL: systemd timer or cron | Windows Git Bash: schtasks
1722
+ _oauth_token_refresh_ready() {
1723
+ local tr_script="$1"
1724
+ if ! [[ -x "$tr_script" ]]; then
1725
+ return 1
1324
1726
  fi
1727
+ if ! [[ -f "$HOME/.aidevops/oauth-pool.json" ]]; then
1728
+ return 1
1729
+ fi
1730
+ return 0
1731
+ }
1325
1732
 
1326
- local tr_log_dir="$HOME/.aidevops/.agent-workspace/logs"
1327
- mkdir -p "$tr_log_dir"
1328
-
1329
- if [[ "$(uname -s)" == "Darwin" ]]; then
1330
- local tr_plist="$HOME/Library/LaunchAgents/${tr_label}.plist"
1331
-
1332
- local _xml_tr_script _xml_tr_home
1333
- _xml_tr_script=$(_xml_escape "$tr_script")
1334
- _xml_tr_home=$(_xml_escape "$HOME")
1335
-
1336
- local tr_plist_content
1337
- tr_plist_content=$(
1338
- cat <<TR_PLIST
1733
+ _install_token_refresh_launchd() {
1734
+ local tr_label="$1"
1735
+ local tr_script="$2"
1736
+ local tr_plist="$HOME/Library/LaunchAgents/${tr_label}.plist"
1737
+ local _xml_tr_script _xml_tr_home
1738
+ _xml_tr_script=$(_xml_escape "$tr_script")
1739
+ _xml_tr_home=$(_xml_escape "$HOME")
1740
+
1741
+ local tr_plist_content
1742
+ tr_plist_content=$(
1743
+ cat <<TR_PLIST
1339
1744
  <?xml version="1.0" encoding="UTF-8"?>
1340
1745
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1341
1746
  <plist version="1.0">
@@ -1374,29 +1779,45 @@ setup_oauth_token_refresh() {
1374
1779
  </dict>
1375
1780
  </plist>
1376
1781
  TR_PLIST
1377
- )
1782
+ )
1378
1783
 
1379
- if _launchd_install_if_changed "$tr_label" "$tr_plist" "$tr_plist_content"; then
1380
- print_info "OAuth token refresh enabled (launchd, every 30 min)"
1381
- else
1382
- print_warning "Failed to load token refresh LaunchAgent"
1383
- fi
1784
+ if _launchd_install_if_changed "$tr_label" "$tr_plist" "$tr_plist_content"; then
1785
+ print_info "OAuth token refresh enabled (launchd, every 30 min)"
1786
+ else
1787
+ print_warning "Failed to load token refresh LaunchAgent"
1788
+ fi
1789
+ return 0
1790
+ }
1791
+
1792
+ setup_oauth_token_refresh() {
1793
+ local tr_script="$HOME/.aidevops/agents/scripts/oauth-pool-helper.sh"
1794
+ local tr_label="sh.aidevops.token-refresh"
1795
+ if ! _oauth_token_refresh_ready "$tr_script"; then
1796
+ return 0
1797
+ fi
1798
+
1799
+ local tr_log_dir="$HOME/.aidevops/.agent-workspace/logs"
1800
+ mkdir -p "$tr_log_dir"
1801
+
1802
+ if [[ "$(uname -s)" == "Darwin" ]]; then
1803
+ _install_token_refresh_launchd "$tr_label" "$tr_script"
1384
1804
  elif _is_windows; then
1385
1805
  # Windows Git Bash / MINGW64 / MSYS2: use Task Scheduler (schtasks)
1386
1806
  _install_token_refresh_schtasks "$tr_script" "$tr_log_dir"
1387
1807
  else
1388
- # Linux / WSL: cron entry (every 30 min)
1389
- local _cron_tr_script
1390
- _cron_tr_script=$(_cron_escape "$tr_script")
1391
- (
1392
- crontab -l 2>/dev/null | grep -v 'aidevops: token-refresh' || true
1393
- echo "*/30 * * * * /bin/bash ${_cron_tr_script} refresh anthropic >> \"\$HOME/.aidevops/.agent-workspace/logs/token-refresh.log\" 2>&1; /bin/bash ${_cron_tr_script} refresh openai >> \"\$HOME/.aidevops/.agent-workspace/logs/token-refresh.log\" 2>&1 # aidevops: token-refresh"
1394
- ) | crontab - 2>/dev/null || true
1395
- if crontab -l 2>/dev/null | grep -qF "aidevops: token-refresh" 2>/dev/null; then
1396
- print_info "OAuth token refresh enabled (cron, every 30 min)"
1397
- else
1398
- print_warning "Failed to install token refresh cron entry"
1399
- fi
1808
+ # Linux / WSL without systemd: systemd timer or cron fallback
1809
+ _install_scheduler_linux \
1810
+ "aidevops-token-refresh" \
1811
+ "aidevops: token-refresh" \
1812
+ "*/30 * * * *" \
1813
+ "\"${tr_script}\" refresh anthropic; \"${tr_script}\" refresh openai" \
1814
+ "1800" \
1815
+ "${tr_log_dir}/token-refresh.log" \
1816
+ "" \
1817
+ "OAuth token refresh enabled (every 30 min)" \
1818
+ "Failed to install token refresh scheduler" \
1819
+ "true" \
1820
+ "true"
1400
1821
  fi
1401
1822
  return 0
1402
1823
  }
@@ -1413,8 +1834,13 @@ setup_repo_sync() {
1413
1834
  local _repo_sync_installed=false
1414
1835
  if _launchd_has_agent "com.aidevops.aidevops-repo-sync"; then
1415
1836
  _repo_sync_installed=true
1837
+ elif _launchd_has_agent "sh.aidevops.repo-sync"; then
1838
+ _repo_sync_installed=true
1416
1839
  elif crontab -l 2>/dev/null | grep -qF "aidevops-repo-sync"; then
1417
1840
  _repo_sync_installed=true
1841
+ elif command -v systemctl >/dev/null 2>&1 &&
1842
+ systemctl --user is-enabled "aidevops-repo-sync.timer" >/dev/null 2>&1; then
1843
+ _repo_sync_installed=true
1418
1844
  fi
1419
1845
  if [[ "$_repo_sync_installed" == "false" ]]; then
1420
1846
  if [[ "$NON_INTERACTIVE" == "true" ]]; then