Skip to content

Commit 9c80698

Browse files
committed
Normalize loop-start boundary into actual UTC
setup-rlcr-loop.sh creates loop dirs named YYYY-MM-DD_HH-MM-SS with `date +%Y-%m-%d_%H-%M-%S` (no -u), so the basename is local wall clock. The previous derive_loop_start_iso_ts pasted `.000Z` on top of that basename, treating the local wall clock as if it were already UTC. Claude transcript events carry real UTC timestamps, so on any non-UTC machine the filter boundary was shifted by the local offset: west-of-UTC users inherited pre-loop background launches and stayed parked forever, east-of-UTC users missed in-loop background work and ran Codex review too early. Fix in hooks/lib/loop-common.sh: * derive_loop_start_iso_ts now does a two-step local -> epoch -> UTC conversion. Local wall clock is parsed into epoch seconds with `date -d` (GNU) or `date -j -f` (BSD/macOS), then the epoch is formatted in UTC as YYYY-MM-DDTHH:MM:SS.000Z with `date -u -d "@<epoch>"` (GNU) or `date -u -r <epoch>` (BSD/macOS). Any failure yields an empty string, which disables the filter in callers -- same backward-compat behaviour as before. Regressions in tests/test-stop-hook-bg-allow.sh: AC-21b pinned to `export TZ=UTC` inside its subshell so the expected 2026-03-01T00:00:00.000Z is TZ-deterministic. AC-21d NEW. TZ=Asia/Tokyo + basename 2026-03-01_09-00-00 -> 2026-03-01T00:00:00.000Z (9am JST = 0am UTC). AC-21e NEW. TZ=America/Los_Angeles + basename 2026-03-01_00-00-00 -> 2026-03-01T08:00:00.000Z (0am PST = 8am UTC; March 1 is before DST starts on March 8, 2026). Validation: - bash tests/test-stop-hook-bg-allow.sh -> 39 passed, 0 failed - bash tests/run-all-tests.sh -> 1718 passed, 0 failed - TZ=America/Los_Angeles bash tests/test-stop-hook-bg-allow.sh -> 39 passed, 0 failed - TZ=Asia/Tokyo bash tests/test-stop-hook-bg-allow.sh -> 39 passed, 0 failed - HOME=/nonexistent/readonly bash tests/test-stop-hook-bg-allow.sh -> 39 passed, 0 failed systemMessage wording unchanged. Version stays at 1.16.0.
1 parent 7538f74 commit 9c80698

2 files changed

Lines changed: 74 additions & 17 deletions

File tree

hooks/lib/loop-common.sh

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -268,28 +268,52 @@ extract_transcript_path() {
268268
}
269269

270270
# Convert an RLCR loop dir basename to a lexically-comparable ISO-8601
271-
# timestamp suitable for filtering transcript events.
271+
# UTC timestamp suitable for filtering transcript events.
272272
#
273-
# The setup script creates loop dirs named `YYYY-MM-DD_HH-MM-SS`; real
274-
# Claude transcript events carry timestamps like `2026-04-16T13:19:26.819Z`.
275-
# String comparison works cleanly once we pad the loop boundary with
276-
# `.000Z` so sub-second transcript timestamps in the same second always
277-
# compare greater.
273+
# `setup-rlcr-loop.sh` creates loop dirs named `YYYY-MM-DD_HH-MM-SS` in
274+
# the system's LOCAL wall clock (it calls `date +%Y-%m-%d_%H-%M-%S`
275+
# without `-u`). Claude transcript events carry actual UTC timestamps
276+
# like `2026-04-16T13:19:26.819Z`. To compare them correctly, this
277+
# helper converts the local wall-clock parse back to a real UTC moment
278+
# via a two-step: parse local -> epoch seconds -> format in UTC.
279+
#
280+
# The `.000Z` suffix keeps sub-second transcript timestamps in the same
281+
# second compared greater via lexical string ordering.
278282
#
279283
# Usage: derive_loop_start_iso_ts "$loop_dir"
280-
# Prints the ISO-8601 timestamp, or empty string when the basename does
281-
# not match the expected format.
284+
# Prints the ISO-8601 UTC timestamp, or empty string when the
285+
# basename does not match the expected format or the local `date`
286+
# binary cannot parse it.
282287
derive_loop_start_iso_ts() {
283288
local loop_dir="$1"
284289
local base
285290
base=$(basename "$loop_dir" 2>/dev/null || echo "")
286-
if [[ "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
287-
printf '%sT%s:%s:%s.000Z' \
288-
"${BASH_REMATCH[1]}" \
289-
"${BASH_REMATCH[2]}" \
290-
"${BASH_REMATCH[3]}" \
291-
"${BASH_REMATCH[4]}"
291+
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})$ ]]; then
292+
return
293+
fi
294+
local local_datetime
295+
local_datetime="${BASH_REMATCH[1]} ${BASH_REMATCH[2]}:${BASH_REMATCH[3]}:${BASH_REMATCH[4]}"
296+
297+
# Local wall-clock -> epoch seconds. GNU `date -d` first,
298+
# BSD/macOS `date -j -f ...` second. Both honour the caller's TZ
299+
# for interpretation, matching setup-rlcr-loop.sh's behaviour at
300+
# loop-dir creation time.
301+
local epoch
302+
epoch=$(date -d "$local_datetime" +%s 2>/dev/null) || epoch=""
303+
if [[ -z "$epoch" ]]; then
304+
epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$local_datetime" +%s 2>/dev/null) || epoch=""
305+
fi
306+
if [[ -z "$epoch" ]]; then
307+
return
308+
fi
309+
310+
# Epoch -> UTC ISO-8601. Try GNU then BSD.
311+
local utc_iso
312+
utc_iso=$(date -u -d "@$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
313+
if [[ -z "$utc_iso" ]]; then
314+
utc_iso=$(date -u -r "$epoch" "+%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null) || utc_iso=""
292315
fi
316+
printf '%s' "$utc_iso"
293317
}
294318

295319
# Enumerate background-task ids that have been launched but not yet marked

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,19 +1214,52 @@ else
12141214
fi
12151215

12161216
# AC-21b: confirm the derive helper produces the expected ISO-8601 form
1217-
# so real callers get a matching boundary.
1217+
# under TZ=UTC, where local wall clock == UTC so no offset is applied.
12181218
AC21B_DERIVED=$(
12191219
# shellcheck source=/dev/null
12201220
source "$PROJECT_ROOT/hooks/lib/loop-common.sh"
1221+
export TZ="UTC"
12211222
derive_loop_start_iso_ts "/tmp/.humanize/rlcr/2026-03-01_00-00-00"
12221223
)
12231224
if [[ "$AC21B_DERIVED" == "2026-03-01T00:00:00.000Z" ]]; then
1224-
pass "AC-21b: derive_loop_start_iso_ts emits ISO-8601 with .000Z suffix"
1225+
pass "AC-21b: derive_loop_start_iso_ts under TZ=UTC preserves the wall-clock"
12251226
else
1226-
fail "AC-21b: derive_loop_start_iso_ts emits ISO-8601 with .000Z suffix" \
1227+
fail "AC-21b: derive_loop_start_iso_ts under TZ=UTC preserves the wall-clock" \
12271228
"2026-03-01T00:00:00.000Z" "$AC21B_DERIVED"
12281229
fi
12291230

1231+
# AC-21d: setup-rlcr-loop.sh names the dir with local wall clock, so a
1232+
# non-UTC caller must see the boundary shifted into actual UTC.
1233+
# JST (UTC+9) example: 09:00 JST == 00:00 UTC.
1234+
AC21D_DERIVED=$(
1235+
# shellcheck source=/dev/null
1236+
source "$PROJECT_ROOT/hooks/lib/loop-common.sh"
1237+
export TZ="Asia/Tokyo"
1238+
derive_loop_start_iso_ts "/tmp/.humanize/rlcr/2026-03-01_09-00-00"
1239+
)
1240+
if [[ "$AC21D_DERIVED" == "2026-03-01T00:00:00.000Z" ]]; then
1241+
pass "AC-21d: derive_loop_start_iso_ts converts JST wall-clock to correct UTC"
1242+
else
1243+
fail "AC-21d: derive_loop_start_iso_ts converts JST wall-clock to correct UTC" \
1244+
"2026-03-01T00:00:00.000Z (9am JST = 0am UTC)" "$AC21D_DERIVED"
1245+
fi
1246+
1247+
# AC-21e: PST (UTC-8) example. Pick March 1 which is still PST (DST
1248+
# does not start until March 8, 2026), so the offset is a fixed -8h:
1249+
# 00:00 PST == 08:00 UTC.
1250+
AC21E_DERIVED=$(
1251+
# shellcheck source=/dev/null
1252+
source "$PROJECT_ROOT/hooks/lib/loop-common.sh"
1253+
export TZ="America/Los_Angeles"
1254+
derive_loop_start_iso_ts "/tmp/.humanize/rlcr/2026-03-01_00-00-00"
1255+
)
1256+
if [[ "$AC21E_DERIVED" == "2026-03-01T08:00:00.000Z" ]]; then
1257+
pass "AC-21e: derive_loop_start_iso_ts converts PST wall-clock to correct UTC"
1258+
else
1259+
fail "AC-21e: derive_loop_start_iso_ts converts PST wall-clock to correct UTC" \
1260+
"2026-03-01T08:00:00.000Z (0am PST = 8am UTC before DST)" "$AC21E_DERIVED"
1261+
fi
1262+
12301263
# AC-21c: end-to-end through the stop hook. Pre-loop launch only -> hook
12311264
# must NOT short-circuit (no pending bg "belongs" to this loop).
12321265
echo "Test AC-21c: stop hook ignores pre-loop launches for this loop"

0 commit comments

Comments
 (0)