Skip to content

Commit fa6849a

Browse files
authored
Merge pull request #12228 from boxp/fix-task-board-runner-fable-stall
fix(task-board): retry idle Fable runs
2 parents 2ea3b27 + bbf7ca4 commit fa6849a

3 files changed

Lines changed: 242 additions & 15 deletions

File tree

docker/codex-workspace/task-board/task_board_runner.bb

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -953,7 +953,7 @@
953953
"- Delegate long investigation, implementation, file editing, and test execution to Codex whenever practical. If no explicit Codex model is supplied, use the default Codex route: gpt-5.6-terra (GPT-5.5-equivalent, cost-efficient), unless CODEX_TASK_BOARD_MODEL overrides it. Reserve gpt-5.6-sol (via codex-sol/codex-full assignees) for high-complexity tasks. Use the prepared workspace and repository worktrees from this prompt.\n"
954954
"- If Codex is delegated work, preserve the Task Board runner contract: include a concise delegated-work summary in your final response and end with exactly one TASK_BOARD_RESULT marker that the runner can parse.\n"
955955
"- For repository changes, make sure a GitHub PR URL is included before returning TASK_BOARD_RESULT: review. If no repository changes were made, include TASK_BOARD_REVIEW_PR: none.\n"
956-
"- Progress logging: at each milestone (investigation complete, approach decided, PR created, blocker encountered), append a note to the ticket Notes by running: bb ~/.claude/skills/obsidian-task-board/bin/task-board.bb append-note TICKET_ID --vault \"$CODEX_TASK_BOARD_VAULT\" --source fable --note \"<milestone summary>\"\n\n"))
956+
"- Progress logging: at each milestone (investigation complete, approach decided, PR created, blocker encountered), append a note to the ticket Notes by running: bb ~/.claude/skills/obsidian-task-board/bin/task-board.bb append-note TICKET_ID --vault \"$CODEX_TASK_BOARD_VAULT\" --source fable --note \"<milestone summary>\". For lengthy work, log a concise checkpoint before CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS elapses; an entirely idle run is stopped and retried.\n\n"))
957957

958958
(defn codex-sol-policy-prompt [agent]
959959
(str "High-cost model routing policy:\n"
@@ -971,6 +971,7 @@
971971
"~/.claude/skills/obsidian-task-board/bin/task-board.bb"
972972
"~/.codex/skills/obsidian-task-board/bin/task-board.bb")]
973973
(str "Progress logging: at each milestone during your work (investigation complete, approach decided, PR created, blocker encountered), "
974+
"and before CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS elapses during lengthy work, "
974975
"append a note to this ticket's Notes by running:\n"
975976
" bb " helper " append-note " ticket-id " --vault \"$CODEX_TASK_BOARD_VAULT\" --source " agent " --note \"<milestone summary>\"\n")))
976977

@@ -1055,6 +1056,67 @@
10551056
(defn pr-gate-poll-seconds []
10561057
(env-long "CODEX_TASK_BOARD_PR_GATE_POLL_SECONDS" "15"))
10571058

1059+
(defn agent-idle-timeout-seconds []
1060+
;; Task duration is not a useful failure signal: valid implementation work can
1061+
;; take many hours. Only interrupt an agent when neither its output nor the
1062+
;; ticket itself has changed for this long. Set to 0 to disable the watchdog.
1063+
(let [seconds (env-long "CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS" "7200")]
1064+
(when (neg? seconds)
1065+
(throw (ex-info "CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS must not be negative"
1066+
{:value seconds})))
1067+
seconds))
1068+
1069+
(defn latest-modification-millis [paths]
1070+
(reduce max 0 (map (fn [path]
1071+
(let [file (io/file (str path))]
1072+
(if (.exists file) (.lastModified file) 0)))
1073+
paths)))
1074+
1075+
(defn signal-agent-process-group! [process signal]
1076+
;; `run-agent!` starts the CLI through `setsid`, making its PID the process
1077+
;; group ID. Signalling the negative PID covers all current and subsequently
1078+
;; spawned children, including those created while the agent handles TERM.
1079+
(let [result @(p/process ["/bin/kill" (str "-" signal) "--"
1080+
(str "-" (.pid process))]
1081+
{:out :string :err :string})]
1082+
(when-not (zero? (:exit result))
1083+
(log! (str "failed to send " signal " to idle agent process group: "
1084+
(str/trim (:err result)))))))
1085+
1086+
(defn await-agent! [proc progress-paths idle-timeout-seconds]
1087+
(let [process (:proc proc)
1088+
started-at (System/currentTimeMillis)]
1089+
(loop [last-progress-at (max started-at (latest-modification-millis progress-paths))]
1090+
(if-not (.isAlive process)
1091+
{:proc @proc :idle-timeout? false}
1092+
(do
1093+
(Thread/sleep 1000)
1094+
(let [observed-at (latest-modification-millis progress-paths)
1095+
last-progress-at (max last-progress-at observed-at)
1096+
idle-millis (- (System/currentTimeMillis) last-progress-at)]
1097+
(cond
1098+
;; The process can finish during the polling sleep. Check again
1099+
;; immediately before the timeout action so a valid final response
1100+
;; is not discarded as an idle retry.
1101+
(not (.isAlive process))
1102+
{:proc @proc :idle-timeout? false}
1103+
1104+
(and (pos? idle-timeout-seconds)
1105+
(>= idle-millis (* 1000 idle-timeout-seconds)))
1106+
(do
1107+
(log! (str "stopping idle agent after " idle-timeout-seconds
1108+
" seconds without progress"))
1109+
(do
1110+
(signal-agent-process-group! process "TERM")
1111+
(Thread/sleep 5000)
1112+
;; Send KILL even if the agent itself exited: its process group
1113+
;; can still contain a TERM-resistant or late-created child.
1114+
(signal-agent-process-group! process "KILL")
1115+
{:proc @proc :idle-timeout? true}))
1116+
1117+
:else
1118+
(recur last-progress-at))))))))
1119+
10581120
(defn no-ci-repos []
10591121
;; Explicit opt-in list of repos (owner/name) known to have no PR CI workflows.
10601122
;; When a PR belongs to a listed repo and mergeStateStatus=CLEAN with no checks,
@@ -1355,9 +1417,9 @@
13551417
(fs/create-dirs dir)
13561418
(spit (str prompt-path) (prompt-for action ticket-id lane workspace agent))
13571419
(mark-run! ticket-id run :running {:action action :agent agent :lane lane :started-at (now-str)})
1358-
(let [args (case agent
1359-
"fable"
1360-
(cond-> ["claude" "--print" "--output-format" "text"]
1420+
(let [agent-args (case agent
1421+
"fable"
1422+
(cond-> ["claude" "--print" "--output-format" "text"]
13611423
(= "true" (env "CODEX_TASK_BOARD_BYPASS_APPROVALS" "true"))
13621424
(conj "--dangerously-skip-permissions")
13631425

@@ -1368,7 +1430,7 @@
13681430
true
13691431
(into (fable-model-args)))
13701432

1371-
(cond-> ["codex" "exec" "--json" "--cd" (:workspace-dir workspace)
1433+
(cond-> ["codex" "exec" "--json" "--cd" (:workspace-dir workspace)
13721434
"--skip-git-repo-check"
13731435
"--output-last-message" (str last-message-path)]
13741436
(= "true" (env "CODEX_TASK_BOARD_BYPASS_APPROVALS" "true"))
@@ -1386,11 +1448,13 @@
13861448

13871449
true
13881450
(conj "-")))
1389-
proc @(p/process args (cond-> {:in (io/file (str prompt-path))
1390-
:out (io/file (str stdout-path))
1391-
:err (io/file (str stderr-path))}
1392-
(= "fable" agent)
1393-
(assoc :dir (:workspace-dir workspace))))
1451+
idle-timeout-seconds (agent-idle-timeout-seconds)
1452+
proc (p/process (into ["setsid"] agent-args) (cond-> {:in (io/file (str prompt-path))
1453+
:out (io/file (str stdout-path))
1454+
:err (io/file (str stderr-path))}
1455+
(= "fable" agent)
1456+
(assoc :dir (:workspace-dir workspace))))
1457+
{:keys [proc idle-timeout?]} (await-agent! proc [stdout-path stderr-path (ticket-path ticket-id)] idle-timeout-seconds)
13941458
exit (:exit proc)
13951459
_ (when (and (= "fable" agent) (fs/exists? stdout-path))
13961460
(io/copy (io/file (str stdout-path))
@@ -1404,9 +1468,15 @@
14041468
:agent agent
14051469
:lane lane
14061470
:exit-code exit
1471+
:idle-timeout? idle-timeout?
14071472
:result marker
14081473
:finished-at (now-str)}))
1409-
{:exit exit :result marker :run-id run :dir (str dir) :last-message last-message})))
1474+
{:exit exit
1475+
:result marker
1476+
:run-id run
1477+
:dir (str dir)
1478+
:last-message last-message
1479+
:idle-timeout? idle-timeout?})))
14101480

14111481
(defn candidate-action [{:keys [lane status]} assignee]
14121482
(when (supported-assignee? assignee)
@@ -1437,10 +1507,14 @@
14371507
:else
14381508
intended)))
14391509

1440-
(defn final-note [run-id next-status result last-message review-gate]
1510+
(defn final-note [run-id next-status result last-message review-gate idle-timeout?]
14411511
(let [base (str "Codex task-board run " run-id " finished with result " next-status ".")
14421512
pr-urls (github-pr-urls last-message)]
14431513
(cond
1514+
idle-timeout?
1515+
(str base " No agent progress was logged before the idle timeout; "
1516+
"the run was stopped and will be retried automatically.")
1517+
14441518
(and (= "blocked" next-status)
14451519
(not (#{"done" "blocked"} result))
14461520
(not (:ok? review-gate)))
@@ -1484,7 +1558,7 @@
14841558
(move-card! ticket-id "in-progress")
14851559
(update-frontmatter! ticket-id {:status "in-progress"}))
14861560
(append-note! ticket-id (str "Codex task-board run " (:run-id lock) " started from " lane " with action " (name action) " using " assignee "."))
1487-
(let [{:keys [exit result run-id dir last-message]} (run-agent! ticket-id action effective-lane assignee lock)
1561+
(let [{:keys [exit result run-id dir last-message idle-timeout?]} (run-agent! ticket-id action effective-lane assignee lock)
14881562
intended (cond
14891563
(not (zero? exit)) "blocked"
14901564
(= :groom action) "ready"
@@ -1497,7 +1571,18 @@
14971571
(record-pr-gate-failure! ticket-id run-id assignee gate-result)
14981572
gate-result))
14991573
{:ok? true})
1500-
next-status (final-status action result exit review-gate)]
1574+
next-status (if idle-timeout?
1575+
"in-progress"
1576+
(final-status action result exit review-gate))]
1577+
(when idle-timeout?
1578+
(mark-run! ticket-id run-id :retrying
1579+
{:action action
1580+
:agent assignee
1581+
:lane effective-lane
1582+
:exit-code exit
1583+
:result result
1584+
:idle-timeout? true
1585+
:finished-at (now-str)}))
15011586
(when (= "review" intended)
15021587
(mark-run! ticket-id run-id (cond
15031588
(:ok? review-gate) :succeeded
@@ -1516,7 +1601,7 @@
15161601
(update-frontmatter! ticket-id (cond-> {:status next-status
15171602
:assignee (if (= "in-progress" next-status) assignee "boxp")}
15181603
(= "done" next-status) (assoc :closed (today))))
1519-
(append-note! ticket-id (final-note run-id next-status result last-message review-gate))
1604+
(append-note! ticket-id (final-note run-id next-status result last-message review-gate idle-timeout?))
15201605
true)
15211606
(catch Exception e
15221607
(move-card! ticket-id "blocked")
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Task Board Runner Fable Stall Fix Plan
2+
3+
## Goal
4+
5+
Prevent Fable-assigned Task Board tickets from remaining in progress indefinitely
6+
when the agent stops making progress, without imposing a maximum duration on valid
7+
long-running work.
8+
9+
## Approach
10+
11+
1. Monitor the Fable/Codex process while it is running.
12+
2. Treat changes to the agent output or ticket file as progress.
13+
3. Stop only an agent that has produced no progress for the configured idle period
14+
(`CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS`, default: 7200 seconds).
15+
4. Keep the ticket in progress after an idle stop so the next poll retries it.
16+
5. Preserve explicit `TASK_BOARD_RESULT: blocked` handling for genuine blockers.
17+
18+
## Verification
19+
20+
- Add a regression test for an idle Fable process being retried.
21+
- Add a regression test that periodic ticket progress prevents an idle timeout.
22+
- Run the complete Task Board runner test suite with the CI Babashka version.
23+
- Run Codex review and merge only after its findings are resolved and PR CI passes.

tests/codex-workspace/task-board-runner-test.sh

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ fi
9999
if [[ -n "${CLAUDE_FAKE_START_LOG:-}" ]]; then
100100
printf '%s %s\n' "${ticket}" "$(date +%s)" >>"${CLAUDE_FAKE_START_LOG}"
101101
fi
102+
if [[ -n "${CLAUDE_FAKE_PROGRESS_FILE:-}" ]]; then
103+
for _ in $(seq 1 "${CLAUDE_FAKE_PROGRESS_COUNT:-1}"); do
104+
sleep "${CLAUDE_FAKE_PROGRESS_INTERVAL:-0}"
105+
printf '%s\n' 'fake agent progress' >>"${CLAUDE_FAKE_PROGRESS_FILE}"
106+
done
107+
fi
108+
if [[ -n "${CLAUDE_FAKE_CHILD_PID_FILE:-}" ]]; then
109+
if [[ "${CLAUDE_FAKE_CHILD_IGNORES_TERM:-false}" == true ]]; then
110+
(trap '' TERM; sleep "${CLAUDE_FAKE_CHILD_SLEEP:-30}") &
111+
else
112+
sleep "${CLAUDE_FAKE_CHILD_SLEEP:-30}" &
113+
fi
114+
printf '%s\n' "$!" >"${CLAUDE_FAKE_CHILD_PID_FILE}"
115+
wait "$!"
116+
fi
102117
sleep "${CLAUDE_FAKE_SLEEP:-0}"
103118
printf '%s\n' "${CLAUDE_FAKE_MESSAGE:-TASK_BOARD_RESULT: done}"
104119
EOF
@@ -299,6 +314,106 @@ test_fable_assignee_runs_via_claude() {
299314
assert_file_contains "${events}" '^TASK_BOARD_RESULT: done$'
300315
}
301316

317+
test_fable_agent_idle_timeout_retries() {
318+
local tmp vault state bin summary
319+
tmp="$(mktemp -d)"
320+
vault="${tmp}/vault"
321+
state="${tmp}/state"
322+
bin="${tmp}/bin"
323+
mkdir -p "${bin}"
324+
make_fake_claude "${bin}"
325+
write_board "${vault}" "- [ ] [[Tickets/BOXP-154|BOXP-154: stalled fable]] #ticket status::in-progress"
326+
write_ticket "${vault}" BOXP-154 in-progress fable
327+
328+
PATH="${bin}:$PATH" \
329+
CLAUDE_FAKE_SLEEP=2 \
330+
CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS=1 \
331+
run_tick "${vault}" "${state}" env >/tmp/task-board-fable-timeout.out
332+
333+
assert_file_contains "${vault}/Boards/Task Board.md" '\[\[Tickets/BOXP-154\|BOXP-154: stalled fable\]\].*status::in-progress'
334+
assert_file_contains "${vault}/Tickets/BOXP-154.md" '^status: in-progress$'
335+
assert_file_contains "${vault}/Tickets/BOXP-154.md" 'No agent progress was logged before the idle timeout; the run was stopped and will be retried automatically'
336+
summary="$(find "${state}/runs/BOXP-154" -name summary.edn -print | sort | tail -n 1)"
337+
assert_file_contains "${summary}" ':idle-timeout\? true'
338+
339+
PATH="${bin}:$PATH" \
340+
CLAUDE_FAKE_SLEEP=2 \
341+
CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS=1 \
342+
run_tick "${vault}" "${state}" env >/tmp/task-board-fable-timeout-retry.out
343+
344+
assert_file_contains "${vault}/Boards/Task Board.md" '\[\[Tickets/BOXP-154\|BOXP-154: stalled fable\]\].*status::in-progress'
345+
assert_file_contains "${vault}/Tickets/BOXP-154.md" '^status: in-progress$'
346+
}
347+
348+
test_fable_idle_timeout_stops_agent_children() {
349+
local tmp vault state bin child_pid_file child_pid
350+
tmp="$(mktemp -d)"
351+
vault="${tmp}/vault"
352+
state="${tmp}/state"
353+
bin="${tmp}/bin"
354+
child_pid_file="${tmp}/child.pid"
355+
mkdir -p "${bin}"
356+
make_fake_claude "${bin}"
357+
write_board "${vault}" "- [ ] [[Tickets/BOXP-156|BOXP-156: child process]] #ticket status::in-progress"
358+
write_ticket "${vault}" BOXP-156 in-progress fable
359+
360+
PATH="${bin}:$PATH" \
361+
CLAUDE_FAKE_CHILD_PID_FILE="${child_pid_file}" \
362+
CLAUDE_FAKE_CHILD_SLEEP=30 \
363+
CLAUDE_FAKE_CHILD_IGNORES_TERM=true \
364+
CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS=1 \
365+
run_tick "${vault}" "${state}" env >/tmp/task-board-fable-child-timeout.out
366+
367+
child_pid="$(cat "${child_pid_file}")"
368+
if kill -0 "${child_pid}" 2>/dev/null; then
369+
fail "expected idle timeout to stop Fable child process ${child_pid}"
370+
fi
371+
}
372+
373+
test_invalid_idle_timeout_does_not_start_agent() {
374+
local tmp vault state bin start_log
375+
tmp="$(mktemp -d)"
376+
vault="${tmp}/vault"
377+
state="${tmp}/state"
378+
bin="${tmp}/bin"
379+
start_log="${tmp}/starts.log"
380+
mkdir -p "${bin}"
381+
make_fake_claude "${bin}"
382+
write_board "${vault}" "- [ ] [[Tickets/BOXP-158|BOXP-158: invalid timeout]] #ticket status::in-progress"
383+
write_ticket "${vault}" BOXP-158 in-progress fable
384+
385+
PATH="${bin}:$PATH" \
386+
CLAUDE_FAKE_START_LOG="${start_log}" \
387+
CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS=-1 \
388+
run_tick "${vault}" "${state}" env >/tmp/task-board-invalid-idle-timeout.out
389+
390+
[[ ! -e "${start_log}" ]] || fail "expected invalid idle timeout to prevent Fable startup"
391+
assert_file_contains "${vault}/Tickets/BOXP-158.md" '^status: blocked$'
392+
}
393+
394+
test_fable_progress_prevents_idle_timeout() {
395+
local tmp vault state bin
396+
tmp="$(mktemp -d)"
397+
vault="${tmp}/vault"
398+
state="${tmp}/state"
399+
bin="${tmp}/bin"
400+
mkdir -p "${bin}"
401+
make_fake_claude "${bin}"
402+
write_board "${vault}" "- [ ] [[Tickets/BOXP-155|BOXP-155: long fable]] #ticket status::in-progress"
403+
write_ticket "${vault}" BOXP-155 in-progress fable
404+
405+
PATH="${bin}:$PATH" \
406+
CLAUDE_FAKE_PROGRESS_FILE="${vault}/Tickets/BOXP-155.md" \
407+
CLAUDE_FAKE_PROGRESS_COUNT=4 \
408+
CLAUDE_FAKE_PROGRESS_INTERVAL=0.4 \
409+
CLAUDE_FAKE_SLEEP=1 \
410+
CODEX_TASK_BOARD_AGENT_IDLE_TIMEOUT_SECONDS=1 \
411+
run_tick "${vault}" "${state}" env >/tmp/task-board-fable-progress.out
412+
413+
assert_file_contains "${vault}/Boards/Task Board.md" '\[\[Tickets/BOXP-155\|BOXP-155: long fable\]\].*status::done'
414+
assert_file_contains "${vault}/Tickets/BOXP-155.md" '^status: done$'
415+
}
416+
302417
test_codex_sol_assignee_includes_delegation_policy() {
303418
local tmp vault state bin prompt_log summary last_message
304419
tmp="$(mktemp -d)"
@@ -1726,6 +1841,10 @@ BOARD
17261841

17271842
test_parallel_codex_runs
17281843
test_fable_assignee_runs_via_claude
1844+
test_fable_agent_idle_timeout_retries
1845+
test_fable_idle_timeout_stops_agent_children
1846+
test_invalid_idle_timeout_does_not_start_agent
1847+
test_fable_progress_prevents_idle_timeout
17291848
test_codex_sol_assignee_includes_delegation_policy
17301849
test_codex_full_assignee_includes_delegation_policy
17311850
test_unsupported_assignee_is_ignored

0 commit comments

Comments
 (0)