|
953 | 953 | "- 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" |
954 | 954 | "- 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" |
955 | 955 | "- 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")) |
957 | 957 |
|
958 | 958 | (defn codex-sol-policy-prompt [agent] |
959 | 959 | (str "High-cost model routing policy:\n" |
|
971 | 971 | "~/.claude/skills/obsidian-task-board/bin/task-board.bb" |
972 | 972 | "~/.codex/skills/obsidian-task-board/bin/task-board.bb")] |
973 | 973 | (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, " |
974 | 975 | "append a note to this ticket's Notes by running:\n" |
975 | 976 | " bb " helper " append-note " ticket-id " --vault \"$CODEX_TASK_BOARD_VAULT\" --source " agent " --note \"<milestone summary>\"\n"))) |
976 | 977 |
|
|
1055 | 1056 | (defn pr-gate-poll-seconds [] |
1056 | 1057 | (env-long "CODEX_TASK_BOARD_PR_GATE_POLL_SECONDS" "15")) |
1057 | 1058 |
|
| 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 | + |
1058 | 1120 | (defn no-ci-repos [] |
1059 | 1121 | ;; Explicit opt-in list of repos (owner/name) known to have no PR CI workflows. |
1060 | 1122 | ;; When a PR belongs to a listed repo and mergeStateStatus=CLEAN with no checks, |
|
1355 | 1417 | (fs/create-dirs dir) |
1356 | 1418 | (spit (str prompt-path) (prompt-for action ticket-id lane workspace agent)) |
1357 | 1419 | (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"] |
1361 | 1423 | (= "true" (env "CODEX_TASK_BOARD_BYPASS_APPROVALS" "true")) |
1362 | 1424 | (conj "--dangerously-skip-permissions") |
1363 | 1425 |
|
|
1368 | 1430 | true |
1369 | 1431 | (into (fable-model-args))) |
1370 | 1432 |
|
1371 | | - (cond-> ["codex" "exec" "--json" "--cd" (:workspace-dir workspace) |
| 1433 | + (cond-> ["codex" "exec" "--json" "--cd" (:workspace-dir workspace) |
1372 | 1434 | "--skip-git-repo-check" |
1373 | 1435 | "--output-last-message" (str last-message-path)] |
1374 | 1436 | (= "true" (env "CODEX_TASK_BOARD_BYPASS_APPROVALS" "true")) |
|
1386 | 1448 |
|
1387 | 1449 | true |
1388 | 1450 | (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) |
1394 | 1458 | exit (:exit proc) |
1395 | 1459 | _ (when (and (= "fable" agent) (fs/exists? stdout-path)) |
1396 | 1460 | (io/copy (io/file (str stdout-path)) |
|
1404 | 1468 | :agent agent |
1405 | 1469 | :lane lane |
1406 | 1470 | :exit-code exit |
| 1471 | + :idle-timeout? idle-timeout? |
1407 | 1472 | :result marker |
1408 | 1473 | :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?}))) |
1410 | 1480 |
|
1411 | 1481 | (defn candidate-action [{:keys [lane status]} assignee] |
1412 | 1482 | (when (supported-assignee? assignee) |
|
1437 | 1507 | :else |
1438 | 1508 | intended))) |
1439 | 1509 |
|
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?] |
1441 | 1511 | (let [base (str "Codex task-board run " run-id " finished with result " next-status ".") |
1442 | 1512 | pr-urls (github-pr-urls last-message)] |
1443 | 1513 | (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 | + |
1444 | 1518 | (and (= "blocked" next-status) |
1445 | 1519 | (not (#{"done" "blocked"} result)) |
1446 | 1520 | (not (:ok? review-gate))) |
|
1484 | 1558 | (move-card! ticket-id "in-progress") |
1485 | 1559 | (update-frontmatter! ticket-id {:status "in-progress"})) |
1486 | 1560 | (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) |
1488 | 1562 | intended (cond |
1489 | 1563 | (not (zero? exit)) "blocked" |
1490 | 1564 | (= :groom action) "ready" |
|
1497 | 1571 | (record-pr-gate-failure! ticket-id run-id assignee gate-result) |
1498 | 1572 | gate-result)) |
1499 | 1573 | {: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)})) |
1501 | 1586 | (when (= "review" intended) |
1502 | 1587 | (mark-run! ticket-id run-id (cond |
1503 | 1588 | (:ok? review-gate) :succeeded |
|
1516 | 1601 | (update-frontmatter! ticket-id (cond-> {:status next-status |
1517 | 1602 | :assignee (if (= "in-progress" next-status) assignee "boxp")} |
1518 | 1603 | (= "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?)) |
1520 | 1605 | true) |
1521 | 1606 | (catch Exception e |
1522 | 1607 | (move-card! ticket-id "blocked") |
|
0 commit comments