Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 58 additions & 7 deletions hooks/lib/loop-bg-tasks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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: <task-id>. Output is being
# written to: <path>. You will be notified when it completes."
#
# The <path> 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).
Expand All @@ -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.
Expand All @@ -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"
}

Expand Down Expand Up @@ -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

Expand Down
84 changes: 84 additions & 0 deletions tests/test-stop-hook-bg-allow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 $?