Skip to content

Commit 3711e5f

Browse files
committed
Extract bg-task helpers into dedicated lib and guard transcript_path survival
Move the six background-task helpers (expand_leading_tilde, extract_transcript_path, derive_loop_start_iso_ts, list/has/count_pending_background_task[_ids]) and the four hook guard blocks (ambiguous caller, cross-session parked, pending-bg short-circuit, stale-marker cleanup) out of hooks/lib/loop-common.sh and hooks/loop-codex-stop-hook.sh into a new hooks/lib/loop-bg-tasks.sh. The stop hook now delegates to a single handle_bg_task_short_circuit entry point; loop-common.sh sources the new lib so every existing consumer continues to see the helpers transparently. Add a regression test to tests/test-stop-gate.sh that asserts transcript_path is still forwarded to the hook when session_id is empty, freezing the jq object-collapse fix that replaced plain select(length > 0) field values with explicit if/then/else nulls.
1 parent 39f09c4 commit 3711e5f

4 files changed

Lines changed: 467 additions & 306 deletions

File tree

hooks/lib/loop-bg-tasks.sh

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Background-task helpers for the RLCR stop hook.
4+
#
5+
# Owns all logic that inspects the Claude Code transcript to decide
6+
# whether the hook should short-circuit (the main session is still
7+
# waiting on an asynchronous Agent/Bash dispatch), plus the four guard
8+
# blocks that the stop hook runs before its normal gate logic:
9+
#
10+
# 1. Ambiguous-caller marker guard
11+
# 2. Cross-session parked-loop guard
12+
# 3. Early exit: pending background tasks
13+
# 4. Same-session stale-marker cleanup
14+
#
15+
# Depends on loop-common.sh (FIELD_SESSION_ID, resolve_active_state_file)
16+
# being sourced first.
17+
#
18+
19+
# Source guard.
20+
[[ -n "${_LOOP_BG_TASKS_LOADED:-}" ]] && return 0 2>/dev/null || true
21+
_LOOP_BG_TASKS_LOADED=1
22+
23+
# Expand a leading "~" or "~/" in a path to "$HOME" without using eval.
24+
# Only the bare "~" and "~/..." forms are expanded; "~user/..." and every
25+
# other input (absolute path, relative path, empty string) is returned verbatim.
26+
#
27+
# Usage: expand_leading_tilde "$path"
28+
# Prints the normalized path to stdout.
29+
expand_leading_tilde() {
30+
local path="$1"
31+
case "$path" in
32+
'~') printf '%s' "${HOME:-}" ;;
33+
'~/'*) printf '%s/%s' "${HOME:-}" "${path#'~/'}" ;;
34+
*) printf '%s' "$path" ;;
35+
esac
36+
}
37+
38+
# Extract transcript_path from hook JSON input and expand any leading tilde.
39+
# Usage: extract_transcript_path "$json_input"
40+
# Outputs the transcript_path to stdout, or empty string if not available.
41+
extract_transcript_path() {
42+
local input="$1"
43+
local raw
44+
raw=$(printf '%s' "$input" | jq -r '.transcript_path // empty' 2>/dev/null || echo "")
45+
expand_leading_tilde "$raw"
46+
}
47+
48+
# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
49+
# UTC timestamp suitable for filtering transcript events.
50+
#
51+
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
52+
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
53+
# without `-u`). Claude transcript events carry actual UTC timestamps
54+
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
55+
# helper converts the local wall-clock parse back to a real UTC moment
56+
# via a two-step: parse local -> epoch seconds -> format in UTC.
57+
#
58+
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
59+
# second compared greater via lexical string ordering.
60+
#
61+
# Usage: derive_loop_start_iso_ts "$loop_dir"
62+
# Prints the ISO-8601 UTC timestamp, or empty string when the
63+
# basename does not match the expected format or the local `date`
64+
# binary cannot parse it.
65+
derive_loop_start_iso_ts() {
66+
local loop_dir="$1"
67+
local base
68+
base=$(basename "$loop_dir" 2>/dev/null || echo "")
69+
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
70+
return
71+
fi
72+
local local_datetime
73+
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"
74+
75+
# Local wall-clock -> epoch seconds. GNU `date -d` first,
76+
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
77+
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
78+
# loop-dir creation time.
79+
local epoch
80+
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
81+
if [[ -z "$epoch" ]]; then
82+
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
83+
fi
84+
if [[ -z "$epoch" ]]; then
85+
return
86+
fi
87+
88+
# Epoch -> UTC ISO-8601. Try GNU then BSD.
89+
local utc_iso
90+
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
91+
if [[ -z "$utc_iso" ]]; then
92+
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
93+
fi
94+
printf '%s' "$utc_iso"
95+
}
96+
97+
# Enumerate background-task ids that have been launched but not yet marked
98+
# completed in a Claude Code transcript.jsonl.
99+
#
100+
# Launch events (inspected in tool_result "user" messages):
101+
# - Background subagent: toolUseResult.isAsync == true
102+
# -> id is toolUseResult.agentId
103+
# - Background shell: toolUseResult.backgroundTaskId non-empty
104+
# -> id is toolUseResult.backgroundTaskId
105+
#
106+
# Completion events are recognised from two Claude Code transcript forms:
107+
#
108+
# 1. Structured SDK record
109+
# (see SDKTaskNotificationMessage in docs/typescript.md):
110+
# `type == "system"`, `subtype == "task_notification"`,
111+
# `task_id` is the completed id. Any `status` value
112+
# (completed, failed, stopped, ...) is treated as terminal.
113+
#
114+
# 2. Legacy queue-operation enqueue whose `content` embeds a
115+
# `<task-notification>` XML block with `<task-id>...</task-id>`;
116+
# kept for transcripts produced by older Claude Code versions.
117+
#
118+
# pending := launched \ completed
119+
#
120+
# Optional second argument `since_ts` (ISO-8601 string, e.g. the value
121+
# returned by `derive_loop_start_iso_ts`): when provided, only launch
122+
# events whose top-level `.timestamp` field is >= `since_ts` count as
123+
# candidate launches. Events without a `.timestamp` are included (keeps
124+
# fixture transcripts and older record formats working). This keeps
125+
# pre-loop session-wide background work from pinning an RLCR loop that
126+
# has no pending work of its own.
127+
#
128+
# Usage: list_pending_background_task_ids "$transcript_path" [since_ts]
129+
# - Outputs one id per line on stdout (possibly empty).
130+
# - Returns 0 when the transcript is readable (including when there are
131+
# no pending tasks). Returns 1 when the transcript path is empty, not
132+
# a regular file, or jq is unavailable, so callers must treat non-zero
133+
# as "unknown -> do not short-circuit".
134+
list_pending_background_task_ids() {
135+
local transcript_path="$1"
136+
local since_ts="${2:-}"
137+
138+
# Normalize a leading tilde so direct callers (tests, ad-hoc scripts)
139+
# work correctly even when transcript_path was not routed through
140+
# extract_transcript_path.
141+
transcript_path=$(expand_leading_tilde "$transcript_path")
142+
143+
if [[ -z "$transcript_path" ]] || [[ ! -f "$transcript_path" ]]; then
144+
return 1
145+
fi
146+
if ! command -v jq >/dev/null 2>&1; then
147+
return 1
148+
fi
149+
150+
local launched completed
151+
launched=$(jq -r --arg since_ts "$since_ts" '
152+
select(.toolUseResult != null)
153+
| select(
154+
($since_ts == ""
155+
or ((.timestamp // "") == "")
156+
or ((.timestamp // "") >= $since_ts))
157+
)
158+
| select(
159+
(.toolUseResult.isAsync == true and (.toolUseResult.agentId // "") != "")
160+
or ((.toolUseResult.backgroundTaskId // "") != "")
161+
)
162+
| (.toolUseResult.agentId // .toolUseResult.backgroundTaskId)
163+
' "$transcript_path" 2>/dev/null | sort -u) || return 1
164+
165+
# Union of both completion formats. Either source alone is enough to
166+
# mark a launched id terminal.
167+
#
168+
# The `grep -oE || true` guard on the legacy branch keeps `set -o
169+
# pipefail` from poisoning the combined pipeline when no legacy
170+
# queue-operation records exist in the transcript (grep with `-o`
171+
# exits 1 on no matches, which would otherwise wipe out any SDK
172+
# task_notification results collected above).
173+
completed=$(
174+
{
175+
jq -r '
176+
select(.type == "system" and .subtype == "task_notification")
177+
| (.task_id // empty)
178+
' "$transcript_path" 2>/dev/null
179+
jq -r '
180+
select(.type == "queue-operation" and .operation == "enqueue")
181+
| (.content // "" | tostring)
182+
| select(contains("<task-notification>"))
183+
' "$transcript_path" 2>/dev/null \
184+
| { grep -oE '<task-id>[^<]+</task-id>' || true; } \
185+
| sed -E 's|</?task-id>||g'
186+
} | sort -u | sed '/^$/d'
187+
) || completed=""
188+
189+
# Emit launched ids that have no matching completion notification.
190+
comm -23 \
191+
<(printf '%s\n' "$launched" | sed '/^$/d') \
192+
<(printf '%s\n' "$completed" | sed '/^$/d')
193+
}
194+
195+
# Returns 0 when the transcript shows at least one pending background task.
196+
# Returns 1 when no pending tasks are detected (including fail-closed cases
197+
# like missing transcript, non-file path, or jq unavailable).
198+
#
199+
# Usage: has_pending_background_tasks "$transcript_path" [since_ts]
200+
has_pending_background_tasks() {
201+
local transcript_path="$1"
202+
local since_ts="${2:-}"
203+
local pending
204+
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || return 1
205+
[[ -n "$pending" ]]
206+
}
207+
208+
# Prints the count of pending background tasks to stdout. Prints 0 for any
209+
# error case so callers can still format messages safely.
210+
#
211+
# Usage: count_pending_background_tasks "$transcript_path" [since_ts]
212+
count_pending_background_tasks() {
213+
local transcript_path="$1"
214+
local since_ts="${2:-}"
215+
local pending
216+
pending=$(list_pending_background_task_ids "$transcript_path" "$since_ts" 2>/dev/null) || {
217+
echo 0
218+
return 0
219+
}
220+
if [[ -z "$pending" ]]; then
221+
echo 0
222+
else
223+
printf '%s\n' "$pending" | sed '/^$/d' | wc -l | tr -d ' '
224+
fi
225+
}
226+
227+
# Single entry point for the stop hook: runs the four guard blocks
228+
# (ambiguous-caller, cross-session parked, pending-bg short-circuit,
229+
# same-session stale-marker cleanup) in order. When a guard decides to
230+
# short-circuit the stop hook, it emits the appropriate JSON on stdout
231+
# and `exit 0`s directly; the caller (sourcing the hook script) never
232+
# returns. When no guard fires, this function returns 0 and the stop
233+
# hook continues into its normal gate logic.
234+
#
235+
# Depends on FIELD_SESSION_ID and resolve_active_state_file from
236+
# loop-common.sh.
237+
#
238+
# Usage: handle_bg_task_short_circuit "$LOOP_DIR" "$HOOK_INPUT" "$HOOK_SESSION_ID"
239+
handle_bg_task_short_circuit() {
240+
local loop_dir="$1" hook_input="$2" hook_session_id="$3"
241+
242+
# Shared state used by the guard blocks below.
243+
# Loop-start boundary: derived from the loop dir basename
244+
# (`YYYY-MM-DD_HH-MM-SS`). Empty means derivation failed; helpers
245+
# treat empty since_ts as no boundary.
246+
local loop_start_ts transcript_path
247+
loop_start_ts=$(derive_loop_start_iso_ts "$loop_dir")
248+
transcript_path=$(extract_transcript_path "$hook_input")
249+
250+
# ----------------------------------------
251+
# Ambiguous-Caller Marker Guard
252+
# ----------------------------------------
253+
# If a bg-pending.marker is present but we have no session_id on
254+
# this hook invocation (typical of scripts/rlcr-stop-gate.sh
255+
# invoked without --session-id, or any other caller that doesn't
256+
# forward session_id), we cannot tell whether this caller owns the
257+
# parked loop. Taking either branch (foreign-session guard below,
258+
# or same-session cleanup further down) would be wrong in one of
259+
# the two possible realities. Exit 0 silently: the real Claude
260+
# hook will arrive with session_id populated and drive parking /
261+
# cleanup from an authoritative context.
262+
if [[ -f "$loop_dir/bg-pending.marker" ]] && [[ -z "$hook_session_id" ]]; then
263+
exit 0
264+
fi
265+
266+
# ----------------------------------------
267+
# Cross-Session Parked-Loop Guard
268+
# ----------------------------------------
269+
# If find_active_loop handed this dir over via the marker fallback,
270+
# the loop is parked by a different session waiting on a background
271+
# task. The current session has no authority to inspect or advance
272+
# that loop - its transcript sees none of the foreign bg activity -
273+
# so the only safe response is to exit 0 with a distinct
274+
# systemMessage and leave every on-disk artifact (state file,
275+
# stored session_id, marker) untouched.
276+
#
277+
# Both sides of the session-id comparison must be non-empty for
278+
# this branch to trigger: an empty hook_session_id has already
279+
# exited above via the ambiguous-caller guard, and an empty stored
280+
# session_id keeps the backward-compat "matches any" semantics
281+
# from find_active_loop.
282+
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
283+
local guard_state_file guard_stored_sid
284+
guard_state_file=$(resolve_active_state_file "$loop_dir")
285+
if [[ -n "$guard_state_file" ]]; then
286+
guard_stored_sid=$(sed -n '/^---$/,/^---$/{ /^'"${FIELD_SESSION_ID}"':/{ s/^'"${FIELD_SESSION_ID}"': *//; p; } }' "$guard_state_file" 2>/dev/null | tr -d ' ')
287+
if [[ -n "$guard_stored_sid" ]] \
288+
&& [[ -n "$hook_session_id" ]] \
289+
&& [[ "$guard_stored_sid" != "$hook_session_id" ]]; then
290+
jq -n \
291+
'{systemMessage: "RLCR loop in this repo is parked by another Claude session waiting for background work. Stop allowed; your session leaves the loop untouched. If that session ended, run /humanize:cancel-rlcr-loop to clean up."}'
292+
exit 0
293+
fi
294+
fi
295+
fi
296+
297+
# ----------------------------------------
298+
# Early Exit: Pending Background Tasks
299+
# ----------------------------------------
300+
# When the main Claude Code session has dispatched background work
301+
# (Agent with run_in_background=true, or Bash with
302+
# run_in_background=true) whose completion notifications have not
303+
# yet arrived, the natural "stop" is simply "I am waiting for the
304+
# background task". Running git/summary/BitLesson/Codex gates in
305+
# that state wastes Codex tokens and produces low-signal reviews.
306+
#
307+
# Allow the stop (exit 0) and emit a user-visible systemMessage so
308+
# nobody mistakes the pause for loop completion. The on-disk loop
309+
# state is left untouched -- the next natural stop (after
310+
# background work finishes) will re-enter this hook with no
311+
# pending tasks and run the normal flow.
312+
#
313+
# loop_start_ts confines the transcript scan to launches that
314+
# actually happened during this loop; earlier session-wide bg
315+
# activity cannot pin the loop.
316+
#
317+
# This check MUST run before any other gate (phase detection,
318+
# state parsing, branch / plan / git-clean / summary / max-iter
319+
# checks, Codex review).
320+
local pending_bg_ids
321+
pending_bg_ids=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) || true
322+
if [[ -n "$pending_bg_ids" ]]; then
323+
local pending_bg_count
324+
pending_bg_count=$(printf '%s\n' "$pending_bg_ids" | sed '/^$/d' | wc -l | tr -d ' ')
325+
# Mark the loop as parked; allows the same session to resume
326+
# later and makes the cross-session guard above reachable if
327+
# the user opens a different Claude session in this repo
328+
# before the bg task completes.
329+
: > "$loop_dir/bg-pending.marker" 2>/dev/null || true
330+
jq -n --arg count "$pending_bg_count" \
331+
'{systemMessage: ("RLCR loop active. " + $count + " background task(s) still running - stop allowed naturally; loop has NOT terminated and will resume on completion.")}'
332+
exit 0
333+
fi
334+
335+
# ----------------------------------------
336+
# Same-Session Stale-Marker Cleanup
337+
# ----------------------------------------
338+
# The cross-session guard above already exited for every foreign
339+
# session, so reaching here with the marker present means the
340+
# CURRENT session parked the loop and has now come back with a
341+
# transcript showing no pending bg events. Remove the stale marker
342+
# before the normal flow takes over.
343+
#
344+
# Two-part guard to make sure we never drop the parked-state
345+
# signal without evidence:
346+
# (a) list_pending_background_task_ids returned exit 0 -- the
347+
# transcript was present, readable, AND parsed successfully.
348+
# The helper is fail-closed on missing files, empty paths,
349+
# jq parse failure, and truncation, so a non-zero exit
350+
# blocks cleanup here even when the transcript "file"
351+
# exists.
352+
# (b) its output is empty -- proves "no pending" was
353+
# authoritatively verified, not inferred from a failure.
354+
# The check uses a single fresh call so we capture both the exit
355+
# code and the emptiness without double-running jq.
356+
if [[ -f "$loop_dir/bg-pending.marker" ]]; then
357+
local pending_bg_check
358+
if pending_bg_check=$(list_pending_background_task_ids "$transcript_path" "$loop_start_ts" 2>/dev/null) \
359+
&& [[ -z "$pending_bg_check" ]]; then
360+
rm -f "$loop_dir/bg-pending.marker" 2>/dev/null || true
361+
fi
362+
fi
363+
}

0 commit comments

Comments
 (0)