diff --git a/.agents/scripts/headless-runtime-helper.sh b/.agents/scripts/headless-runtime-helper.sh index d4649bab7d..fad8b1b4c8 100755 --- a/.agents/scripts/headless-runtime-helper.sh +++ b/.agents/scripts/headless-runtime-helper.sh @@ -222,6 +222,13 @@ get_session_id() { return 0 } +clear_session_id() { + local provider="$1" + local session_key="$2" + db_query "DELETE FROM provider_sessions WHERE provider = '$(sql_escape "$provider")' AND session_key = '$(sql_escape "$session_key")';" >/dev/null + return 0 +} + store_session_id() { local provider="$1" local session_key="$2" @@ -671,10 +678,18 @@ cmd_run() { return 1 } - local selected_model provider persisted_session + local selected_model provider persisted_session="" selected_model=$(choose_model "$role" "$model_override") || return $? provider=$(extract_provider "$selected_model") - persisted_session=$(get_session_id "$provider" "$session_key") + if [[ "$role" == "pulse" ]]; then + # Pulse runs must start from the current pre-fetched state each cycle. + # Reusing a prior OpenCode session contaminates later /pulse runs with + # stale conversational context, which leads to idle watchdog kills and an + # empty worker pool. Workers still keep session reuse. + clear_session_id "$provider" "$session_key" + else + persisted_session=$(get_session_id "$provider" "$session_key") + fi local -a cmd=("$OPENCODE_BIN_DEFAULT" run "$prompt" --dir "$work_dir" -m "$selected_model" --title "$title" --format json) if [[ -n "$agent_name" ]]; then @@ -704,7 +719,7 @@ cmd_run() { print_warning "$provider returned exit 0 without any model activity; backing off provider" return 75 fi - if [[ -n "$discovered_session" ]]; then + if [[ "$role" != "pulse" && -n "$discovered_session" ]]; then store_session_id "$provider" "$session_key" "$discovered_session" "$selected_model" fi rm -f "$output_file" diff --git a/setup.sh b/setup.sh index 8cfc27bd22..722a8e8805 100755 --- a/setup.sh +++ b/setup.sh @@ -112,6 +112,21 @@ _cron_escape() { return 0 } +# Resolve the canonical main worktree path for the current repo. +# When setup.sh is run from a linked worktree, launchd/cron should still point +# autonomous services at the main repo checkout, not the feature worktree. +_resolve_main_worktree_dir() { + local repo_dir="$1" + local main_worktree="" + main_worktree=$(git -C "$repo_dir" worktree list --porcelain 2>/dev/null | awk '/^worktree / {print substr($0, 10); exit}') || main_worktree="" + if [[ -n "$main_worktree" && -d "$main_worktree" ]]; then + printf '%s' "$main_worktree" + return 0 + fi + printf '%s' "$repo_dir" + return 0 +} + # Ensure the crontab has a single PATH= line at the top with the current $PATH. # Individual cron entries must NOT set inline PATH= — it overrides the global one # and hardcodes system-specific paths (nvm, bun, cargo, etc.). This function @@ -840,8 +855,9 @@ main() { # - Non-interactive: only installs if config explicitly says true local wrapper_script="$HOME/.aidevops/agents/scripts/pulse-wrapper.sh" local pulse_label="com.aidevops.aidevops-supervisor-pulse" - local _aidevops_dir + local _aidevops_dir _pulse_repo_dir _aidevops_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _pulse_repo_dir=$(_resolve_main_worktree_dir "$_aidevops_dir") # Read explicit user consent from config.jsonc (not merged defaults). # Empty = user never configured this; "true"/"false" = explicit choice. @@ -959,7 +975,7 @@ main() { _xml_wrapper_script=$(_xml_escape "$wrapper_script") _xml_home=$(_xml_escape "$HOME") _xml_opencode_bin=$(_xml_escape "$opencode_bin") - _xml_aidevops_dir=$(_xml_escape "$_aidevops_dir") + _xml_aidevops_dir=$(_xml_escape "$_pulse_repo_dir") _xml_path=$(_xml_escape "$PATH") if [[ -n "${AIDEVOPS_HEADLESS_MODELS:-}" ]]; then local _xml_headless_models @@ -1035,7 +1051,7 @@ PLIST # via $(…) or backticks if paths contain shell metacharacters local _cron_opencode_bin _cron_aidevops_dir _cron_wrapper_script _cron_headless_env="" _cron_opencode_bin=$(_cron_escape "$opencode_bin") - _cron_aidevops_dir=$(_cron_escape "$_aidevops_dir") + _cron_aidevops_dir=$(_cron_escape "$_pulse_repo_dir") _cron_wrapper_script=$(_cron_escape "$wrapper_script") if [[ -n "${AIDEVOPS_HEADLESS_MODELS:-}" ]]; then local _cron_headless_models diff --git a/tests/test-headless-runtime-helper.sh b/tests/test-headless-runtime-helper.sh index a911ed0e6b..aab2375e29 100755 --- a/tests/test-headless-runtime-helper.sh +++ b/tests/test-headless-runtime-helper.sh @@ -148,6 +148,29 @@ else fail "second run reuses persisted provider session" "logged args: $(tr '\n' ' ' <"$STUB_LOG_FILE")" fi +section "Pulse Runs Stay Fresh" +export STUB_SESSION_ID="ses_pulse_one" +rm -f "$STUB_LOG_FILE" +AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST=openai bash "$HELPER" run \ + --role pulse \ + --session-key supervisor-pulse \ + --dir "$REPO_DIR" \ + --title "Supervisor Pulse" \ + --prompt "/pulse" >/dev/null +export STUB_SESSION_ID="ses_pulse_two" +AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST=openai bash "$HELPER" run \ + --role pulse \ + --session-key supervisor-pulse \ + --dir "$REPO_DIR" \ + --title "Supervisor Pulse" \ + --prompt "/pulse" >/dev/null + +if grep -q -- '--session ' "$STUB_LOG_FILE"; then + fail "pulse runs do not reuse persisted sessions" "logged args: $(tr '\n' ' ' <"$STUB_LOG_FILE")" +else + pass "pulse runs do not reuse persisted sessions" +fi + section "Zero Activity Success Is Rejected" export STUB_EMIT_ACTIVITY="0" if AIDEVOPS_HEADLESS_PROVIDER_ALLOWLIST=openai bash "$HELPER" run \