Skip to content

Commit 82f8509

Browse files
committed
Recognize TaskStop'd bg tasks and harden cross-session liveness
Fix A - TaskStop completion recognition: list_pending_background_task_ids now treats a TaskStop tool_result (.toolUseResult.message "Successfully stopped task: <id>") as a terminal completion event, because many Claude Code builds do not also emit a task_notification system record for a TaskStop. The id is read from .task_id, falling back to parsing it out of the message text when that field is absent. The message field is coerced with tostring before contains() so unrelated tool results carrying a structured (object/array/number) .message do not error and wipe the whole completed set under pipefail. Fix B - cross-session liveness glob: is_bg_task_alive now searches sibling session dirs under the same project slug when neither the transcript-recorded output path nor the session-derived path exists. This only expands where we look, so a genuinely alive task is never wrongly pruned; a file missing everywhere still fails open as before. Builds on the extract_bg_task_output_path_from_transcript helper landed in #215. Tests: 51/51. Renames existing AC-NN cases to descriptive names and adds regression coverage for TaskStop completion, the cross-session glob, and non-string .message poisoning.
1 parent 155bfe8 commit 82f8509

2 files changed

Lines changed: 1021 additions & 681 deletions

File tree

hooks/lib/loop-bg-tasks.sh

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,27 @@ is_bg_task_alive() {
183183
fi
184184
[[ -n "$output_file" ]] || output_file="$tasks_dir/$task_id.output"
185185

186+
# Session-resume fallback: when the transcript has no extractable
187+
# launch path AND the session-derived path does not exist, the real
188+
# .output file likely lives under a DIFFERENT session id (the one that
189+
# launched the task). Search sibling session dirs under the same
190+
# project slug before giving up. This only EXPANDS where we look, so a
191+
# genuinely alive task is never wrongly pruned (its file exists and
192+
# lsof still sees the open fd); a file missing everywhere remains
193+
# genuinely unknown and fails open below as before.
194+
if [[ ! -e "$output_file" ]] && [[ -n "$tasks_dir" ]]; then
195+
local slug_dir _candidate
196+
slug_dir=$(dirname "$(dirname "$tasks_dir")") # /tmp/claude-<uid>/<slug>
197+
if [[ -d "$slug_dir" ]]; then
198+
for _candidate in "$slug_dir"/*/tasks/"$task_id".output; do
199+
if [[ -f "$_candidate" ]]; then
200+
output_file="$_candidate"
201+
break
202+
fi
203+
done
204+
fi
205+
fi
206+
186207
# Output file absent -> fail open (treat as still running).
187208
[[ -f "$output_file" ]] || return 0
188209
# lsof unavailable -> fail open.
@@ -213,7 +234,7 @@ prune_dead_bg_task_ids() {
213234
# - Background shell: toolUseResult.backgroundTaskId non-empty
214235
# -> id is toolUseResult.backgroundTaskId
215236
#
216-
# Completion events are recognised from two Claude Code transcript forms:
237+
# Completion events are recognised from three Claude Code transcript forms:
217238
#
218239
# 1. Structured SDK record
219240
# (see SDKTaskNotificationMessage in docs/typescript.md):
@@ -225,6 +246,15 @@ prune_dead_bg_task_ids() {
225246
# `<task-notification>` XML block with `<task-id>...</task-id>`;
226247
# kept for transcripts produced by older Claude Code versions.
227248
#
249+
# 3. TaskStop tool_result: when the model or user stops a background
250+
# task via the TaskStop tool, Claude Code records a top-level
251+
# `.toolUseResult` whose `.message` is "Successfully stopped task:
252+
# <id>" and (usually) whose `.task_id` is the stopped id. The id is
253+
# read from `.task_id` when present and otherwise parsed out of the
254+
# message text as a fallback. Many builds do NOT also emit a
255+
# `task_notification` system event for a TaskStop, so this source is
256+
# required or stopped tasks stay pending forever.
257+
#
228258
# pending := launched \ completed
229259
#
230260
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
@@ -272,7 +302,7 @@ list_pending_background_task_ids() {
272302
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
273303
' "$transcript_path" 2>/dev/null | sort -u) || return 1
274304

275-
# Union of both completion formats. Either source alone is enough to
305+
# Union of all completion formats. Any one source alone is enough to
276306
# mark a launched id terminal.
277307
#
278308
# The `grep -oE || true` guard on the legacy branch keeps `set -o
@@ -293,6 +323,28 @@ list_pending_background_task_ids() {
293323
' "$transcript_path" 2>/dev/null \
294324
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
295325
| sed -E 's|</?task-id>||g'
326+
# TaskStop tool_result (source 3 above): the model/user stopped
327+
# a background task. Match the top-level .toolUseResult whose
328+
# .message is "Successfully stopped task: <id>" and emit its id.
329+
# Required because many builds do not also emit a task_notification
330+
# system event for a TaskStop. The id is read from .task_id when
331+
# present; otherwise it is parsed out of the message text as a
332+
# fallback, because some builds record the id only there. The
333+
# message is coerced with `tostring` before `contains()` because
334+
# unrelated toolUseResult records may carry a structured (object,
335+
# array, number) `.message`, and `contains()` errors on non-strings;
336+
# without the coercion one bad record would wipe the whole `completed`
337+
# set under pipefail. The `try ... catch empty` keeps a degenerate
338+
# message (prefix with no id, so the regex cannot match) from raising.
339+
jq -r '
340+
select(.toolUseResult != null)
341+
| ((.toolUseResult.message // "") | tostring) as $msg
342+
| select($msg | contains("Successfully stopped task:"))
343+
| (.toolUseResult.task_id
344+
// try ($msg
345+
| capture("Successfully stopped task: (?<i>[^ (]+)")
346+
| .i) catch empty)
347+
' "$transcript_path" 2>/dev/null
296348
} | sort -u | sed '/^$/d'
297349
) || completed=""
298350

0 commit comments

Comments
 (0)