Skip to content

Commit 8df8ac0

Browse files
GiggleLiuclaude
andcommitted
Drain board queues continuously in run-*-forever loops
Rework run-pipeline-forever and run-review-forever so successful dispatches immediately re-check the queue instead of sleeping for the poll interval. WATCH_MODE sub-makes emit __WATCH_OUTCOME__ markers (processed/gone) that dispatch_watch_target parses, distinguishing items that became ineligible mid-flight from successful processing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 26ac230 commit 8df8ac0

4 files changed

Lines changed: 220 additions & 45 deletions

File tree

.claude/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ make run-plan # Execute a plan with Codex or Claude
7777
make run-issue N=42 # Run issue-to-pr --execute for a GitHub issue
7878
make run-pipeline # Pick next Ready issue from project board, implement, move to Review pool
7979
make run-pipeline N=97 # Process a specific issue from the project board
80-
make run-pipeline-forever # Poll Ready column, run-pipeline when new issues appear
80+
make run-pipeline-forever # Drain eligible Ready issues forever; poll only while idle
8181
make run-review # Pick next PR from Review pool column, run agentic review, move to Final review
8282
make run-review N=570 # Process a specific PR from the Review pool column
83-
make run-review-forever # Poll Review pool for eligible PRs, dispatch run-review
83+
make run-review-forever # Drain eligible Review pool PRs forever; poll only while idle
8484
make copilot-review # (Optional) Request Copilot code review on current PR
8585
make release V=x.y.z # Tag and push a new release (CI publishes to crates.io)
8686
make papers # Full paper fetch: lookup + download + scihub

Makefile

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ help:
3636
@echo " run-plan - Execute a plan with Codex or Claude (latest plan in docs/plans/)"
3737
@echo " run-issue N=<number> - Run issue-to-pr --execute for a GitHub issue"
3838
@echo " run-pipeline [N=<number>] - Pick a Ready issue, implement, move to Review pool"
39-
@echo " run-pipeline-forever - Loop: poll Ready column for new issues, run-pipeline when new ones appear"
39+
@echo " run-pipeline-forever - Loop: drain eligible Ready issues forever; poll only when the queue is empty"
4040
@echo " run-review [N=<number>] - Pick PR from Review pool, fix comments/CI, run agentic tests"
41-
@echo " run-review-forever - Loop: poll Review pool for eligible PRs, dispatch run-review"
41+
@echo " run-review-forever - Loop: drain eligible Review pool PRs forever; poll only when the queue is empty"
4242
@echo " board-next MODE=<ready|review|final-review> [NUMBER=<n>] [FORMAT=text|json] - Get the next eligible queued project item"
4343
@echo " board-claim MODE=<ready|review> [NUMBER=<n>] [FORMAT=text|json] - Claim and move the next eligible queued project item"
4444
@echo " board-ack MODE=<ready|review|final-review> ITEM=<id> - Acknowledge a queued project item"
@@ -396,8 +396,23 @@ cli-demo: cli
396396
# make run-pipeline N=97 (processes specific issue)
397397
run-pipeline:
398398
@. scripts/make_helpers.sh; \
399+
selection=""; \
399400
if [ -n "$(N)" ]; then \
400401
issue="$(N)"; \
402+
if [ -n "$$WATCH_MODE" ]; then \
403+
status=0; \
404+
tmp_state=$$(mktemp); \
405+
selection=$$(board_next_json ready "" "$(N)" "$$tmp_state") || status=$$?; \
406+
rm -f "$$tmp_state"; \
407+
if [ "$$status" -eq 1 ]; then \
408+
echo "Ready issue #$(N) is no longer eligible."; \
409+
watch_emit_outcome gone; \
410+
exit 0; \
411+
elif [ "$$status" -ne 0 ]; then \
412+
exit "$$status"; \
413+
fi; \
414+
issue=$$(printf '%s\n' "$$selection" | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['issue_number'] or data['number'])"); \
415+
fi; \
401416
else \
402417
status=0; \
403418
tmp_state=$$(mktemp); \
@@ -412,10 +427,10 @@ run-pipeline:
412427
issue=$$(printf '%s\n' "$$selection" | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['issue_number'] or data['number'])"); \
413428
fi; \
414429
PROMPT=$$(skill_prompt_with_context project-pipeline "/project-pipeline $$issue" "process GitHub issue $$issue" "Selected queue item" "$$selection"); \
415-
run_agent "pipeline-output.log" "$$PROMPT"
430+
run_agent_with_watch_outcome "pipeline-output.log" "$$PROMPT"
416431

417-
# Poll Ready column for new issues and run-pipeline when new ones appear
418-
# Checks every 30 minutes; triggers make run-pipeline when the eligible Ready-item set gains new members
432+
# Drain Ready issues forever, polling only when the eligible queue is empty
433+
# Checks every 30 minutes while idle; successful dispatches immediately re-check the queue
419434
run-pipeline-forever:
420435
@. scripts/make_helpers.sh; \
421436
MAKE=$(MAKE) watch_and_dispatch ready run-pipeline "Ready issues"
@@ -575,6 +590,11 @@ run-review:
575590
selection=$$(review_pipeline_context "$$repo" "$$pr"); \
576591
status_name=$$(printf '%s\n' "$$selection" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])"); \
577592
if [ "$$status_name" = "empty" ]; then \
593+
if [ -n "$$WATCH_MODE" ]; then \
594+
echo "Review item for PR #$$pr is no longer eligible."; \
595+
watch_emit_outcome gone; \
596+
exit 0; \
597+
fi; \
578598
echo "No Review pool PRs are currently eligible."; \
579599
exit 1; \
580600
fi; \
@@ -587,9 +607,9 @@ run-review:
587607
codex_desc="inspect the review pipeline bundle and resolve the next action"; \
588608
fi; \
589609
PROMPT=$$(skill_prompt_with_context review-pipeline "$$slash_cmd" "$$codex_desc" "Review pipeline context" "$$selection"); \
590-
run_agent "review-output.log" "$$PROMPT"
610+
run_agent_with_watch_outcome "review-output.log" "$$PROMPT"
591611

592-
# Poll Review pool column for eligible PRs and dispatch run-review
612+
# Drain Review pool PRs forever, polling only when the eligible queue is empty
593613
run-review-forever:
594614
@. scripts/make_helpers.sh; \
595615
REPO=$$(gh repo view --json nameWithOwner --jq .nameWithOwner) || { echo "Failed to detect repo (gh repo view failed)"; exit 1; }; \

scripts/make_helpers.sh

Lines changed: 81 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ run_agent() {
5959
fi
6060
}
6161

62+
watch_emit_outcome() {
63+
outcome=$1
64+
if [ -n "${WATCH_MODE:-}" ]; then
65+
printf '__WATCH_OUTCOME__=%s\n' "$outcome"
66+
fi
67+
}
68+
69+
run_agent_with_watch_outcome() {
70+
output_file=$1
71+
prompt=$2
72+
73+
if run_agent "$output_file" "$prompt"; then
74+
watch_emit_outcome processed
75+
return 0
76+
fi
77+
78+
rc=$?
79+
watch_emit_outcome agent-failed
80+
return "$rc"
81+
}
82+
6283
# --- Project board ---
6384

6485
# Detect the next eligible item from the current board snapshot.
@@ -212,7 +233,44 @@ cleanup_pipeline_worktree() {
212233
python3 scripts/pipeline_worktree.py cleanup --worktree "$worktree" --format json
213234
}
214235

215-
# Poll a board column and dispatch a make target when new items appear.
236+
# Run a make target from watch_and_dispatch and capture any explicit outcome marker.
237+
dispatch_watch_target() {
238+
make_target=$1
239+
number=$2
240+
output_file=$(mktemp)
241+
242+
if WATCH_MODE=1 ${MAKE:-make} "$make_target" N="$number" >"$output_file" 2>&1; then
243+
rc=0
244+
else
245+
rc=$?
246+
fi
247+
248+
outcome=
249+
while IFS= read -r line || [ -n "$line" ]; do
250+
case "$line" in
251+
__WATCH_OUTCOME__=*)
252+
outcome=${line#__WATCH_OUTCOME__=}
253+
;;
254+
*)
255+
printf '%s\n' "$line"
256+
;;
257+
esac
258+
done <"$output_file"
259+
rm -f "$output_file"
260+
261+
if [ -z "$outcome" ]; then
262+
if [ "$rc" -eq 0 ]; then
263+
outcome=processed
264+
else
265+
outcome=agent-failed
266+
fi
267+
fi
268+
269+
WATCH_DISPATCH_OUTCOME=$outcome
270+
WATCH_DISPATCH_RC=$rc
271+
}
272+
273+
# Poll a board column and dispatch a make target until the eligible queue is drained.
216274
# watch_and_dispatch <mode> <make-target> <label> [repo]
217275
# Example:
218276
# watch_and_dispatch ready run-pipeline "Ready issues"
@@ -223,50 +281,39 @@ watch_and_dispatch() {
223281
label=$3
224282
repo=${4-}
225283
interval=${POLL_INTERVAL:-1800}
226-
max_retries=${MAX_RETRIES:-3}
227284

228285
state_file=${STATE_FILE:-/tmp/problemreductions-${mode}-forever-state.json}
229286

230287
trap 'exit 130' INT TERM
231-
echo "Watching for new ${label} (polling every $((interval / 60))m, max retries ${max_retries})..."
288+
echo "Watching ${label} (polling every $((interval / 60))m when idle)..."
232289
while true; do
233290
next_item=$(poll_project_items "$mode" "$state_file" "$repo" "" text)
234291
status=$?
235292
if [ "$status" -eq 0 ]; then
236293
item_id=$(printf '%s\n' "$next_item" | cut -f1)
237294
number=$(printf '%s\n' "$next_item" | cut -f2)
238-
echo "$(date '+%Y-%m-%d %H:%M:%S') New ${label}: item $number ($item_id)"
239-
if ${MAKE:-make} "$make_target" N="$number"; then
240-
ack_polled_item "$state_file" "$item_id" || exit $?
241-
echo "$(date '+%Y-%m-%d %H:%M:%S') Processed ${label} item $number; sleeping $((interval / 60))m..."
242-
sleep "$interval"
243-
else
244-
# Track retries in state file; move to On Hold after max_retries
245-
retry_count=$(python3 -c "
246-
import json, sys
247-
state_file, item_id = sys.argv[1], sys.argv[2]
248-
try:
249-
state = json.load(open(state_file))
250-
except (FileNotFoundError, json.JSONDecodeError, ValueError):
251-
state = {}
252-
retries = state.get('retries', {})
253-
retries[item_id] = retries.get(item_id, 0) + 1
254-
state['retries'] = retries
255-
json.dump(state, open(state_file, 'w'), indent=2, sort_keys=True)
256-
print(retries[item_id])
257-
" "$state_file" "$item_id" 2>/dev/null || echo 1)
258-
if [ "$retry_count" -ge "$max_retries" ]; then
259-
echo "$(date '+%Y-%m-%d %H:%M:%S') Item $number ($item_id) failed ${retry_count} times; moving to On Hold." >&2
260-
move_board_item "$item_id" "on-hold" 2>/dev/null || true
261-
ack_polled_item "$state_file" "$item_id" 2>/dev/null || true
262-
else
263-
echo "$(date '+%Y-%m-%d %H:%M:%S') Dispatch failed for ${label} item $number (attempt ${retry_count}/${max_retries}); will retry after sleep." >&2
264-
fi
265-
sleep "$interval"
266-
continue
267-
fi
295+
echo "$(date '+%Y-%m-%d %H:%M:%S') Dispatching ${label} item $number ($item_id)"
296+
dispatch_watch_target "$make_target" "$number"
297+
dispatch_outcome=$WATCH_DISPATCH_OUTCOME
298+
dispatch_rc=$WATCH_DISPATCH_RC
299+
case "$dispatch_outcome" in
300+
processed)
301+
ack_polled_item "$state_file" "$item_id" || exit $?
302+
echo "$(date '+%Y-%m-%d %H:%M:%S') Processed ${label} item $number; rechecking queue immediately..."
303+
continue
304+
;;
305+
gone)
306+
echo "$(date '+%Y-%m-%d %H:%M:%S') ${label} item $number is no longer eligible; rechecking queue immediately..."
307+
continue
308+
;;
309+
*)
310+
echo "$(date '+%Y-%m-%d %H:%M:%S') Dispatch for ${label} item $number ended with outcome '$dispatch_outcome' (rc=${dispatch_rc}); sleeping $((interval / 60))m before retrying..." >&2
311+
sleep "$interval"
312+
continue
313+
;;
314+
esac
268315
elif [ "$status" -eq 1 ]; then
269-
echo "$(date '+%Y-%m-%d %H:%M:%S') No new ${label}, sleeping $((interval / 60))m..."
316+
echo "$(date '+%Y-%m-%d %H:%M:%S') No eligible ${label}, sleeping $((interval / 60))m..."
270317
sleep "$interval"
271318
else
272319
exit "$status"

scripts/test_make_helpers.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
import shutil
33
import subprocess
4+
import tempfile
45
import unittest
56
from pathlib import Path
67

@@ -371,6 +372,17 @@ def test_make_run_review_uses_skill_bundle_context(self) -> None:
371372
self.assertIn('review_pipeline_context "$repo"', proc.stdout)
372373
self.assertIn('skill_prompt_with_context review-pipeline', proc.stdout)
373374

375+
def test_make_run_review_watch_mode_emits_outcome_markers(self) -> None:
376+
proc = subprocess.run(
377+
["make", "-n", "run-review", "WATCH_MODE=1", "N=570"],
378+
cwd=REPO_ROOT,
379+
capture_output=True,
380+
text=True,
381+
)
382+
self.assertEqual(proc.returncode, 0, proc.stderr)
383+
self.assertIn("watch_emit_outcome gone", proc.stdout)
384+
self.assertIn('run_agent_with_watch_outcome "review-output.log" "$PROMPT"', proc.stdout)
385+
374386
def test_make_run_pipeline_uses_scripted_board_selection(self) -> None:
375387
proc = subprocess.run(
376388
["make", "-n", "run-pipeline"],
@@ -382,6 +394,17 @@ def test_make_run_pipeline_uses_scripted_board_selection(self) -> None:
382394
self.assertIn('board_next_json ready "" "" "$tmp_state"', proc.stdout)
383395
self.assertIn('skill_prompt_with_context project-pipeline', proc.stdout)
384396

397+
def test_make_run_pipeline_watch_mode_emits_outcome_markers(self) -> None:
398+
proc = subprocess.run(
399+
["make", "-n", "run-pipeline", "WATCH_MODE=1", "N=42"],
400+
cwd=REPO_ROOT,
401+
capture_output=True,
402+
text=True,
403+
)
404+
self.assertEqual(proc.returncode, 0, proc.stderr)
405+
self.assertIn("watch_emit_outcome gone", proc.stdout)
406+
self.assertIn('run_agent_with_watch_outcome "pipeline-output.log" "$PROMPT"', proc.stdout)
407+
385408
def test_watch_and_dispatch_uses_persistent_default_state_file(self) -> None:
386409
if shutil.which("dash") is None:
387410
self.skipTest("dash is not installed")
@@ -406,7 +429,7 @@ def test_watch_and_dispatch_uses_persistent_default_state_file(self) -> None:
406429
proc.stderr,
407430
)
408431

409-
def test_watch_and_dispatch_sleeps_after_successful_dispatch(self) -> None:
432+
def test_watch_and_dispatch_rechecks_immediately_after_successful_dispatch(self) -> None:
410433
if shutil.which("dash") is None:
411434
self.skipTest("dash is not installed")
412435

@@ -437,7 +460,92 @@ def test_watch_and_dispatch_sleeps_after_successful_dispatch(self) -> None:
437460
self.assertEqual(proc.returncode, 2, proc.stderr)
438461
self.assertIn("make:run-pipeline N=42", proc.stdout)
439462
self.assertIn("ack:PVTI_1", proc.stdout)
440-
self.assertIn("sleep:600", proc.stdout)
463+
self.assertNotIn("sleep:600", proc.stdout)
464+
465+
def test_watch_and_dispatch_drains_ready_items_before_sleeping(self) -> None:
466+
if shutil.which("dash") is None:
467+
self.skipTest("dash is not installed")
468+
469+
proc = subprocess.run(
470+
[
471+
"dash",
472+
"-c",
473+
(
474+
". scripts/make_helpers.sh; "
475+
"counter_file=/tmp/test-watch-drain-$$.count; "
476+
"rm -f \"$counter_file\"; "
477+
"date() { printf '2026-03-16 00:00:00'; }; "
478+
"poll_project_items() { "
479+
" count=$(cat \"$counter_file\" 2>/dev/null || printf '0'); "
480+
" count=$((count + 1)); "
481+
" printf '%s' \"$count\" > \"$counter_file\"; "
482+
" case \"$count\" in "
483+
" 1) printf 'PVTI_1\\t42\\n'; return 0 ;; "
484+
" 2) printf 'PVTI_2\\t43\\n'; return 0 ;; "
485+
" 3) return 1 ;; "
486+
" *) return 2 ;; "
487+
" esac; "
488+
"}; "
489+
"make() { printf 'make:%s %s\\n' \"$1\" \"$2\"; return 0; }; "
490+
"ack_polled_item() { printf 'ack:%s\\n' \"$2\"; }; "
491+
"sleep() { printf 'sleep:%s\\n' \"$1\"; return 0; }; "
492+
"MAKE=make POLL_INTERVAL=600 "
493+
"watch_and_dispatch ready run-pipeline 'Ready issues'"
494+
),
495+
],
496+
cwd=REPO_ROOT,
497+
capture_output=True,
498+
text=True,
499+
)
500+
self.assertEqual(proc.returncode, 2, proc.stderr)
501+
self.assertIn("make:run-pipeline N=42", proc.stdout)
502+
self.assertIn("ack:PVTI_1", proc.stdout)
503+
self.assertIn("make:run-pipeline N=43", proc.stdout)
504+
self.assertIn("ack:PVTI_2", proc.stdout)
505+
self.assertEqual(proc.stdout.count("sleep:600"), 1)
506+
self.assertLess(
507+
proc.stdout.index("make:run-pipeline N=43"),
508+
proc.stdout.index("sleep:600"),
509+
)
510+
511+
def test_watch_and_dispatch_skips_retry_state_for_explicit_gone_outcome(self) -> None:
512+
if shutil.which("dash") is None:
513+
self.skipTest("dash is not installed")
514+
515+
with tempfile.TemporaryDirectory() as tmpdir:
516+
state_file = Path(tmpdir) / "gone-state.json"
517+
counter_file = Path(tmpdir) / "gone-counter.txt"
518+
proc = subprocess.run(
519+
[
520+
"dash",
521+
"-c",
522+
(
523+
". scripts/make_helpers.sh; "
524+
f"state_file={state_file}; "
525+
f"counter_file={counter_file}; "
526+
"rm -f \"$state_file\" \"$counter_file\"; "
527+
"poll_project_items() { "
528+
" count=$(cat \"$counter_file\" 2>/dev/null || printf '0'); "
529+
" count=$((count + 1)); "
530+
" printf '%s' \"$count\" > \"$counter_file\"; "
531+
" if [ \"$count\" -eq 1 ]; then printf 'PVTI_1\\t42\\n'; return 0; fi; "
532+
" return 2; "
533+
"}; "
534+
"make() { printf '__WATCH_OUTCOME__=gone\\n'; return 3; }; "
535+
"move_board_item() { printf 'move:%s %s\\n' \"$1\" \"$2\"; }; "
536+
"sleep() { printf 'sleep:%s\\n' \"$1\"; return 0; }; "
537+
"STATE_FILE=\"$state_file\" MAKE=make POLL_INTERVAL=600 "
538+
"watch_and_dispatch ready run-pipeline 'Ready issues'"
539+
),
540+
],
541+
cwd=REPO_ROOT,
542+
capture_output=True,
543+
text=True,
544+
)
545+
state_file_exists = state_file.exists()
546+
self.assertEqual(proc.returncode, 2, proc.stderr)
547+
self.assertNotIn("move:PVTI_1 on-hold", proc.stdout)
548+
self.assertFalse(state_file_exists)
441549

442550
def test_watch_and_dispatch_calls_poll_with_correct_args(self) -> None:
443551
if shutil.which("dash") is None:

0 commit comments

Comments
 (0)