Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
75 changes: 68 additions & 7 deletions hooks/lib/loop-bg-tasks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,57 @@ 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 trailing " You will be notified when it completes." sentence is
# optional: some launch records omit it, and the parser must still recover
# <path>.
#
# 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 the text from that
# prefix up to the closing JSON string quote: the output path never
# contains a double quote, so [^"]* bounds it exactly, including paths
# that contain spaces or other characters a [^[:space:]]+ pattern would
# reject. The optional trailing ". You will be notified when it
# completes." sentence is stripped afterwards. That suffix is emitted by
# most Claude Code versions, but some launch records omit it; requiring
# it caused the real output path to be missed on session resume, so
# is_bg_task_alive fell back to the derived (wrong) current-session path
# and dead/orphaned tasks stayed pending forever.
match=$(grep -F "$task_id" "$transcript_path" 2>/dev/null \
| grep -oE 'Output is being written to: [^"]*' \
Comment thread
zevorn marked this conversation as resolved.
Outdated
| head -n1) || true
[[ -z "$match" ]] && return

local path
path="${match#Output is being written to: }"
# Strip the optional notification suffix; a no-op when absent.
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 +178,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 +204,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 +318,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
125 changes: 125 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,30 @@ 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" include_suffix="${4:-1}"
local suffix
if [[ "$include_suffix" == "1" ]]; then
suffix=". You will be notified when it completes."
else
suffix=""
fi
jq -c -n \
--arg id "$tool_use_id" \
--arg bid "$bg_task_id" \
--arg out "$output_path" \
--arg suffix "$suffix" \
'{
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 + $suffix)}]}]
},
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 +1482,106 @@ 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"

# ---------------- AC-25c ----------------
# Some Claude Code launch records omit the trailing
# "You will be notified when it completes." sentence. The path extractor must
# still recover the real output path from the JSON string (bounded by the
# closing quote) instead of returning no match. A no-match would make
# is_bg_task_alive fall back to the derived current-session path, so the real
# old-session output file is never probed and the dead task stays pending
# forever -- the exact session-resume regression AC-25 guards against.
echo "Test AC-25c: liveness probe recovers real path when launch message omits notification suffix"
AC25C_REPO="$TEST_DIR/ac25c"
create_full_fixture "$AC25C_REPO" > /dev/null
AC25C_UID=$(id -u)
AC25C_SLUG=$(basename "$TRANSCRIPTS_DIR")
AC25C_OLD_SESSION="aaaaaaaa-1111-2222-3333-444444444444"
AC25C_NEW_SESSION="bbbbbbbb-5555-6666-7777-888888888888"
AC25C_TASK_ID="shell_resumed_session_no_suffix"
AC25C_REAL_OUTPUT="/tmp/claude-${AC25C_UID}/${AC25C_SLUG}/${AC25C_OLD_SESSION}/tasks/${AC25C_TASK_ID}.output"

AC25C_LAUNCH=$(emit_tool_use_assistant "toolu_AC25C" "Bash" ',"command":"sleep 30"')
# Fourth arg "0" -> emit the launch message WITHOUT the notification suffix.
AC25C_RESULT=$(emit_bg_shell_launch_result_with_output_path "toolu_AC25C" "$AC25C_TASK_ID" "$AC25C_REAL_OUTPUT" 0)

AC25C_TRANSCRIPT="/tmp/claude-${AC25C_UID}/${AC25C_SLUG}/${AC25C_NEW_SESSION}.jsonl"
write_transcript "$AC25C_TRANSCRIPT" "$AC25C_LAUNCH" "$AC25C_RESULT"

mkdir -p "$(dirname "$AC25C_REAL_OUTPUT")"
touch "$AC25C_REAL_OUTPUT"

AC25C_INPUT=$(jq -c -n --arg tp "$AC25C_TRANSCRIPT" '{transcript_path:$tp}')
run_stop_hook_with_input "$AC25C_REPO" "$AC25C_INPUT" "" "$TEST_DIR/bin/lsof-dead"
rm -rf "/tmp/claude-${AC25C_UID}/${AC25C_SLUG}/${AC25C_OLD_SESSION}" \
"/tmp/claude-${AC25C_UID}/${AC25C_SLUG}/${AC25C_NEW_SESSION}.jsonl" 2>/dev/null || true
assert_reached_codex "AC-25c: dead task pruned when launch message omits the notification suffix"

print_test_summary "Stop Hook Background-Task Allow Test Summary"
exit $?
Loading