Skip to content

Commit 41b9034

Browse files
Merge remote-tracking branch 'origin/main' into worktree-20260323-193735
2 parents 6ca72a4 + 05c28a9 commit 41b9034

File tree

8 files changed

+407
-5
lines changed

8 files changed

+407
-5
lines changed

.test-index

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ plugins/dso/scripts/ticket-show.sh:tests/scripts/test-ticket-show.sh,tests/scrip
4040
plugins/dso/scripts/ticket-create.sh:tests/scripts/test-ticket-create.sh [test_create_with_closed_parent_blocked]
4141
plugins/dso/scripts/ticket-link.sh:tests/scripts/test-ticket-link.sh [test_link_depends_on_closed_target_blocked]
4242
plugins/dso/scripts/ticket-transition.sh:tests/scripts/test-ticket-transition.sh [test_transition_bug_close_requires_reason]
43+
plugins/dso/scripts/runners/bash-runner.sh:tests/scripts/test-test-batched.sh
4344
plugins/dso/scripts/validate-phase.sh:tests/test-validate-phase-portability.sh
4445
plugins/dso/scripts/validate.sh:tests/plugin/test-validate-work-portability.sh,tests/hooks/test-validate-review-output.sh,tests/hooks/test-validate-crash-detection.sh,tests/scripts/test-validate-test-batched-integration.sh,tests/scripts/test-validate-flock-timeout.sh,tests/scripts/test-validate-background.sh,tests/scripts/test-validate-skip-ci-flag.sh,tests/scripts/test-validate-issues.sh,tests/scripts/test-validate-config.sh,tests/scripts/test-validate-script-writes-integration.sh,tests/scripts/test-validate-config-driven.sh,tests/scripts/test-validate-state-lifecycle.sh,tests/test-validate-phase-portability.sh
4546
plugins/dso/scripts/worktree-cleanup.sh:tests/scripts/test_worktree_cleanup_startup_config.py

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ These rules protect core structural boundaries. Violating them causes subtle bug
125125
9. **When fixing a bug, search for the same anti-pattern elsewhere.** After fixing a bug, search the codebase for other code that follows the same anti-pattern you just fixed. Create a bug ticket (`.claude/scripts/dso ticket create bug "<title>"`) for each occurrence found so they can be tracked and fixed systematically.
126126
10. **Write a failing test to verify your CI/staging bug hypothesis before fixing.** When diagnosing a CI or staging failure, write a unit or integration test that reproduces the suspected root cause FIRST. Run it to confirm it fails (RED). Only then implement the fix and verify the test passes (GREEN). This prevents fixing symptoms instead of causes and guards against the fix being wrong.
127127
11. **Always set `timeout: 600000` on Bash tool calls for commands expected to exceed 30 seconds, AND on all Bash calls during commit/review workflows.** Claude Code's hard timeout ceiling is ~73s even with max timeout. Without `timeout: 600000`, the ceiling drops to ~48s. Commands known to exceed 30s: `validate.sh --ci`, `make test`, `.claude/scripts/dso ticket sync`, `tk` write commands in worktrees with many tickets. Additionally, set `timeout: 600000` on ALL Bash tool calls during COMMIT-WORKFLOW.md and REVIEW-WORKFLOW.md execution — even fast commands like `ruff check` can receive SIGURG (exit 144) from tool-call cancellation during internal event processing (see INC-016 scenario 4).
128-
12. **Use `test-batched.sh` for test commands expected to exceed 60 seconds.** Example: `$(git rev-parse --show-toplevel)/plugins/dso/scripts/test-batched.sh --timeout=50 "plugins/dso/scripts/validate.sh --ci"`. The script runs the command in a time-bounded loop, saves progress to a state file, and prints a `NEXT:` resume command when the time limit is reached. Run the printed `NEXT:` command in subsequent Bash tool calls until the summary appears. Do NOT use `while` polling loops — they get killed by the ~73s tool timeout ceiling, producing spurious exit 144. For non-test long-running commands (e.g., `.claude/scripts/dso ticket sync`), see INC-016 in KNOWN-ISSUES.md for the managed launch/poll script pattern.
128+
12. **Use `test-batched.sh` for test commands expected to exceed 60 seconds.** The script supports runner drivers that decompose test suites into individual items for per-test resume. **Prefer `--runner=bash --test-dir=<dir>` for bash test suites** — this discovers `test-*.sh` and `run-*-tests.sh` files and runs each as a separate item, enabling per-script resume on timeout. Example: `$(git rev-parse --show-toplevel)/plugins/dso/scripts/test-batched.sh --timeout=50 --runner=bash --test-dir=tests/scripts`. The generic fallback (`--timeout=50 "command"`) treats the entire command as a single item — use it only when no runner driver applies. Available runners: `bash` (test-*.sh files), `node` (*.test.js files), `pytest` (pytest collection). Run the printed `RUN:` command in subsequent Bash tool calls until the summary appears. Do NOT use `while` polling loops — they get killed by the ~73s tool timeout ceiling, producing spurious exit 144. For non-test long-running commands (e.g., `.claude/scripts/dso ticket sync`), see INC-016 in KNOWN-ISSUES.md for the managed launch/poll script pattern.
129129

130130
## Task Start Workflow
131131

plugins/dso/docs/workflows/COMMIT-WORKFLOW.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,13 @@ TEST_CMD="$(".claude/scripts/dso read-config.sh" commands.test_unit 2>/dev/null
106106
cd app && $TEST_CMD 2>&1 | tail -5
107107
```
108108

109-
**If the test command is expected to exceed 60s** (e.g., `bash tests/run-all.sh`), wrap with `test-batched.sh`:
109+
**If the test command is expected to exceed 60s** (e.g., `bash tests/run-all.sh`), use `test-batched.sh` with a runner driver for per-test resume. **Prefer `--runner=bash --test-dir=<dir>` for bash test suites** — this discovers `test-*.sh` and `run-*-tests.sh` files and runs each as a separate item, so completed tests are skipped on resume:
110+
111+
```bash
112+
bash "$REPO_ROOT/plugins/dso/scripts/test-batched.sh" --timeout=50 --runner=bash --test-dir="$REPO_ROOT/tests/scripts"
113+
```
114+
115+
If no runner driver applies (the test command is not a directory of scripts), fall back to the generic runner which wraps the entire command as a single item (no sub-test resume):
110116

111117
```bash
112118
bash "$REPO_ROOT/plugins/dso/scripts/test-batched.sh" --timeout=50 "$TEST_CMD"
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env bash
2+
# scripts/runners/bash-runner.sh — Bash test script runner driver
3+
#
4+
# Sourced by test-batched.sh to provide bash test script discovery and
5+
# execution. Discovers test-*.sh files under --test-dir and runs each
6+
# as a separate test item, enabling per-script resume on timeout.
7+
#
8+
# Requires these variables from the caller (test-batched.sh):
9+
# RUNNER — "bash" for explicit, "" for auto-detect
10+
# TEST_DIR — directory to search for test-*.sh files
11+
# COMPLETED_LIST — array of already-completed test IDs (for resume)
12+
# RESULTS_JSON — JSON object of results so far
13+
# STATE_FILE — path to the JSON state file
14+
# TIMEOUT — timeout in seconds
15+
# DEFAULT_TIMEOUT — default timeout value (for resume command construction)
16+
# CMD — fallback command (optional for bash runner)
17+
#
18+
# After sourcing, the caller checks USE_BASH_RUNNER and, if set, calls
19+
# _bash_runner_run to execute the bash runner path.
20+
#
21+
# Exports (set by this file):
22+
# USE_BASH_RUNNER — 1 if bash runner is active, 0 otherwise
23+
# BASH_FILES — array of discovered test script paths
24+
25+
# _bash_discover_files <dir>
26+
# Prints one file path per line for test-*.sh files; returns non-zero if none found.
27+
_bash_discover_files() {
28+
local dir="$1"
29+
local found=0
30+
# Use a while loop with sorted glob expansion for portability (no find -print0)
31+
while IFS= read -r f; do
32+
[ -f "$f" ] && [ -x "$f" ] && { echo "$f"; found=1; }
33+
done < <(find "$dir" -maxdepth 1 \( -name 'test-*.sh' -o -name 'run-*-tests.sh' \) -print 2>/dev/null | sort)
34+
[ "$found" -eq 1 ]
35+
}
36+
37+
# Determine effective runner ──────────────────────────────────────────────────
38+
USE_BASH_RUNNER=0
39+
BASH_FILES=()
40+
41+
if [ "$RUNNER" = "bash" ]; then
42+
# Explicit --runner=bash: attempt bash driver; fall back on failures
43+
if [ -z "$TEST_DIR" ]; then
44+
echo "WARNING: --runner=bash requested but --test-dir not set; falling back to generic runner." >&2
45+
else
46+
while IFS= read -r f; do
47+
BASH_FILES+=("$f")
48+
done < <(_bash_discover_files "$TEST_DIR" 2>/dev/null || true)
49+
50+
if [ "${#BASH_FILES[@]}" -eq 0 ]; then
51+
echo "WARNING: --runner=bash: no test-*.sh or run-*-tests.sh files found under $TEST_DIR; falling back to generic runner." >&2
52+
else
53+
USE_BASH_RUNNER=1
54+
fi
55+
fi
56+
elif [ -z "$RUNNER" ] && [ -n "$TEST_DIR" ]; then
57+
# Auto-detect: activate bash driver when test-*.sh files exist under TEST_DIR
58+
# Only auto-detect if node and pytest didn't already claim the runner
59+
while IFS= read -r f; do
60+
BASH_FILES+=("$f")
61+
done < <(_bash_discover_files "$TEST_DIR" 2>/dev/null || true)
62+
63+
if [ "${#BASH_FILES[@]}" -gt 0 ]; then
64+
USE_BASH_RUNNER=1
65+
RUNNER="bash"
66+
fi
67+
fi
68+
69+
# _bash_runner_run
70+
# Executes the bash runner path. Called by test-batched.sh when USE_BASH_RUNNER=1.
71+
# Uses all shared state variables from the caller.
72+
_bash_runner_run() {
73+
local TOTAL=${#BASH_FILES[@]}
74+
local START_TIME
75+
START_TIME=$(date +%s)
76+
# Preserve created_at from existing state (if resuming), otherwise use now.
77+
local SESSION_CREATED_AT="${_state_created_at:-$START_TIME}"
78+
_elapsed() { echo $(( $(date +%s) - START_TIME )); }
79+
local _bash_tmpdir
80+
_bash_tmpdir=$(mktemp -d /tmp/test-batched-bash-XXXXXX)
81+
local _existing_exit_trap
82+
_existing_exit_trap=$(trap -p EXIT | sed "s/^trap -- '//;s/' EXIT$//")
83+
if [ -n "$_existing_exit_trap" ]; then
84+
trap 'rm -rf "$_bash_tmpdir"; '"$_existing_exit_trap" EXIT
85+
else
86+
trap 'rm -rf "$_bash_tmpdir"' EXIT
87+
fi
88+
89+
_save_state_and_resume_bash() {
90+
local completed_json results_json
91+
completed_json=$(_completed_to_json)
92+
results_json="$RESULTS_JSON"
93+
_state_write "$STATE_FILE" "bash:${TEST_DIR}" "$completed_json" "$results_json" "" "$SESSION_CREATED_AT" 2>/dev/null || {
94+
echo "WARNING: Could not write state file: $STATE_FILE" >&2
95+
}
96+
local done_count=${#COMPLETED_LIST[@]}
97+
local resume_runner_arg="--runner=bash"
98+
local resume_dir_arg="--test-dir=${TEST_DIR}"
99+
local resume_timeout_arg=""
100+
[ "$TIMEOUT" -ne "$DEFAULT_TIMEOUT" ] && resume_timeout_arg="--timeout=$TIMEOUT "
101+
local resume_cmd="TEST_BATCHED_STATE_FILE=$STATE_FILE bash $0 ${resume_runner_arg} ${resume_dir_arg} ${resume_timeout_arg}${CMD:+"'$CMD'"}"
102+
echo ""
103+
echo "$done_count/$TOTAL tests completed."
104+
echo ""
105+
echo "════════════════════════════════════════════════════════════"
106+
echo " ⚠ ACTION REQUIRED — TESTS NOT COMPLETE ⚠"
107+
echo "════════════════════════════════════════════════════════════"
108+
echo "RUN: $resume_cmd"
109+
echo "DO NOT PROCEED until the command above prints a final summary."
110+
echo "════════════════════════════════════════════════════════════"
111+
exit 0
112+
}
113+
114+
for bash_file in "${BASH_FILES[@]}"; do
115+
# Use a path-relative test ID (relative to TEST_DIR) to avoid collisions
116+
# when two files share the same basename (even though -maxdepth 1 currently
117+
# prevents this, using a stable relative path makes the invariant explicit).
118+
# Portable: strip the TEST_DIR prefix from the absolute path.
119+
local test_id
120+
local _abs_bash_file _abs_test_dir
121+
_abs_bash_file="$(cd "$(dirname "$bash_file")" && pwd)/$(basename "$bash_file")"
122+
_abs_test_dir="$(cd "$TEST_DIR" && pwd)"
123+
test_id="${_abs_bash_file#"${_abs_test_dir}/"}"
124+
# Fallback to basename if prefix stripping produced an empty or unchanged result
125+
[ -z "$test_id" ] || [ "$test_id" = "$_abs_bash_file" ] && test_id="$(basename "$bash_file")"
126+
127+
if _is_completed "$test_id"; then
128+
echo "Skipping (already completed): $test_id"
129+
continue
130+
fi
131+
132+
# Check timeout before running this file
133+
if [ "$(_elapsed)" -ge "$TIMEOUT" ]; then
134+
_save_state_and_resume_bash
135+
fi
136+
137+
echo "Running: bash $bash_file"
138+
139+
# Launch the test script as a direct background child. Exit-code capture
140+
# uses `wait <pid>` — which is synchronous and race-free — instead of the
141+
# previous approach of writing "$?" to a file from inside a subshell and then
142+
# reading it from the parent (which could race with file-system buffering on
143+
# busy or network-mounted filesystems).
144+
local bash_exit=0
145+
bash "$bash_file" &
146+
local _test_bg_pid=$!
147+
148+
# Monitor: poll until the test finishes or the time budget runs out.
149+
while kill -0 "$_test_bg_pid" 2>/dev/null; do
150+
if [ "$(_elapsed)" -ge "$TIMEOUT" ]; then
151+
kill "$_test_bg_pid" 2>/dev/null || true
152+
wait "$_test_bg_pid" 2>/dev/null || true
153+
COMPLETED_LIST+=("$test_id")
154+
RESULTS_JSON=$(_results_add "$RESULTS_JSON" "$test_id" "interrupted")
155+
_save_state_and_resume_bash
156+
fi
157+
sleep 0.1 2>/dev/null || sleep 1
158+
done
159+
160+
# `wait` on a direct child always returns the child's actual exit code —
161+
# no file-write race is possible here.
162+
wait "$_test_bg_pid" 2>/dev/null; bash_exit=$?
163+
164+
local bash_outcome
165+
if [ "$bash_exit" -eq 0 ]; then
166+
bash_outcome="pass"
167+
else
168+
bash_outcome="fail"
169+
fi
170+
171+
COMPLETED_LIST+=("$test_id")
172+
RESULTS_JSON=$(_results_add "$RESULTS_JSON" "$test_id" "$bash_outcome")
173+
174+
local done_count=${#COMPLETED_LIST[@]}
175+
echo "$done_count/$TOTAL tests completed."
176+
done
177+
178+
# All bash files processed — print summary
179+
local pass_count fail_count interrupted_count total_done
180+
pass_count=$(_results_count "$RESULTS_JSON" "pass")
181+
fail_count=$(_results_count "$RESULTS_JSON" "fail")
182+
interrupted_count=$(_results_count "$RESULTS_JSON" "interrupted")
183+
total_done=${#COMPLETED_LIST[@]}
184+
185+
echo ""
186+
echo "All tests done. $total_done/$TOTAL tests completed. $pass_count passed, $fail_count failed, $interrupted_count interrupted."
187+
188+
if [ "$fail_count" -gt 0 ]; then
189+
echo ""
190+
echo "Failures:"
191+
_results_failures "$RESULTS_JSON" | while IFS= read -r fid; do
192+
echo " FAIL: $fid"
193+
done
194+
fi
195+
196+
rm -f "$STATE_FILE"
197+
# Interrupted tests are non-passing — exit non-zero if any tests failed or were interrupted
198+
[ "$fail_count" -gt 0 ] || [ "$interrupted_count" -gt 0 ] && exit 1 || exit 0
199+
}

plugins/dso/scripts/test-batched.sh

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,15 @@ set -uo pipefail
2828
# files exist under --test-dir.
2929
# Falls back to generic when: pytest not installed, no test files found,
3030
# collection fails, or collection yields no test IDs.
31+
# bash Discovers test-*.sh and run-*-tests.sh files under --test-dir
32+
# and runs each via: bash <file>
33+
# Auto-detected when: test-*.sh or run-*-tests.sh files exist
34+
# under --test-dir (after node and pytest auto-detect).
35+
# Falls back to generic when: no matching files found.
3136
# generic (default) Runs <command> as a single test item.
3237
#
3338
# The <command> positional argument is required for the generic runner.
34-
# For the node runner, <command> is optional (used as fallback command).
39+
# For the node, pytest, and bash runners, <command> is optional (used as fallback).
3540
#
3641
# Output format:
3742
# Between batches: progress line + Structured Action-Required Block (ACTION REQUIRED / RUN: / DO NOT PROCEED)
@@ -187,7 +192,7 @@ done
187192

188193
# ── Validate required argument ─────────────────────────────────────────────────
189194
# CMD is required for generic runner; node and pytest runners can operate without it.
190-
if [ -z "$CMD" ] && [ "$RUNNER" != "node" ] && [ "$RUNNER" != "pytest" ]; then
195+
if [ -z "$CMD" ] && [ "$RUNNER" != "node" ] && [ "$RUNNER" != "pytest" ] && [ "$RUNNER" != "bash" ]; then
191196
echo "ERROR: Missing required argument: <command>" >&2
192197
echo ""
193198
sed -n '2,/^$/s/^# \{0,1\}//p' "$0" | head -60 >&2
@@ -402,6 +407,16 @@ if [ "$USE_PYTEST_RUNNER" -eq 1 ]; then
402407
_pytest_runner_run
403408
fi
404409

410+
# ── Bash runner driver (sourced from runners/bash-runner.sh) ─────────────────
411+
# Sets USE_BASH_RUNNER and BASH_FILES; provides _bash_runner_run function.
412+
# shellcheck source=runners/bash-runner.sh
413+
source "$(dirname "$0")/runners/bash-runner.sh"
414+
415+
# ── Bash runner execution path ───────────────────────────────────────────────
416+
if [ "$USE_BASH_RUNNER" -eq 1 ]; then
417+
_bash_runner_run
418+
fi
419+
405420
# ── Generic fallback runner ───────────────────────────────────────────────────
406421
# Runs CMD as a single test item with an auto-generated ID.
407422
# This is the default mode — a generic harness for any command.

plugins/dso/scripts/ticket-reducer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def reduce_ticket(
170170
"""
171171
if strategy is None:
172172
strategy = LastTimestampWinsStrategy()
173-
ticket_dir = str(ticket_dir_path)
173+
ticket_dir = os.path.normpath(str(ticket_dir_path))
174174
ticket_id = os.path.basename(ticket_dir)
175175

176176
# Compute content hash for caching (filename + file size to detect in-place

0 commit comments

Comments
 (0)