Merge pull request 'fix: feat: PostToolUse hook detects phase file writes in real-time (eliminates polling latency) (#278)' (#290) from fix/issue-278 into main
This commit is contained in:
commit
62d8c81069
7 changed files with 187 additions and 8 deletions
|
|
@ -18,10 +18,14 @@ FAILED=0
|
|||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# Extract function names defined in a bash script (top-level or indented).
|
||||
# Uses awk instead of grep -Eo for busybox/Alpine compatibility (#296).
|
||||
get_fns() {
|
||||
local f="$1"
|
||||
grep -Eo '[a-zA-Z_][a-zA-Z0-9_]+[[:space:]]*[(][)]' "$f" 2>/dev/null \
|
||||
| sed 's/[[:space:]]*()//' | sort -u || true
|
||||
awk '/^[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]+[[:space:]]*\(\)/ {
|
||||
sub(/^[[:space:]]+/, "")
|
||||
sub(/[[:space:]]*\(\).*/, "")
|
||||
print
|
||||
}' "$f" 2>/dev/null | sort -u || true
|
||||
}
|
||||
|
||||
# Extract call-position identifiers that look like custom function calls:
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ sourced as needed.
|
|||
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
|
||||
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` patterns. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll |
|
||||
| `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon |
|
||||
| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `❯` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. Agents must handle `idle_prompt` in both their callback and their post-loop exit handler. | dev-agent.sh, gardener-agent.sh |
|
||||
| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `❯` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. Agents must handle `idle_prompt` in both their callback and their post-loop exit handler. | dev-agent.sh, gardener-agent.sh |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -681,7 +681,7 @@ fi
|
|||
# =============================================================================
|
||||
status "creating tmux session: ${SESSION_NAME}"
|
||||
|
||||
if ! create_agent_session "${SESSION_NAME}" "${WORKTREE}"; then
|
||||
if ! create_agent_session "${SESSION_NAME}" "${WORKTREE}" "${PHASE_FILE}"; then
|
||||
log "ERROR: failed to create agent session"
|
||||
cleanup_labels
|
||||
cleanup_worktree
|
||||
|
|
|
|||
|
|
@ -167,6 +167,93 @@ else
|
|||
fail "needs_human mtime guard: expected 1 notify call, got $NOTIFY_COUNT"
|
||||
fi
|
||||
|
||||
# ── Test 9: PostToolUse hook detects writes, ignores reads ────────────────
|
||||
HOOK_SCRIPT="$(dirname "$0")/../lib/hooks/on-phase-change.sh"
|
||||
MARKER_FILE="/tmp/phase-changed-test-session.marker"
|
||||
rm -f "$MARKER_FILE"
|
||||
|
||||
if [ -x "$HOOK_SCRIPT" ]; then
|
||||
# 9a: Bash redirect to phase file → marker written
|
||||
printf '{"tool_name":"Bash","tool_input":{"command":"echo PHASE:awaiting_ci > %s"}}' \
|
||||
"$PHASE_FILE" | "$HOOK_SCRIPT" "$PHASE_FILE" "$MARKER_FILE"
|
||||
if [ -f "$MARKER_FILE" ]; then
|
||||
ok "PostToolUse hook writes marker on Bash redirect to phase file"
|
||||
else
|
||||
fail "PostToolUse hook did not write marker on Bash redirect"
|
||||
fi
|
||||
rm -f "$MARKER_FILE"
|
||||
|
||||
# 9b: Write tool targeting phase file → marker written
|
||||
printf '{"tool_name":"Write","tool_input":{"file_path":"%s","content":"PHASE:done"}}' \
|
||||
"$PHASE_FILE" | "$HOOK_SCRIPT" "$PHASE_FILE" "$MARKER_FILE"
|
||||
if [ -f "$MARKER_FILE" ]; then
|
||||
ok "PostToolUse hook writes marker on Write tool to phase file"
|
||||
else
|
||||
fail "PostToolUse hook did not write marker on Write tool"
|
||||
fi
|
||||
rm -f "$MARKER_FILE"
|
||||
|
||||
# 9c: Bash read of phase file (cat) → NO marker (not a write)
|
||||
printf '{"tool_name":"Bash","tool_input":{"command":"cat %s"}}' \
|
||||
"$PHASE_FILE" | "$HOOK_SCRIPT" "$PHASE_FILE" "$MARKER_FILE"
|
||||
if [ ! -f "$MARKER_FILE" ]; then
|
||||
ok "PostToolUse hook ignores Bash read of phase file (no false positive)"
|
||||
else
|
||||
fail "PostToolUse hook wrote marker for Bash read (false positive)"
|
||||
fi
|
||||
rm -f "$MARKER_FILE"
|
||||
|
||||
# 9d: Unrelated Bash command → NO marker
|
||||
printf '{"tool_name":"Bash","tool_input":{"command":"echo hello > /tmp/other-file"}}' \
|
||||
| "$HOOK_SCRIPT" "$PHASE_FILE" "$MARKER_FILE"
|
||||
if [ ! -f "$MARKER_FILE" ]; then
|
||||
ok "PostToolUse hook skips marker for unrelated operations"
|
||||
else
|
||||
fail "PostToolUse hook wrote marker for unrelated operation (false positive)"
|
||||
fi
|
||||
rm -f "$MARKER_FILE"
|
||||
|
||||
# 9e: Write tool targeting different file → NO marker
|
||||
printf '{"tool_name":"Write","tool_input":{"file_path":"/tmp/other-file","content":"hello"}}' \
|
||||
| "$HOOK_SCRIPT" "$PHASE_FILE" "$MARKER_FILE"
|
||||
if [ ! -f "$MARKER_FILE" ]; then
|
||||
ok "PostToolUse hook skips marker for Write to different file"
|
||||
else
|
||||
fail "PostToolUse hook wrote marker for Write to different file (false positive)"
|
||||
fi
|
||||
rm -f "$MARKER_FILE"
|
||||
else
|
||||
fail "PostToolUse hook script not found or not executable: $HOOK_SCRIPT"
|
||||
fi
|
||||
|
||||
# ── Test 10: phase-changed marker resets mtime guard ─────────────────────
|
||||
# Simulates monitor_phase_loop behavior: when marker exists, last_mtime
|
||||
# is reset to 0 so the phase is processed even if mtime hasn't changed.
|
||||
echo "PHASE:awaiting_ci" > "$PHASE_FILE"
|
||||
LAST_MTIME=$(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0)
|
||||
PHASE_MTIME="$LAST_MTIME"
|
||||
|
||||
# Without marker, mtime guard blocks processing (same mtime)
|
||||
if [ "$PHASE_MTIME" -le "$LAST_MTIME" ]; then
|
||||
ok "mtime guard blocks when no marker present (baseline)"
|
||||
else
|
||||
fail "mtime guard should block when phase_mtime <= last_mtime"
|
||||
fi
|
||||
|
||||
# Now simulate marker present — reset last_mtime to 0
|
||||
MARKER_FILE="/tmp/phase-changed-test-session.marker"
|
||||
date +%s > "$MARKER_FILE"
|
||||
if [ -f "$MARKER_FILE" ]; then
|
||||
rm -f "$MARKER_FILE"
|
||||
LAST_MTIME=0
|
||||
fi
|
||||
|
||||
if [ "$PHASE_MTIME" -gt "$LAST_MTIME" ]; then
|
||||
ok "phase-changed marker resets mtime guard (phase now processable)"
|
||||
else
|
||||
fail "phase-changed marker did not reset mtime guard"
|
||||
fi
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||
rm -f "$PHASE_FILE"
|
||||
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ touch "$RESULT_FILE"
|
|||
|
||||
# ── Create tmux session ───────────────────────────────────────────────────
|
||||
log "Creating tmux session: ${SESSION_NAME}"
|
||||
if ! create_agent_session "$SESSION_NAME" "$PROJECT_REPO_ROOT"; then
|
||||
if ! create_agent_session "$SESSION_NAME" "$PROJECT_REPO_ROOT" "$PHASE_FILE"; then
|
||||
log "ERROR: failed to create tmux session ${SESSION_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -290,7 +290,7 @@ gardener_phase_callback() {
|
|||
log "WARNING: tmux session died unexpectedly — attempting recovery"
|
||||
rm -f "$RESULT_FILE"
|
||||
touch "$RESULT_FILE"
|
||||
if create_agent_session "${_MONITOR_SESSION:-$SESSION_NAME}" "$PROJECT_REPO_ROOT" 2>/dev/null; then
|
||||
if create_agent_session "${_MONITOR_SESSION:-$SESSION_NAME}" "$PROJECT_REPO_ROOT" "$PHASE_FILE" 2>/dev/null; then
|
||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" "$PROMPT"
|
||||
log "Recovery session started"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -45,10 +45,17 @@ agent_inject_into_session() {
|
|||
|
||||
# Create a tmux session running Claude in the given workdir.
|
||||
# Installs a Stop hook for idle detection (see monitor_phase_loop).
|
||||
# Optionally installs a PostToolUse hook for phase file write detection.
|
||||
# Args: session workdir [phase_file]
|
||||
# Returns 0 if session is ready, 1 otherwise.
|
||||
create_agent_session() {
|
||||
local session="$1"
|
||||
local workdir="${2:-.}"
|
||||
local phase_file="${3:-}"
|
||||
|
||||
# Prepare settings directory for hooks
|
||||
mkdir -p "${workdir}/.claude"
|
||||
local settings="${workdir}/.claude/settings.json"
|
||||
|
||||
# Install Stop hook for idle detection: when Claude finishes a response,
|
||||
# the hook writes a timestamp to a marker file. monitor_phase_loop checks
|
||||
|
|
@ -56,8 +63,6 @@ create_agent_session() {
|
|||
local idle_marker="/tmp/claude-idle-${session}.ts"
|
||||
local hook_script="${FACTORY_ROOT}/lib/hooks/on-idle-stop.sh"
|
||||
if [ -x "$hook_script" ]; then
|
||||
mkdir -p "${workdir}/.claude"
|
||||
local settings="${workdir}/.claude/settings.json"
|
||||
local hook_cmd="${hook_script} ${idle_marker}"
|
||||
if [ -f "$settings" ]; then
|
||||
# Append our Stop hook to existing project settings
|
||||
|
|
@ -79,6 +84,36 @@ create_agent_session() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# Install PostToolUse hook for phase file write detection: when Claude
|
||||
# writes to the phase file via Bash or Write, the hook writes a marker
|
||||
# so monitor_phase_loop can react immediately instead of waiting for
|
||||
# the next mtime-based poll cycle.
|
||||
if [ -n "$phase_file" ]; then
|
||||
local phase_marker="/tmp/phase-changed-${session}.marker"
|
||||
local phase_hook_script="${FACTORY_ROOT}/lib/hooks/on-phase-change.sh"
|
||||
if [ -x "$phase_hook_script" ]; then
|
||||
local phase_hook_cmd="${phase_hook_script} ${phase_file} ${phase_marker}"
|
||||
if [ -f "$settings" ]; then
|
||||
jq --arg cmd "$phase_hook_cmd" '
|
||||
.hooks.PostToolUse = (.hooks.PostToolUse // []) + [{
|
||||
matcher: "Bash|Write",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
|
||||
else
|
||||
jq -n --arg cmd "$phase_hook_cmd" '{
|
||||
hooks: {
|
||||
PostToolUse: [{
|
||||
matcher: "Bash|Write",
|
||||
hooks: [{type: "command", command: $cmd}]
|
||||
}]
|
||||
}
|
||||
}' > "$settings"
|
||||
fi
|
||||
rm -f "$phase_marker"
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$idle_marker"
|
||||
tmux new-session -d -s "$session" -c "$workdir" \
|
||||
"claude --dangerously-skip-permissions" 2>/dev/null
|
||||
|
|
@ -145,6 +180,15 @@ monitor_phase_loop() {
|
|||
esac
|
||||
fi
|
||||
|
||||
# Check phase-changed marker from PostToolUse hook — if present, the hook
|
||||
# detected a phase file write so we reset last_mtime to force processing
|
||||
# this cycle instead of waiting for the next mtime change.
|
||||
local phase_marker="/tmp/phase-changed-${_session}.marker"
|
||||
if [ -f "$phase_marker" ]; then
|
||||
rm -f "$phase_marker"
|
||||
last_mtime=0
|
||||
fi
|
||||
|
||||
# Check phase file for changes
|
||||
local phase_mtime
|
||||
phase_mtime=$(stat -c %Y "$phase_file" 2>/dev/null || echo 0)
|
||||
|
|
@ -217,6 +261,7 @@ agent_kill_session() {
|
|||
local session="${1:-}"
|
||||
[ -n "$session" ] && tmux kill-session -t "$session" 2>/dev/null || true
|
||||
rm -f "/tmp/claude-idle-${session}.ts"
|
||||
rm -f "/tmp/phase-changed-${session}.marker"
|
||||
}
|
||||
|
||||
# Read the current phase from a phase file, stripped of whitespace.
|
||||
|
|
|
|||
43
lib/hooks/on-phase-change.sh
Executable file
43
lib/hooks/on-phase-change.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/bin/bash
|
||||
# on-phase-change.sh — PostToolUse hook for phase file write detection.
|
||||
#
|
||||
# Called by Claude Code after every Bash|Write tool execution.
|
||||
# Detects writes (not reads) to the phase file and writes a timestamp
|
||||
# marker so monitor_phase_loop can react immediately instead of waiting
|
||||
# for the next mtime-based poll.
|
||||
#
|
||||
# Usage (in .claude/settings.json):
|
||||
# {"type": "command", "command": "this-script /path/to/phase-file /path/to/marker"}
|
||||
#
|
||||
# Args: $1 = phase file path, $2 = marker file path
|
||||
|
||||
phase_file="${1:-}"
|
||||
marker_file="${2:-}"
|
||||
|
||||
[ -z "$phase_file" ] && exit 0
|
||||
[ -z "$marker_file" ] && exit 0
|
||||
|
||||
input=$(cat) # consume hook JSON from stdin
|
||||
|
||||
# Fast path: skip if phase file not referenced at all
|
||||
printf '%s' "$input" | grep -qF "$phase_file" || exit 0
|
||||
|
||||
# Parse tool type and detect writes only (ignore reads like cat/head)
|
||||
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null)
|
||||
|
||||
case "$tool_name" in
|
||||
Write)
|
||||
# Write tool: check if file_path targets the phase file
|
||||
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
||||
[ "$file_path" = "$phase_file" ] && date +%s > "$marker_file"
|
||||
;;
|
||||
Bash)
|
||||
# Bash tool: check if the decoded command contains a redirect (>)
|
||||
# targeting the phase file — distinguishes writes from reads
|
||||
command_str=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
if printf '%s' "$command_str" | grep -qF "$phase_file" \
|
||||
&& printf '%s' "$command_str" | grep -q '>'; then
|
||||
date +%s > "$marker_file"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
Loading…
Add table
Add a link
Reference in a new issue