Skip to content

Commit 155bfe8

Browse files
committed
Fix bg task output path extraction on session resume
When a Claude Code session is resumed, the current transcript's session id differs from the session that launched a background task, so the task's .output file lives under the old session dir while the stop hook probed a path derived from the new transcript. Dead/orphaned tasks were never pruned, blocking the loop from reaching Codex review. Teach is_bg_task_alive to extract the real output path recorded in the transcript launch message, piping an optional transcript_path arg through prune_dead_bg_task_ids and list_pending_background_task_ids. Falls back to the derived tasks_dir when the transcript is unreadable or the message is missing. Add regression tests AC-25 (session resume) and AC-25b (whitespace in recorded output path).
1 parent eec73c4 commit 155bfe8

2 files changed

Lines changed: 142 additions & 7 deletions

File tree

hooks/lib/loop-bg-tasks.sh

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,47 @@ derive_tasks_dir_from_transcript() {
117117
printf '/tmp/claude-%s/%s/%s/tasks' "$uid" "$slug" "$sid"
118118
}
119119

120+
# Extract the real background-task output file path recorded in the
121+
# transcript launch message.
122+
#
123+
# Claude Code records background Bash launches in the tool_result message
124+
# text as:
125+
# "Command running in background with ID: <task-id>. Output is being
126+
# written to: <path>. You will be notified when it completes."
127+
#
128+
# The <path> is authoritative: when a Claude session is resumed or
129+
# continued, the current transcript may have a different session id from
130+
# the session under which the task was launched, so the output file does
131+
# NOT live under derive_tasks_dir_from_transcript(current transcript).
132+
# Falling back to the derived directory causes dead/orphaned tasks to be
133+
# treated as alive forever.
134+
#
135+
# Usage: extract_bg_task_output_path_from_transcript "$transcript_path" "$task_id"
136+
# Prints the absolute output file path, or nothing when the transcript
137+
# is unreadable or the launch message does not contain a path.
138+
extract_bg_task_output_path_from_transcript() {
139+
local transcript_path="$1" task_id="$2"
140+
[[ -z "$transcript_path" ]] && return
141+
[[ -f "$transcript_path" ]] || return
142+
[[ -z "$task_id" ]] && return
143+
144+
local match
145+
# Grep the JSONL line that mentions this task id and contains the
146+
# literal "Output is being written to". The path is everything between
147+
# that prefix and the fixed trailing sentence " You will be notified
148+
# when it completes." This handles paths that contain spaces or other
149+
# characters that a simple [^[:space:]]+ pattern would reject.
150+
match=$(grep -F "$task_id" "$transcript_path" 2>/dev/null \
151+
| grep -oE 'Output is being written to: .*\. You will be notified when it completes\.' \
152+
| head -n1) || true
153+
[[ -z "$match" ]] && return
154+
155+
local path
156+
path="${match#Output is being written to: }"
157+
path="${path%. You will be notified when it completes.}"
158+
expand_leading_tilde "$path"
159+
}
160+
120161
# Returns 0 if the background task identified by task_id appears to be alive
121162
# (output file absent, or lsof reports >= 1 holder), 1 if confirmed dead
122163
# (output file exists and lsof reports 0 holders).
@@ -127,11 +168,21 @@ derive_tasks_dir_from_transcript() {
127168
#
128169
# Set LSOF_BIN to override the lsof binary path (used in tests).
129170
#
130-
# Usage: is_bg_task_alive "$task_id" "$tasks_dir"
171+
# Usage: is_bg_task_alive "$task_id" "$tasks_dir" [transcript_path]
131172
is_bg_task_alive() {
132-
local task_id="$1" tasks_dir="$2"
173+
local task_id="$1" tasks_dir="$2" transcript_path="${3:-}"
133174
local lsof_bin="${LSOF_BIN:-lsof}"
134-
local output_file="$tasks_dir/$task_id.output"
175+
local output_file
176+
177+
# Prefer the real output path recorded in the transcript launch
178+
# message; fall back to the derived tasks_dir. This matters when a
179+
# Claude session has been resumed/continued and the current
180+
# transcript's session id differs from the launch session id.
181+
if [[ -n "$transcript_path" ]]; then
182+
output_file=$(extract_bg_task_output_path_from_transcript "$transcript_path" "$task_id")
183+
fi
184+
[[ -n "$output_file" ]] || output_file="$tasks_dir/$task_id.output"
185+
135186
# Output file absent -> fail open (treat as still running).
136187
[[ -f "$output_file" ]] || return 0
137188
# lsof unavailable -> fail open.
@@ -143,13 +194,13 @@ is_bg_task_alive() {
143194
# Filter a newline-delimited list of task IDs, retaining only those that
144195
# pass is_bg_task_alive. Prints surviving IDs one per line.
145196
#
146-
# Usage: prune_dead_bg_task_ids "$pending_ids" "$tasks_dir"
197+
# Usage: prune_dead_bg_task_ids "$pending_ids" "$tasks_dir" [transcript_path]
147198
prune_dead_bg_task_ids() {
148-
local pending_ids="$1" tasks_dir="$2"
199+
local pending_ids="$1" tasks_dir="$2" transcript_path="${3:-}"
149200
local task_id
150201
while IFS= read -r task_id; do
151202
[[ -z "$task_id" ]] && continue
152-
is_bg_task_alive "$task_id" "$tasks_dir" && printf '%s\n' "$task_id"
203+
is_bg_task_alive "$task_id" "$tasks_dir" "$transcript_path" && printf '%s\n' "$task_id"
153204
done <<< "$pending_ids"
154205
}
155206

@@ -257,7 +308,7 @@ list_pending_background_task_ids() {
257308
local tasks_dir
258309
tasks_dir=$(derive_tasks_dir_from_transcript "$transcript_path")
259310
if [[ -n "$tasks_dir" ]]; then
260-
pending=$(prune_dead_bg_task_ids "$pending" "$tasks_dir")
311+
pending=$(prune_dead_bg_task_ids "$pending" "$tasks_dir" "$transcript_path")
261312
fi
262313
fi
263314

tests/test-stop-hook-bg-allow.sh

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,23 @@ emit_bg_shell_launch_result() {
237237
}'
238238
}
239239

240+
emit_bg_shell_launch_result_with_output_path() {
241+
local tool_use_id="$1" bg_task_id="$2" output_path="$3"
242+
jq -c -n \
243+
--arg id "$tool_use_id" \
244+
--arg bid "$bg_task_id" \
245+
--arg out "$output_path" \
246+
'{
247+
type:"user",
248+
message:{
249+
role:"user",
250+
content:[{tool_use_id:$id, type:"tool_result",
251+
content:[{type:"text", text:("Command running in background with ID: " + $bid + ". Output is being written to: " + $out + ". You will be notified when it completes.")}]}]
252+
},
253+
toolUseResult:{backgroundTaskId:$bid}
254+
}'
255+
}
256+
240257
emit_task_completion_event() {
241258
local task_id="$1" tool_use_id="$2" status="${3:-completed}"
242259
local notif
@@ -1458,5 +1475,72 @@ run_stop_hook_with_input "$AC24_REPO" "$AC24_INPUT" "" "$TEST_DIR/bin/lsof-dead"
14581475
rm -rf "/tmp/claude-${AC24_UID}/${AC24_SLUG}/ac24" 2>/dev/null || true
14591476
assert_reached_codex "AC-24: dead/orphaned task (lsof no holder) is pruned; Codex review runs"
14601477

1478+
# ---------------- AC-25 ----------------
1479+
# Session resume regression: when Claude resumes a session, the current
1480+
# transcript file has a NEW session id, but background tasks launched
1481+
# earlier physically wrote their .output files under the OLD session
1482+
# directory. The transcript launch message records the real path. The
1483+
# liveness probe must look at that real path, not at a path derived
1484+
# from the current transcript's session id, or orphaned dead tasks are
1485+
# never pruned.
1486+
echo "Test AC-25: liveness probe follows real output path from transcript on session resume"
1487+
AC25_REPO="$TEST_DIR/ac25"
1488+
create_full_fixture "$AC25_REPO" > /dev/null
1489+
AC25_UID=$(id -u)
1490+
AC25_SLUG=$(basename "$TRANSCRIPTS_DIR")
1491+
AC25_OLD_SESSION="aaaaaaaa-1111-2222-3333-444444444444"
1492+
AC25_NEW_SESSION="bbbbbbbb-5555-6666-7777-888888888888"
1493+
AC25_TASK_ID="shell_resumed_session"
1494+
AC25_REAL_OUTPUT="/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_OLD_SESSION}/tasks/${AC25_TASK_ID}.output"
1495+
1496+
# Build the launch event with the real (old-session) output path embedded
1497+
# in the Claude Code launch message.
1498+
AC25_LAUNCH=$(emit_tool_use_assistant "toolu_AC25" "Bash" ',"command":"sleep 30"')
1499+
AC25_RESULT=$(emit_bg_shell_launch_result_with_output_path "toolu_AC25" "$AC25_TASK_ID" "$AC25_REAL_OUTPUT")
1500+
1501+
# Write the transcript under the NEW session id (resume session).
1502+
AC25_TRANSCRIPT="/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_NEW_SESSION}.jsonl"
1503+
write_transcript "$AC25_TRANSCRIPT" "$AC25_LAUNCH" "$AC25_RESULT"
1504+
1505+
# The real output file lives in the OLD session directory.
1506+
mkdir -p "$(dirname "$AC25_REAL_OUTPUT")"
1507+
touch "$AC25_REAL_OUTPUT"
1508+
1509+
AC25_INPUT=$(jq -c -n --arg tp "$AC25_TRANSCRIPT" '{transcript_path:$tp}')
1510+
run_stop_hook_with_input "$AC25_REPO" "$AC25_INPUT" "" "$TEST_DIR/bin/lsof-dead"
1511+
rm -rf "/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_OLD_SESSION}" \
1512+
"/tmp/claude-${AC25_UID}/${AC25_SLUG}/${AC25_NEW_SESSION}.jsonl" 2>/dev/null || true
1513+
assert_reached_codex "AC-25: dead task pruned using real output path from transcript, not derived new-session path"
1514+
1515+
# ---------------- AC-25b ----------------
1516+
# Same as AC-25, but the recorded output path contains a space. The
1517+
# regex used to extract the path must not stop at the first whitespace
1518+
# token, or it will fall back to the derived new-session path and the
1519+
# dead task will never be pruned.
1520+
echo "Test AC-25b: liveness probe handles whitespace in recorded output path"
1521+
AC25B_REPO="$TEST_DIR/ac25b"
1522+
create_full_fixture "$AC25B_REPO" > /dev/null
1523+
AC25B_UID=$(id -u)
1524+
AC25B_SLUG=$(basename "$TRANSCRIPTS_DIR")
1525+
AC25B_OLD_SESSION="aaaaaaaa-1111-2222-3333-444444444444"
1526+
AC25B_NEW_SESSION="bbbbbbbb-5555-6666-7777-888888888888"
1527+
AC25B_TASK_ID="shell_resumed_session_space"
1528+
AC25B_REAL_OUTPUT="/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_OLD_SESSION}/tasks/with space/${AC25B_TASK_ID}.output"
1529+
1530+
AC25B_LAUNCH=$(emit_tool_use_assistant "toolu_AC25B" "Bash" ',"command":"sleep 30"')
1531+
AC25B_RESULT=$(emit_bg_shell_launch_result_with_output_path "toolu_AC25B" "$AC25B_TASK_ID" "$AC25B_REAL_OUTPUT")
1532+
1533+
AC25B_TRANSCRIPT="/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_NEW_SESSION}.jsonl"
1534+
write_transcript "$AC25B_TRANSCRIPT" "$AC25B_LAUNCH" "$AC25B_RESULT"
1535+
1536+
mkdir -p "$(dirname "$AC25B_REAL_OUTPUT")"
1537+
touch "$AC25B_REAL_OUTPUT"
1538+
1539+
AC25B_INPUT=$(jq -c -n --arg tp "$AC25B_TRANSCRIPT" '{transcript_path:$tp}')
1540+
run_stop_hook_with_input "$AC25B_REPO" "$AC25B_INPUT" "" "$TEST_DIR/bin/lsof-dead"
1541+
rm -rf "/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_OLD_SESSION}" \
1542+
"/tmp/claude-${AC25B_UID}/${AC25B_SLUG}/${AC25B_NEW_SESSION}.jsonl" 2>/dev/null || true
1543+
assert_reached_codex "AC-25b: dead task pruned when real output path contains whitespace"
1544+
14611545
print_test_summary "Stop Hook Background-Task Allow Test Summary"
14621546
exit $?

0 commit comments

Comments
 (0)