diff --git a/hooks/lib/loop-bg-tasks.sh b/hooks/lib/loop-bg-tasks.sh index 3d89c3cc..87d52621 100755 --- a/hooks/lib/loop-bg-tasks.sh +++ b/hooks/lib/loop-bg-tasks.sh @@ -117,6 +117,47 @@ derive_tasks_dir_from_transcript() { printf '/tmp/claude-%s/%s/%s/tasks' "$uid" "$slug" "$sid" } +# Extract the real background-task output file path recorded in the +# transcript launch message. +# +# Claude Code records background Bash launches in the tool_result message +# text as: +# "Command running in background with ID: . Output is being +# written to: . You will be notified when it completes." +# +# The is authoritative: when a Claude session is resumed or +# continued, the current transcript may have a different session id from +# the session under which the task was launched, so the output file does +# NOT live under derive_tasks_dir_from_transcript(current transcript). +# Falling back to the derived directory causes dead/orphaned tasks to be +# treated as alive forever. +# +# Usage: extract_bg_task_output_path_from_transcript "$transcript_path" "$task_id" +# Prints the absolute output file path, or nothing when the transcript +# is unreadable or the launch message does not contain a path. +extract_bg_task_output_path_from_transcript() { + local transcript_path="$1" task_id="$2" + [[ -z "$transcript_path" ]] && return + [[ -f "$transcript_path" ]] || return + [[ -z "$task_id" ]] && return + + local match + # Grep the JSONL line that mentions this task id and contains the + # literal "Output is being written to". The path is everything between + # that prefix and the fixed trailing sentence " You will be notified + # when it completes." This handles paths that contain spaces or other + # characters that a simple [^[:space:]]+ pattern would reject. + match=$(grep -F "$task_id" "$transcript_path" 2>/dev/null \ + | grep -oE 'Output is being written to: .*\. You will be notified when it completes\.' \ + | head -n1) || true + [[ -z "$match" ]] && return + + local path + path="${match#Output is being written to: }" + path="${path%. You will be notified when it completes.}" + expand_leading_tilde "$path" +} + # Returns 0 if the background task identified by task_id appears to be alive # (output file absent, or lsof reports >= 1 holder), 1 if confirmed dead # (output file exists and lsof reports 0 holders). @@ -127,11 +168,21 @@ derive_tasks_dir_from_transcript() { # # Set LSOF_BIN to override the lsof binary path (used in tests). # -# Usage: is_bg_task_alive "$task_id" "$tasks_dir" +# Usage: is_bg_task_alive "$task_id" "$tasks_dir" [transcript_path] is_bg_task_alive() { - local task_id="$1" tasks_dir="$2" + local task_id="$1" tasks_dir="$2" transcript_path="${3:-}" local lsof_bin="${LSOF_BIN:-lsof}" - local output_file="$tasks_dir/$task_id.output" + local output_file + + # Prefer the real output path recorded in the transcript launch + # message; fall back to the derived tasks_dir. This matters when a + # Claude session has been resumed/continued and the current + # transcript's session id differs from the launch session id. + if [[ -n "$transcript_path" ]]; then + output_file=$(extract_bg_task_output_path_from_transcript "$transcript_path" "$task_id") + fi + [[ -n "$output_file" ]] || output_file="$tasks_dir/$task_id.output" + # Output file absent -> fail open (treat as still running). [[ -f "$output_file" ]] || return 0 # lsof unavailable -> fail open. @@ -143,13 +194,13 @@ is_bg_task_alive() { # Filter a newline-delimited list of task IDs, retaining only those that # pass is_bg_task_alive. Prints surviving IDs one per line. # -# Usage: prune_dead_bg_task_ids "$pending_ids" "$tasks_dir" +# Usage: prune_dead_bg_task_ids "$pending_ids" "$tasks_dir" [transcript_path] prune_dead_bg_task_ids() { - local pending_ids="$1" tasks_dir="$2" + local pending_ids="$1" tasks_dir="$2" transcript_path="${3:-}" local task_id while IFS= read -r task_id; do [[ -z "$task_id" ]] && continue - is_bg_task_alive "$task_id" "$tasks_dir" && printf '%s\n' "$task_id" + is_bg_task_alive "$task_id" "$tasks_dir" "$transcript_path" && printf '%s\n' "$task_id" done <<< "$pending_ids" } @@ -257,7 +308,7 @@ list_pending_background_task_ids() { local tasks_dir tasks_dir=$(derive_tasks_dir_from_transcript "$transcript_path") if [[ -n "$tasks_dir" ]]; then - pending=$(prune_dead_bg_task_ids "$pending" "$tasks_dir") + pending=$(prune_dead_bg_task_ids "$pending" "$tasks_dir" "$transcript_path") fi fi diff --git a/tests/test-stop-hook-bg-allow.sh b/tests/test-stop-hook-bg-allow.sh index 9fdfc0f7..4bd56105 100755 --- a/tests/test-stop-hook-bg-allow.sh +++ b/tests/test-stop-hook-bg-allow.sh @@ -237,6 +237,23 @@ emit_bg_shell_launch_result() { }' } +emit_bg_shell_launch_result_with_output_path() { + local tool_use_id="$1" bg_task_id="$2" output_path="$3" + jq -c -n \ + --arg id "$tool_use_id" \ + --arg bid "$bg_task_id" \ + --arg out "$output_path" \ + '{ + type:"user", + message:{ + role:"user", + content:[{tool_use_id:$id, type:"tool_result", + content:[{type:"text", text:("Command running in background with ID: " + $bid + ". Output is being written to: " + $out + ". You will be notified when it completes.")}]}] + }, + toolUseResult:{backgroundTaskId:$bid} + }' +} + emit_task_completion_event() { local task_id="$1" tool_use_id="$2" status="${3:-completed}" local notif @@ -1458,5 +1475,72 @@ run_stop_hook_with_input "$AC24_REPO" "$AC24_INPUT" "" "$TEST_DIR/bin/lsof-dead" rm -rf "/tmp/claude-${AC24_UID}/${AC24_SLUG}/ac24" 2>/dev/null || true assert_reached_codex "AC-24: dead/orphaned task (lsof no holder) is pruned; Codex review runs" +# ---------------- AC-25 ---------------- +# Session resume regression: when Claude resumes a session, the current +# transcript file has a NEW session id, but background tasks launched +# earlier physically wrote their .output files under the OLD session +# directory. The transcript launch message records the real path. The +# liveness probe must look at that real path, not at a path derived +# from the current transcript's session id, or orphaned dead tasks are +# never pruned. +echo "Test AC-25: liveness probe follows real output path from transcript on session resume" +AC25_REPO="$TEST_DIR/ac25" +create_full_fixture "$AC25_REPO" > /dev/null +AC25_UID=$(id -u) +AC25_SLUG=$(basename "$TRANSCRIPTS_DIR") +AC25_OLD_SESSION="aaaaaaaa-1111-2222-3333-444444444444" +AC25_NEW_SESSION="bbbbbbbb-5555-6666-7777-888888888888" +AC25_TASK_ID="shell_resumed_session" +AC25_REAL_OUTPUT="/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_OLD_SESSION}/tasks/${AC25_TASK_ID}.output" + +# Build the launch event with the real (old-session) output path embedded +# in the Claude Code launch message. +AC25_LAUNCH=$(emit_tool_use_assistant "toolu_AC25" "Bash" ',"command":"sleep 30"') +AC25_RESULT=$(emit_bg_shell_launch_result_with_output_path "toolu_AC25" "$AC25_TASK_ID" "$AC25_REAL_OUTPUT") + +# Write the transcript under the NEW session id (resume session). +AC25_TRANSCRIPT="/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_NEW_SESSION}.jsonl" +write_transcript "$AC25_TRANSCRIPT" "$AC25_LAUNCH" "$AC25_RESULT" + +# The real output file lives in the OLD session directory. +mkdir -p "$(dirname "$AC25_REAL_OUTPUT")" +touch "$AC25_REAL_OUTPUT" + +AC25_INPUT=$(jq -c -n --arg tp "$AC25_TRANSCRIPT" '{transcript_path:$tp}') +run_stop_hook_with_input "$AC25_REPO" "$AC25_INPUT" "" "$TEST_DIR/bin/lsof-dead" +rm -rf "/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_OLD_SESSION}" \ + "/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_NEW_SESSION}.jsonl" 2>/dev/null || true +assert_reached_codex "AC-25: dead task pruned using real output path from transcript, not derived new-session path" + +# ---------------- AC-25b ---------------- +# Same as AC-25, but the recorded output path contains a space. The +# regex used to extract the path must not stop at the first whitespace +# token, or it will fall back to the derived new-session path and the +# dead task will never be pruned. +echo "Test AC-25b: liveness probe handles whitespace in recorded output path" +AC25B_REPO="$TEST_DIR/ac25b" +create_full_fixture "$AC25B_REPO" > /dev/null +AC25B_UID=$(id -u) +AC25B_SLUG=$(basename "$TRANSCRIPTS_DIR") +AC25B_OLD_SESSION="aaaaaaaa-1111-2222-3333-444444444444" +AC25B_NEW_SESSION="bbbbbbbb-5555-6666-7777-888888888888" +AC25B_TASK_ID="shell_resumed_session_space" +AC25B_REAL_OUTPUT="/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_OLD_SESSION}/tasks/with space/${AC25B_TASK_ID}.output" + +AC25B_LAUNCH=$(emit_tool_use_assistant "toolu_AC25B" "Bash" ',"command":"sleep 30"') +AC25B_RESULT=$(emit_bg_shell_launch_result_with_output_path "toolu_AC25B" "$AC25B_TASK_ID" "$AC25B_REAL_OUTPUT") + +AC25B_TRANSCRIPT="/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_NEW_SESSION}.jsonl" +write_transcript "$AC25B_TRANSCRIPT" "$AC25B_LAUNCH" "$AC25B_RESULT" + +mkdir -p "$(dirname "$AC25B_REAL_OUTPUT")" +touch "$AC25B_REAL_OUTPUT" + +AC25B_INPUT=$(jq -c -n --arg tp "$AC25B_TRANSCRIPT" '{transcript_path:$tp}') +run_stop_hook_with_input "$AC25B_REPO" "$AC25B_INPUT" "" "$TEST_DIR/bin/lsof-dead" +rm -rf "/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_OLD_SESSION}" \ + "/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_NEW_SESSION}.jsonl" 2>/dev/null || true +assert_reached_codex "AC-25b: dead task pruned when real output path contains whitespace" + print_test_summary "Stop Hook Background-Task Allow Test Summary" exit $?