Repository navigation
Expand file tree
/
Copy pathrun.sh
More file actions
executable file
·1296 lines (1207 loc) · 62 KB
/
Copy pathrun.sh
File metadata and controls
executable file
·1296 lines (1207 loc) · 62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
# Long-running AI agent loop
# Usage: ./run.sh [max_iterations] [tui] [--agent claude|codex|cursor|bravebot] [--model model]
# [--agent-bin path] [--comparison-run [--comparison-agent name]
# [--comparison-agent-bin path] [--comparison-model name]
# [--comparison-branch name]] [extra_prompt_info...]
# ./run.sh --status # what is running in this bot directory
#
# Choosing the story (extra_prompt_info, anything after `tui`):
# An issue or pull request URL, a "#613", or a story id names one story
# outright, whatever the queue thinks of it: one this run already worked, and
# one that is skipped, invalid or merged, which goes back to pending with its
# old status and reason kept in requeuedFrom. That story is worked or the run
# stops naming it and the reason; it is never quietly swapped for another. Any
# other wording is a hint the model reads instead, and a hint it cannot use
# leaves the deterministic queue in charge.
#
# bot.concurrentJobs above 1 makes this one command start that many runs of
# itself in parallel, each on a run slot of its own, and wait for all of them;
# their output is prefixed [job N]. Not for tui: one terminal hosts one session.
# Not for a URL either: ./run.sh <url> is one run, working the story it names.
# See docs/concurrent-runs.md.
#
# Several runs can share one bot directory when bot.maxConcurrentRuns is above
# 1: each takes a numbered *run slot* and keeps its own lock, run state, logs
# and story claim. See docs/concurrent-runs.md. At the default of 1 this is
# exactly the single-run loop it has always been, using the same paths.
#
# With a reviewer account configured (reviewer.username), a story whose pull request
# an iteration pushed to is not finished until that account has reviewed the new
# head: the run waits, and if the review asks for changes it works the story again
# and waits again, at most reviewer.maxRounds reviews (default 5) per story before
# it gives up on the story and goes on. See docs/review-gate.md.
#
# Agent selection precedence (highest wins):
# 1. --agent <name> CLI flag
# 2. BOT_AGENT env var
# 3. bot.agent in config.json
# 4. "claude" default
#
# Model selection:
# --model <name> overrides bot.claudeModel, bot.codexModel, or bot.cursorModel for the selected
# agent. bravebot has no config key, so --model is the only way to name a model for it.
#
# Binary selection:
# --agent-bin <path> runs the selected agent from that path instead of the configured
# one. bravebot has no config key of its own, so this is how a locally built binary is
# used: ./run.sh --agent bravebot --agent-bin target/release/bravebot
#
# Comparison runs (off unless asked for):
# --comparison-run works every story a second time with another tool, in a worktree of
# its own, then has a third run critique the first against it and file issues for the
# gaps. It never pushes, opens a PR, or comments anywhere. The comparison and evaluator
# runs default to claude; --comparison-agent / --comparison-agent-bin /
# --comparison-model override that, and --comparison-branch names the branch the
# comparison works in. See docs/comparison-runs.md.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/scripts/lib/run-slots.sh"
# --status answers "what is running here?" without starting anything.
if [ "${1:-}" = "--status" ]; then
exec "$SCRIPT_DIR/scripts/run-status.sh" "${@:2}"
fi
# Parse arguments
MAX_ITERATIONS=10
USE_TUI=false
EXTRA_PROMPT=""
NAMED_URL_COUNT=0
PAST_TUI=false
CLI_AGENT=""
CLI_MODEL=""
CLI_AGENT_BIN=""
COMPARISON_RUN=false
CLI_COMPARISON_AGENT=""
CLI_COMPARISON_AGENT_BIN=""
CLI_COMPARISON_MODEL=""
CLI_COMPARISON_BRANCH=""
EXPECT_AGENT_VALUE=false
EXPECT_MODEL_VALUE=false
EXPECT_AGENT_BIN_VALUE=false
EXPECT_COMPARISON_AGENT_VALUE=false
EXPECT_COMPARISON_AGENT_BIN_VALUE=false
EXPECT_COMPARISON_MODEL_VALUE=false
EXPECT_COMPARISON_BRANCH_VALUE=false
for arg in "$@"; do
if [ "$EXPECT_AGENT_VALUE" = true ]; then
CLI_AGENT="$arg"
EXPECT_AGENT_VALUE=false
continue
fi
if [ "$EXPECT_COMPARISON_AGENT_VALUE" = true ]; then
CLI_COMPARISON_AGENT="$arg"
EXPECT_COMPARISON_AGENT_VALUE=false
continue
fi
if [ "$EXPECT_COMPARISON_AGENT_BIN_VALUE" = true ]; then
CLI_COMPARISON_AGENT_BIN="$arg"
EXPECT_COMPARISON_AGENT_BIN_VALUE=false
continue
fi
if [ "$EXPECT_COMPARISON_MODEL_VALUE" = true ]; then
CLI_COMPARISON_MODEL="$arg"
EXPECT_COMPARISON_MODEL_VALUE=false
continue
fi
if [ "$EXPECT_COMPARISON_BRANCH_VALUE" = true ]; then
CLI_COMPARISON_BRANCH="$arg"
EXPECT_COMPARISON_BRANCH_VALUE=false
continue
fi
if [ "$EXPECT_MODEL_VALUE" = true ]; then
CLI_MODEL="$arg"
EXPECT_MODEL_VALUE=false
continue
fi
if [ "$EXPECT_AGENT_BIN_VALUE" = true ]; then
CLI_AGENT_BIN="$arg"
EXPECT_AGENT_BIN_VALUE=false
continue
fi
# --agent is recognized regardless of position (before or after `tui`).
if [[ "$arg" == "--agent" ]]; then
EXPECT_AGENT_VALUE=true
continue
elif [[ "$arg" == --agent=* ]]; then
CLI_AGENT="${arg#--agent=}"
continue
elif [[ "$arg" == "--agent-bin" ]]; then
EXPECT_AGENT_BIN_VALUE=true
continue
elif [[ "$arg" == --agent-bin=* ]]; then
CLI_AGENT_BIN="${arg#--agent-bin=}"
continue
elif [[ "$arg" == "--model" ]]; then
EXPECT_MODEL_VALUE=true
continue
elif [[ "$arg" == --model=* ]]; then
CLI_MODEL="${arg#--model=}"
continue
elif [[ "$arg" == "--comparison-run" ]]; then
COMPARISON_RUN=true
continue
elif [[ "$arg" == "--comparison-agent" ]]; then
EXPECT_COMPARISON_AGENT_VALUE=true
continue
elif [[ "$arg" == --comparison-agent=* ]]; then
CLI_COMPARISON_AGENT="${arg#--comparison-agent=}"
continue
elif [[ "$arg" == "--comparison-agent-bin" ]]; then
EXPECT_COMPARISON_AGENT_BIN_VALUE=true
continue
elif [[ "$arg" == --comparison-agent-bin=* ]]; then
CLI_COMPARISON_AGENT_BIN="${arg#--comparison-agent-bin=}"
continue
elif [[ "$arg" == "--comparison-model" ]]; then
EXPECT_COMPARISON_MODEL_VALUE=true
continue
elif [[ "$arg" == --comparison-model=* ]]; then
CLI_COMPARISON_MODEL="${arg#--comparison-model=}"
continue
elif [[ "$arg" == "--comparison-branch" ]]; then
EXPECT_COMPARISON_BRANCH_VALUE=true
continue
elif [[ "$arg" == --comparison-branch=* ]]; then
CLI_COMPARISON_BRANCH="${arg#--comparison-branch=}"
continue
fi
# A URL names a story wherever it stands: without this, ./run.sh <url> would
# drop it and work whatever the queue offers.
if [[ "$arg" =~ ^https?:// ]]; then
NAMED_URL_COUNT=$((NAMED_URL_COUNT + 1))
EXTRA_PROMPT="${EXTRA_PROMPT:+$EXTRA_PROMPT }$arg"
continue
fi
if [ "$PAST_TUI" = true ]; then
# Everything else after 'tui' is extra prompt info
if [ -n "$EXTRA_PROMPT" ]; then
EXTRA_PROMPT="$EXTRA_PROMPT $arg"
else
EXTRA_PROMPT="$arg"
fi
elif [[ "$arg" =~ ^[0-9]+$ ]]; then
MAX_ITERATIONS="$arg"
elif [[ "$arg" == "tui" ]]; then
USE_TUI=true
PAST_TUI=true
fi
done
if [ "$EXPECT_AGENT_VALUE" = true ]; then
echo "Error: --agent requires a value (expected: claude | codex | cursor | bravebot)" >&2
exit 1
fi
if [ "$EXPECT_MODEL_VALUE" = true ]; then
echo "Error: --model requires a value" >&2
exit 1
fi
if [ "$EXPECT_AGENT_BIN_VALUE" = true ]; then
echo "Error: --agent-bin requires a value" >&2
exit 1
fi
if [ "$EXPECT_COMPARISON_AGENT_VALUE" = true ]; then
echo "Error: --comparison-agent requires a value (expected: claude | codex | cursor | bravebot)" >&2
exit 1
fi
if [ "$EXPECT_COMPARISON_AGENT_BIN_VALUE" = true ]; then
echo "Error: --comparison-agent-bin requires a value" >&2
exit 1
fi
if [ "$EXPECT_COMPARISON_MODEL_VALUE" = true ]; then
echo "Error: --comparison-model requires a value" >&2
exit 1
fi
if [ "$EXPECT_COMPARISON_BRANCH_VALUE" = true ]; then
echo "Error: --comparison-branch requires a value" >&2
exit 1
fi
# A comparison flag with no --comparison-run is a run that silently does not
# compare anything, which is the one outcome nobody wants from typing them.
if [ "$COMPARISON_RUN" != true ] &&
{ [ -n "$CLI_COMPARISON_AGENT" ] || [ -n "$CLI_COMPARISON_AGENT_BIN" ] ||
[ -n "$CLI_COMPARISON_MODEL" ] || [ -n "$CLI_COMPARISON_BRANCH" ]; }; then
echo "Error: --comparison-* configures a comparison run, but --comparison-run was not given." >&2
exit 1
fi
source "$SCRIPT_DIR/scripts/lib/load-config.sh"
source "$SCRIPT_DIR/scripts/lib/parallel-jobs.sh"
source "$SCRIPT_DIR/scripts/lib/git-identity.sh"
source "$SCRIPT_DIR/scripts/lib/repo-hooks.sh"
source "$SCRIPT_DIR/scripts/lib/terminal-title.sh"
source "$SCRIPT_DIR/scripts/lib/review-gate.sh"
# This run titles its own tab, with the issue and PR numbers an operator
# switches tabs to find. Claude would otherwise overwrite that every turn with
# a summary of its conversation.
export CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1
# Pin this run to the bot's GitHub identity before anything can touch GitHub.
bot_export_identity_env "$BOT_SSH_KEY_PATH" "$BOT_GH_ACCOUNT" || exit 1
# A profile nobody chose produces wrong work rather than no work — generic
# validations, the wrong docs — and does it silently, iteration after
# iteration. Stop instead, saying which line to change.
if PROFILE_MISMATCH=$(bot_profile_mismatch); then
echo "Error: $PROFILE_MISMATCH" >&2
exit 1
fi
bot_validate_review_config || exit 1
# --- Concurrency ----------------------------------------------------------
# Running two agents against one working tree destroys work, so concurrency is
# allowed only for a profile that gives every story its own git worktree.
bot_validate_concurrency \
"$BOT_MAX_CONCURRENT_RUNS" "$BOT_PROFILE" "$BOT_PROFILE_WORKTREES" || exit 1
bot_validate_concurrent_jobs "$BOT_CONCURRENT_JOBS" "$BOT_MAX_CONCURRENT_RUNS" || exit 1
# bot.concurrentJobs: this command becomes the supervisor of that many runs of
# itself, and takes no slot. Each of them re-enters here with BOT_RUN_JOB set,
# which is what stops it starting more. A terminal session cannot be one of
# several on one terminal, so tui runs the single job it always did. A URL
# names one story, which only one run may work, so it runs one job too.
if [ -z "${BOT_RUN_JOB:-}" ] && [ "$BOT_CONCURRENT_JOBS" -gt 1 ]; then
if [ "$USE_TUI" = true ]; then
echo "bot.concurrentJobs is $BOT_CONCURRENT_JOBS, but tui mode puts one agent session on the terminal: running one job." >&2
elif [ "$NAMED_URL_COUNT" -gt 0 ]; then
echo "bot.concurrentJobs is $BOT_CONCURRENT_JOBS, but the request names a story by URL: running one job." >&2
else
bot_run_parallel_jobs "$BOT_CONCURRENT_JOBS" "$SCRIPT_DIR/run.sh" "$@"
fi
fi
# Take a run slot. Everything this run cannot share with another — run state,
# iteration logs, the story it is working — is keyed to the slot number.
SLOT_RC=0
bot_acquire_run_slot "$SCRIPT_DIR" "$BOT_MAX_CONCURRENT_RUNS" || SLOT_RC=$?
case $SLOT_RC in
0) ;;
1)
if [ "$BOT_MAX_CONCURRENT_RUNS" = "1" ]; then
echo "Another run.sh is already running. Exiting."
else
echo "All $BOT_MAX_CONCURRENT_RUNS run slots are busy. Exiting."
fi
printf '%s' "$BOT_SLOT_BUSY_REPORT"
exit 0 ;;
*) echo "Could not acquire a run slot. Exiting." >&2; exit 1 ;;
esac
BOT_RUN_PID=$$
# One id and one directory per run.sh, outside the checkout: the agent's logs for each
# iteration, and the output of the housekeeping around them, are here and nowhere shared.
# The slot is held exclusively, so slot plus second cannot collide with another run.
RUN_SESSION_ID="$(date -u +%Y%m%dT%H%M%SZ)-slot-${BOT_RUN_SLOT}"
RUN_SESSION_DIR="${BRAVE_DEV_LOOP_HOME:-$HOME/.brave-dev-loop}/sessions/$RUN_SESSION_ID"
mkdir -p "$RUN_SESSION_DIR"
HOUSEKEEPING_LOG="$RUN_SESSION_DIR/housekeeping.log"
BOT_RUN_SESSION_ID="$RUN_SESSION_ID"
BOT_RUN_SESSION_DIR="$RUN_SESSION_DIR"
export BOT_RUN_SLOT BOT_RUN_PID BOT_RUN_SESSION_ID BOT_RUN_SESSION_DIR
CURRENT_STORY_ID=""
TITLE_WATCH_PID=""
# CLI --agent flag has the final say (overrides env + config)
if [ -n "$CLI_AGENT" ]; then
BOT_AGENT="$CLI_AGENT"
fi
case "$BOT_AGENT" in
claude|codex|cursor|bravebot) ;;
*)
echo "Error: unsupported agent '$BOT_AGENT' (expected: claude | codex | cursor | bravebot)" >&2
exit 1
;;
esac
if [ -n "$CLI_MODEL" ]; then
if [ "$BOT_AGENT" = "codex" ]; then
BOT_CODEX_MODEL="$CLI_MODEL"
elif [ "$BOT_AGENT" = "cursor" ]; then
BOT_CURSOR_MODEL="$CLI_MODEL"
elif [ "$BOT_AGENT" = "bravebot" ]; then
BOT_BRAVEBOT_MODEL="$CLI_MODEL"
else
BOT_CLAUDE_MODEL="$CLI_MODEL"
fi
fi
# Applied to whichever agent is selected, so a locally built binary needs no config edit.
if [ -n "$CLI_AGENT_BIN" ]; then
if [ "$BOT_AGENT" = "codex" ]; then
BOT_CODEX_BIN="$CLI_AGENT_BIN"
elif [ "$BOT_AGENT" = "cursor" ]; then
BOT_CURSOR_BIN="$CLI_AGENT_BIN"
elif [ "$BOT_AGENT" = "bravebot" ]; then
BOT_BRAVEBOT_BIN="$CLI_AGENT_BIN"
else
BOT_CLAUDE_BIN="$CLI_AGENT_BIN"
fi
fi
# The comparison and evaluator runs default to claude whatever the base agent is:
# the point is a second opinion from a different tool, and claude is the loop's
# reference implementation of the workflow.
COMPARISON_AGENT="claude"
COMPARISON_AGENT_BIN=""
COMPARISON_MODEL=""
if [ "$COMPARISON_RUN" = true ]; then
if [ -n "$CLI_COMPARISON_AGENT" ]; then
COMPARISON_AGENT="$CLI_COMPARISON_AGENT"
fi
case "$COMPARISON_AGENT" in
claude|codex|cursor|bravebot) ;;
*)
echo "Error: unsupported comparison agent '$COMPARISON_AGENT' (expected: claude | codex | cursor | bravebot)" >&2
exit 1
;;
esac
case "$COMPARISON_AGENT" in
codex) COMPARISON_AGENT_BIN="$BOT_CODEX_BIN"; COMPARISON_MODEL="$BOT_CODEX_MODEL" ;;
cursor) COMPARISON_AGENT_BIN="$BOT_CURSOR_BIN"; COMPARISON_MODEL="$BOT_CURSOR_MODEL" ;;
bravebot) COMPARISON_AGENT_BIN="$BOT_BRAVEBOT_BIN"; COMPARISON_MODEL="$BOT_BRAVEBOT_MODEL" ;;
*) COMPARISON_AGENT_BIN="$BOT_CLAUDE_BIN"; COMPARISON_MODEL="$BOT_CLAUDE_MODEL" ;;
esac
if [ -n "$CLI_COMPARISON_AGENT_BIN" ]; then
COMPARISON_AGENT_BIN="$CLI_COMPARISON_AGENT_BIN"
fi
if [ -n "$CLI_COMPARISON_MODEL" ]; then
COMPARISON_MODEL="$CLI_COMPARISON_MODEL"
fi
fi
PRD_FILE="$SCRIPT_DIR/data/prd.json"
PROGRESS_FILE="$SCRIPT_DIR/data/progress.txt"
# What one iteration may spend on its agent, a resumed session included.
ITERATION_SECONDS=10800
# A Claude session that writes nothing to its transcript for this long is stopped
# early: an API error leaves it idle until the limit otherwise, and so does a
# terminal session whose turn ended with nobody there to answer. A Bash call is
# capped at 10 minutes and wait-gate.sh returns inside that, so a session that is
# working writes well within it.
QUIET_SECONDS=1200
# Slot 1 uses data/run-state.json, as it always has; further slots get their
# own file so two runs never share iteration bookkeeping. The agent reads
# BOT_RUN_STATE_FILE, so its update-prd-status.py calls land in the right one.
RUN_STATE_FILE=$(bot_slot_run_state_file "$SCRIPT_DIR" "$BOT_RUN_SLOT")
BOT_RUN_STATE_FILE="$RUN_STATE_FILE"
export BOT_RUN_STATE_FILE
# Resolve target repo path from config.json (required)
if [ -z "${BOT_TARGET_REPO_PATH:-}" ]; then
echo "Error: project.targetRepoPath not set in config.json"
exit 1
fi
GIT_REPO="$BOT_TARGET_REPO_DIR"
if [ ! -e "$GIT_REPO/.git" ]; then
echo "Error: project.targetRepoPath '$BOT_TARGET_REPO_PATH' does not resolve to a git repo."
echo " Tried: $SCRIPT_DIR/$BOT_TARGET_REPO_PATH"
echo " and $(dirname "$SCRIPT_DIR")/$BOT_TARGET_REPO_PATH"
exit 1
fi
# Every run, rather than at setup only: a hook added to this checkout otherwise never reaches the
# repo it guards, and the repo cannot say what it is missing.
repo_refresh_bot_hooks "$GIT_REPO" "$BOT_USERNAME" "$SCRIPT_DIR/hooks"
# Hand the current story back so another run (or the next iteration) can take
# it. A run that is killed outright skips this, and that is fine: a claim only
# counts while its slot lock is held, which the kernel drops on death.
release_claim() {
[ -n "$CURRENT_STORY_ID" ] || return 0
# stderr stays visible: a failed label release is otherwise a label that
# blocks the issue on every machine with no trace of why.
# Release by slot, not by story: a clustered iteration holds companions beside
# its lead (scripts/claim-companions.py), and a claim left behind would keep
# them out of the queue for as long as this run lives. A slot holds nothing
# between iterations, so everything it holds is this iteration's.
"$SCRIPT_DIR/scripts/claims.py" release \
--slot "$BOT_RUN_SLOT" >/dev/null || true
CURRENT_STORY_ID=""
}
# The PR watcher polls until a PR appears, so an iteration that ends first has
# to stop it, or it keeps polling for a story this run no longer holds.
stop_title_watch() {
[ -n "$TITLE_WATCH_PID" ] || return 0
kill "$TITLE_WATCH_PID" 2>/dev/null || true
wait "$TITLE_WATCH_PID" 2>/dev/null || true
TITLE_WATCH_PID=""
}
# Ask select-task.py for the next story. $1 is the request: a story named
# outright, a hint for the model to read, or nothing for the deterministic queue.
select_story() {
if [ -n "$1" ]; then
python3 "$SCRIPT_DIR/scripts/select-task.py" \
--prd "$PRD_FILE" \
--run-state "$RUN_STATE_FILE" \
--iteration-log "$ITERATION_LOG" \
--claude-bin "$BOT_CLAUDE_BIN" \
--slot "$BOT_RUN_SLOT" --run-pid "$BOT_RUN_PID" --run-id "$RUN_ID" \
--extra-prompt "$1"
else
python3 "$SCRIPT_DIR/scripts/select-task.py" \
--prd "$PRD_FILE" \
--run-state "$RUN_STATE_FILE" \
--iteration-log "$ITERATION_LOG" \
--slot "$BOT_RUN_SLOT" --run-pid "$BOT_RUN_PID" --run-id "$RUN_ID"
fi
}
# What the agent does with a story in this status, in one line. Printed in the
# iteration banner and again when the iteration ends, so the terminal says what
# is about to happen (and what happens next) rather than only which story was
# picked. Each phrase is the goal stated at the top of the matching workflow doc.
story_next_step() {
case "$1" in
pending) printf '%s' "implement and test the fix, then commit" ;;
committed) printf '%s' "push the branch and open the PR" ;;
pushed) printf '%s' "check CI and reviews, address feedback, wait for the maintainer to merge" ;;
merged) printf '%s' "nothing — the PR landed and the story is done" ;;
skipped) printf '%s' "nothing — intentionally skipped" ;;
invalid) printf '%s' "nothing — closed as invalid" ;;
*) printf '%s' "follow docs/workflow-$1.md" ;;
esac
}
# Runs a housekeeping step (PRD sync, label sweep, worktree cleanup) with its output
# in this session's housekeeping.log instead of the terminal, which is the agent's.
# Only what needs a reader reaches the terminal: a line a step prints that starts
# with "warning" or "error", and the tail of one that failed. Returns the step's status.
quiet_step() {
local label="$1" out rc=0
shift
out=$(mktemp "${TMPDIR:-/tmp}/housekeeping.XXXXXX")
"$@" >"$out" 2>&1 || rc=$?
{
printf '[%s] %s (exit %d)\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$label" "$rc"
cat "$out"
echo
} >> "$HOUSEKEEPING_LOG"
grep -iE '^(warning|error)' "$out" >&2 || true
if [ "$rc" -ne 0 ]; then
tail -n 5 "$out" >&2
echo "$label failed (exit $rc); full output in $HOUSEKEEPING_LOG" >&2
fi
rm -f "$out"
return "$rc"
}
cleanup_run() {
stop_title_watch
# Hand back the worktrees this session finished with, before the claim goes:
# a run interrupted mid-iteration still holds one, and that claim is what
# keeps clean-worktrees.py out of the worktree the agent was working in. No
# age limit here — a session that has ended is done with all of them.
if [ "$BOT_PROFILE_WORKTREES" = true ]; then
quiet_step "clean worktrees (session end)" \
python3 "$SCRIPT_DIR/scripts/clean-worktrees.py" || true
fi
release_claim
bot_slot_meta_clear
bot_release_lock
# Under a worktree profile the main checkout is not this run's to touch:
# every story works in its own worktree, and another run may be using the
# checkout right now. Stashing and switching branches there would corrupt
# whatever it is doing.
if [ "$BOT_PROFILE_WORKTREES" = true ]; then
return
fi
if [ -n "$GIT_REPO" ] && [ -d "$GIT_REPO/.git" ]; then
echo ""
echo "Switching back to $BOT_DEFAULT_BRANCH branch in $GIT_REPO..."
cd "$GIT_REPO"
git stash --include-untracked 2>/dev/null || true
git checkout "$BOT_DEFAULT_BRANCH" 2>/dev/null || echo "Could not switch to $BOT_DEFAULT_BRANCH branch"
fi
}
# cleanup_run runs once, whichever way the run ends. A signal has to end the run
# as well: a trap that only cleaned up would hand back the claim and the slot and
# then let the loop carry on, working stories with no slot held.
trap cleanup_run EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
# Initialize progress file if it doesn't exist
if [ ! -f "$PROGRESS_FILE" ]; then
echo "# Progress Log" > "$PROGRESS_FILE"
echo "Started: $(date)" >> "$PROGRESS_FILE"
echo "---" >> "$PROGRESS_FILE"
fi
# Verify org members file exists (required for prompt injection protection)
ORG_MEMBERS_FILE="$SCRIPT_DIR/.ignore/org-members.txt"
if [ ! -f "$ORG_MEMBERS_FILE" ]; then
echo "Error: Org members file not found at $ORG_MEMBERS_FILE"
echo "This file is required for prompt injection protection."
echo "Run 'make setup' or create it manually:"
echo " mkdir -p $SCRIPT_DIR/.ignore && gh api 'orgs/$BOT_ORG/members' --paginate | jq -r '.[].login' > $ORG_MEMBERS_FILE"
exit 1
fi
if [ "$BOT_MAX_CONCURRENT_RUNS" -gt 1 ]; then
echo "Run slot $BOT_RUN_SLOT of $BOT_MAX_CONCURRENT_RUNS (pid $$)"
fi
if [ "$BOT_AGENT" = "codex" ]; then
echo "Starting Codex agent - Max iterations: $MAX_ITERATIONS"
elif [ "$BOT_AGENT" = "cursor" ]; then
echo "Starting Cursor agent - Max iterations: $MAX_ITERATIONS"
elif [ "$BOT_AGENT" = "bravebot" ]; then
echo "Starting bravebot agent - Max iterations: $MAX_ITERATIONS"
else
echo "Starting Claude Code agent - Max iterations: $MAX_ITERATIONS"
fi
if [ "$COMPARISON_RUN" = true ]; then
echo "Comparison runs enabled: every iteration is redone by $COMPARISON_AGENT and then critiqued."
echo " Findings are filed as issues in $BOT_ISSUE_REPO — see docs/comparison-runs.md"
fi
echo "Session: $RUN_SESSION_ID"
echo "Logs: $RUN_SESSION_DIR"
SETUP_STARTED=$SECONDS
# Fetch nightly version once per run (avoids redundant WebFetch in each iteration)
NIGHTLY_VERSION=$(python3 "$SCRIPT_DIR/scripts/get-nightly-version.py" 2>/dev/null || echo "")
if [ -n "$NIGHTLY_VERSION" ]; then
echo "Nightly version: $NIGHTLY_VERSION" >> "$HOUSEKEEPING_LOG"
else
echo "Warning: Could not fetch nightly version (agent will fetch if needed)" >> "$HOUSEKEEPING_LOG"
fi
# Reset run state at the start of each run. Operator settings
# (skipPushedTasks, merge backoff) are not per-slot: they are read from
# data/run-state.json so every slot honours the same configuration.
quiet_step "reset run state" "$SCRIPT_DIR/scripts/reset-run-state.sh" \
--state-file "$RUN_STATE_FILE" --config-from "$SCRIPT_DIR/data/run-state.json"
# In auto mode the PRD is a cache — rebuild it from GitHub before selecting a
# task. Plain Python against the API; no agent is started, so this costs
# nothing. A curated PRD is authored, so a run leaves it alone; its
# backlog top-up belongs to the scheduled job, at a time the operator picked.
# with-lock keeps two runs starting together from syncing at the same time;
# the second simply skips a refresh the first has just done.
if [ "$BOT_PRD_MODE" = "auto" ]; then
quiet_step "sync PRD from GitHub" \
"$SCRIPT_DIR/scripts/with-lock.sh" prd-sync --timeout 900 -- "$SCRIPT_DIR/scripts/sync-prd.sh"
fi
# An issue named outright is work whoever it is assigned to, in either PRD mode:
# naming it is the operator authoring the PRD. It is assigned to the bot, and
# given a story when it has none, so the selector below has one to work.
if [ -n "$EXTRA_PROMPT" ]; then
quiet_step "import the named issue" \
python3 "$SCRIPT_DIR/scripts/add-backlog-to-prd.py" --named "$EXTRA_PROMPT" \
|| echo "WARNING: could not import the issue the request names — continuing." >&2
fi
# Hand back the issues a dead run is still holding. Runs on other machines share
# no claims file with this one, so each labels the issue it is working and
# filters out the stories another machine has labelled; nothing refreshes those
# labels, and a machine that was killed never removes its own. A label older
# than the limit therefore means nobody is on that issue any more, and one on a
# closed issue guards nothing at any age. Only labels the bot applied are
# touched. gh and Python; no agent, no tokens.
# with-lock keeps 20 slots starting together from sweeping 20 times over.
quiet_step "sweep stale in-progress labels" \
"$SCRIPT_DIR/scripts/with-lock.sh" in-progress-sweep --timeout 600 -- \
python3 "$SCRIPT_DIR/scripts/sweep-in-progress.py" \
|| echo "WARNING: could not sweep stale in-progress labels — continuing." >&2
# Collect the worktrees of finished work before starting any. Each one carries a
# build directory of several gigabytes, and a run that is killed leaves its
# worktree behind, so they accumulate until the disk fills. A day is the age
# limit here: another run may be mid-iteration in a worktree it created minutes
# ago, and while clean-worktrees.py checks that too — a live run's claim, or
# uncommitted changes — the age bound means a fresh worktree is never a
# candidate in the first place. Python and git only; nothing here costs tokens.
if [ "$BOT_PROFILE_WORKTREES" = true ]; then
quiet_step "clean worktrees (session start)" \
python3 "$SCRIPT_DIR/scripts/clean-worktrees.py" --max-age-hours 24 \
|| echo "WARNING: worktree cleanup failed — continuing." >&2
fi
echo "Setup: done in $((SECONDS - SETUP_STARTED))s (PRD sync, label sweep, worktree cleanup: $HOUSEKEEPING_LOG)"
# Counted after the sync, which is what decides the backlog in auto mode. A
# story another run holds now is left out: by the time it is released, that run
# has worked it.
CAPPED=false
SELECTABLE=$(python3 "$SCRIPT_DIR/scripts/select-task.py" --count \
--prd "$PRD_FILE" --run-state "$RUN_STATE_FILE" \
--slot "$BOT_RUN_SLOT" --run-pid "$BOT_RUN_PID" \
--extra-prompt "$EXTRA_PROMPT" 2>/dev/null | jq -r '.count // empty' 2>/dev/null || true)
if [ -n "$SELECTABLE" ] && [ "$SELECTABLE" -gt 0 ] && [ "$SELECTABLE" -lt "$MAX_ITERATIONS" ]; then
echo "Only $SELECTABLE stories are selectable: capping this run at $SELECTABLE iterations (asked for $MAX_ITERATIONS)."
MAX_ITERATIONS=$SELECTABLE
CAPPED=true
fi
# Track both loop count (for max iterations) and work iterations (actual state changes)
loop_count=0
work_iteration=0
OUT_OF_WORK=false
while [ $loop_count -lt $MAX_ITERATIONS ]; do
((++loop_count))
# Initialize runId if it's null (start of new run)
RUN_ID=$(jq -r '.runId // "null"' "$RUN_STATE_FILE" 2>/dev/null || echo "null")
if [ "$RUN_ID" = "null" ]; then
# Initialize new run with current timestamp
RUN_ID=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Beside the target, so the mv below is a rename and not a cross-device
# copy another run could read half of.
TMP_RUN_STATE=$(mktemp "$(dirname "$RUN_STATE_FILE")/.run-state.XXXXXX")
jq --arg runId "$RUN_ID" '.runId = $runId | .storiesCheckedThisRun = [] | .lastIterationHadStateChange = true' "$RUN_STATE_FILE" > "$TMP_RUN_STATE" && mv "$TMP_RUN_STATE" "$RUN_STATE_FILE"
fi
# Generate log file path for this iteration (needed by select-task.py). The
# session directory is this run's alone, so the loop number is the whole name.
ITERATION_LOG="$RUN_SESSION_DIR/iteration-loop-${loop_count}.log"
export BOT_RUN_ID="$RUN_ID"
# Select next task — this is the gate check; exit early if no candidates.
#
# A story the reviewer asked changes of is named outright, so it is the one
# worked again and not whatever the queue would pick. If it cannot be taken
# back (another run took it in the seconds its claim was free) the run goes on
# with the ordinary selection: a fix round is not worth stopping the run for.
TASK_JSON=""
SELECT_RC=0
TASK_JSON=$(select_story "${BOT_REVIEW_PIN:-$EXTRA_PROMPT}") || SELECT_RC=$?
TASK_SELECTED=$(echo "$TASK_JSON" | jq -r '.selected // false' 2>/dev/null || echo "false")
if [ -n "$BOT_REVIEW_PIN" ] && [ "$TASK_SELECTED" != "true" ]; then
echo "$BOT_REVIEW_PIN: could not take the story back for its fix round ($(echo "$TASK_JSON" | jq -r '.reason // "unknown"' 2>/dev/null)); selecting as usual."
bot_review_reset
SELECT_RC=0
TASK_JSON=$(select_story "$EXTRA_PROMPT") || SELECT_RC=$?
TASK_SELECTED=$(echo "$TASK_JSON" | jq -r '.selected // false' 2>/dev/null || echo "false")
fi
if [ "$TASK_SELECTED" != "true" ]; then
REASON=$(echo "$TASK_JSON" | jq -r '.reason // "unknown"' 2>/dev/null || echo "unknown")
# Exit 2 is a request that cannot be honoured — usually a story named
# outright that is not selectable — and not a backlog that is finished.
# Every remaining iteration would refuse the same request the same way, so
# say so and stop instead of reporting the run complete.
if [ "$SELECT_RC" = 2 ]; then
echo "Error: $REASON" >&2
exit 1
fi
echo "No more tasks to process: $REASON"
OUT_OF_WORK=true
break
fi
STORY_ID=$(echo "$TASK_JSON" | jq -r '.storyId')
STORY_STATUS=$(echo "$TASK_JSON" | jq -r '.status')
TIER_NAME=$(echo "$TASK_JSON" | jq -r '.tierName')
STORY_TITLE=$(echo "$TASK_JSON" | jq -r '.title')
STORY_DETAILS=$(echo "$TASK_JSON" | jq -c '.storyDetails')
STORY_PRIORITY=$(echo "$TASK_JSON" | jq -r '.priority // "-"')
STORY_ISSUE=$(echo "$TASK_JSON" | jq -r '.issueNumber // empty')
STORY_PR_NUMBER=$(echo "$TASK_JSON" | jq -r '.prNumber // empty')
STORY_PR_URL=$(echo "$TASK_JSON" | jq -r '.prUrl // empty')
STORY_BRANCH=$(echo "$TASK_JSON" | jq -r '.branchName // empty')
# Stories reference their issue by number only, so the link is built here.
STORY_ISSUE_URL=""
if [ -n "$STORY_ISSUE" ]; then
STORY_ISSUE_URL="https://github.com/$BOT_ISSUE_REPO/issues/$STORY_ISSUE"
fi
# A pin that did not select its story (an id select-task.py does not read as a
# name) was an ordinary selection, and the fix round it asked for is not this.
if [ -n "$BOT_REVIEW_PIN" ] && [ "$STORY_ID" != "$BOT_REVIEW_PIN" ]; then
bot_review_reset
fi
FIX_ROUND=""
[ -n "$BOT_REVIEW_PIN" ] && FIX_ROUND="$BOT_REVIEW_ROUND"
# The head the pull request has now, so the gate after the iteration can tell
# whether the iteration pushed. "?" is a head GitHub would not give: the gate
# then leaves the story alone, since it cannot say whether anything changed.
REVIEW_HEAD_BEFORE=""
if bot_review_enabled && [ -n "$STORY_PR_NUMBER" ]; then
REVIEW_HEAD_BEFORE=$(bot_pr_head "$STORY_PR_NUMBER")
REVIEW_HEAD_BEFORE="${REVIEW_HEAD_BEFORE:-?}"
fi
# select-task.py claimed this story under the PRD lock; remember it so the
# claim is handed back when the iteration ends or this run exits.
CURRENT_STORY_ID="$STORY_ID"
# The logs are named by loop, so this is what says which story each one is.
printf 'loop %s: %s (%s) %s\n' "$loop_count" "$STORY_ID" "$STORY_STATUS" "$STORY_TITLE" \
>> "$RUN_SESSION_DIR/stories.txt"
bot_slot_meta_set "storyId=$STORY_ID" "status=$STORY_STATUS" "runId=$RUN_ID" \
"loop:num=$loop_count" "log=$ITERATION_LOG" "agent=$BOT_AGENT"
bot_slot_heartbeat
# Task confirmed — proceed with iteration setup
# Store the current iteration log path in run-state.json
TMP_RUN_STATE=$(mktemp "$(dirname "$RUN_STATE_FILE")/.run-state.XXXXXX")
jq --arg logPath "$ITERATION_LOG" '.currentIterationLogPath = $logPath' "$RUN_STATE_FILE" > "$TMP_RUN_STATE" && mv "$TMP_RUN_STATE" "$RUN_STATE_FILE"
# Check if last iteration had state change (default to true for first iteration)
HAD_STATE_CHANGE=$(jq -r '.lastIterationHadStateChange // true' "$RUN_STATE_FILE" 2>/dev/null || echo "true")
echo ""
echo "==============================================================="
# Only increment work iteration counter if there was actual state change
if [ "$HAD_STATE_CHANGE" = "true" ]; then
((++work_iteration))
echo " Work Iteration $work_iteration (loop $loop_count of $MAX_ITERATIONS)"
else
echo " Checking next task (work iteration $work_iteration, loop $loop_count of $MAX_ITERATIONS)"
echo " Previous check had no state change - continuing without incrementing work iteration"
fi
echo "---------------------------------------------------------------"
echo " Story: $STORY_ID $STORY_TITLE"
echo " Status: $STORY_STATUS (tier $TIER_NAME, priority $STORY_PRIORITY)"
[ -n "$STORY_ISSUE_URL" ] && echo " Issue: $STORY_ISSUE_URL"
[ -n "$STORY_PR_URL" ] && echo " PR: $STORY_PR_URL"
[ -n "$STORY_BRANCH" ] && echo " Branch: $STORY_BRANCH"
echo " Next step: $(story_next_step "$STORY_STATUS")"
echo " Workflow: docs/workflow-$STORY_STATUS.md"
echo " Log: $ITERATION_LOG"
echo "==============================================================="
bot_set_terminal_title \
"$(bot_story_title "$STORY_ISSUE" "$STORY_PR_NUMBER" "$STORY_ID" "$STORY_TITLE")"
# A story with no PR yet gets one partway through this iteration, and the tab
# should say so from that moment rather than at the end of the iteration.
if [ -z "$STORY_PR_NUMBER" ]; then
"$SCRIPT_DIR/scripts/exec-clean.sh" "$SCRIPT_DIR/scripts/watch-pr-title.sh" \
"$PRD_FILE" "$STORY_ID" "$STORY_ISSUE" "$STORY_TITLE" &
TITLE_WATCH_PID=$!
fi
# Run Claude Code with the agent prompt
# The story a Claude session's Stop hook checks, and how often it has refused.
STOP_CHECK=$(mktemp)
# Change to the parent directory (brave-browser) so relative paths in .claude/CLAUDE.md work
BRAVE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$BRAVE_ROOT"
# Pre-generate session ID for monitoring/resume (Claude only — Codex has no --session-id).
SESSION_ID=""
if [ "$BOT_AGENT" = "claude" ]; then
SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
echo ""
echo "To monitor this session (read-only): claude --resume $SESSION_ID"
echo ""
elif [ "$BOT_AGENT" = "cursor" ]; then
echo ""
echo "Cursor session: resume with 'cursor-agent resume' after the iteration."
echo ""
elif [ "$BOT_AGENT" = "bravebot" ]; then
echo ""
# bravebot cannot be handed a session id, so it is looked up when the iteration ends.
echo "bravebot session: its id is printed when the iteration ends."
echo ""
else
echo ""
echo "Codex session: resume with 'codex resume --last' after the iteration."
echo ""
fi
# Build the agent prompt. All agents pick up project instructions from disk:
# - Claude reads .claude/CLAUDE.md from the bot dir.
# - Codex reads AGENTS.md (project) / ~/.codex/AGENTS.md (user).
# - Cursor reads AGENTS.md (project) + .cursor/rules.
# We point them all at the same workflow docs via the prompt itself.
BOT_DIRNAME=$(basename "$SCRIPT_DIR")
BOT_CONFIG=$(cat "$SCRIPT_DIR/config.json")
# A profile need not ship docs, and pointing an agent at a directory that is
# not there costs it a search and teaches it the prompt lies.
if [ -d "$BOT_PROFILE_DIR/docs" ]; then
PROJECT_RULES="Project-specific rules live in ./$BOT_DIRNAME/projects/$BOT_PROFILE/docs/.
Where a workflow doc says a step is project-specific, read the named file there."
else
PROJECT_RULES="The '$BOT_PROFILE' profile ships no project-specific docs, so where a workflow
doc says a step is project-specific there is nothing further to read."
fi
# The profile's reading steps, rendered fresh by select-task.py rather than
# read out of the story. Acceptance criteria are written when a story is
# created, so a story older than a reading step would never see it; said
# here, every iteration starts from what the project is reviewed against.
RESEARCH=$(echo "$TASK_JSON" | jq -r '(.research // []) | map("- " + .) | join("\n")')
READ_FIRST=""
if [ -n "$RESEARCH" ]; then
READ_FIRST="Read these first, whatever the story's acceptance criteria say:
$RESEARCH
"
fi
AGENT_PROMPT="You are working on story $STORY_ID (current status: $STORY_STATUS).
Follow ./$BOT_DIRNAME/docs/workflow-${STORY_STATUS}.md for the workflow.
Follow the general instructions in ./$BOT_DIRNAME/.claude/CLAUDE.md.
$READ_FIRST
$PROJECT_RULES
Story details:
$STORY_DETAILS
Bot config (from config.json — do NOT read this file):
$BOT_CONFIG"
# Only said when the profile clusters and the lead can take companions
# (select-task.py's "clustering" block). The ceiling is a maximum: the doc
# tells the agent to take only issues that are really related, and
# claim-companions.py enforces the ceiling whatever the agent asks for.
CLUSTER_LIMIT=$(echo "$TASK_JSON" | jq -r '.clustering.limit // empty')
if [ -n "$CLUSTER_LIMIT" ]; then
CLUSTER_POOL=$(echo "$TASK_JSON" | jq -r '.clustering.candidates | map("- \(.storyId): issue #\(.issueNumber), \"\(.title)\"" + (if (.triage | length) > 0 then " (" + (.triage | to_entries | map("\(.key) \(.value)") | join(", ")) + ")" else "" end)) | join("\n")')
CLUSTER_SIZE=$(echo "$TASK_JSON" | jq -r '.clustering.leadSize')
AGENT_PROMPT="$AGENT_PROMPT
Clustering: $STORY_ID is size $CLUSTER_SIZE, so this iteration may fix up to $CLUSTER_LIMIT related issues in
one pull request, $STORY_ID included. That is a ceiling, not a target: take an issue only if it shares a root
cause, a file or function, or a spec clause with $STORY_ID, and take none if none is. Read
./$BOT_DIRNAME/projects/$BOT_PROFILE/docs/clustering.md before deciding, and follow it for the claim, the
worktree, the commits and the PR body. Candidates (each no larger than $STORY_ID):
$CLUSTER_POOL"
fi
# Without this the session reads requeuedFrom's reason as a live blocker and
# skips the story again, which is the judgment the operator just overruled.
REQUEUED_FROM=$(echo "$TASK_JSON" | jq -r '.requeuedFrom.status // empty')
if [ -n "$REQUEUED_FROM" ]; then
AGENT_PROMPT="$AGENT_PROMPT
The request named this story outright while it was $REQUEUED_FROM, so it is back to pending at the
operator's request (storyDetails.requeuedFrom has the old status and reason). Work it. Do not set it
back to $REQUEUED_FROM for the reason recorded there; the operator has overruled it."
fi
# Only said when it is true: a single-run deployment's prompt is unchanged.
if [ "$BOT_MAX_CONCURRENT_RUNS" -gt 1 ]; then
AGENT_PROMPT="$AGENT_PROMPT
Concurrency: this run holds slot $BOT_RUN_SLOT of $BOT_MAX_CONCURRENT_RUNS. Other runs may be working
other stories in this same bot directory right now.
- Your run state is $RUN_STATE_FILE (exported as BOT_RUN_STATE_FILE). Scripts pick
it up automatically; do not read or write data/run-state.json directly.
- Never hand-edit data/prd.json. Use ./$BOT_DIRNAME/scripts/update-prd-status.py, which
serializes writes — a hand edit will silently lose another run's update.
- Story $STORY_ID is claimed by this slot. Touch nothing outside it: not another
story's worktree, branch, or PR.
- Append to data/progress.txt with ./$BOT_DIRNAME/scripts/append-progress.sh, not with >>."
fi
if [ -n "$FIX_ROUND" ]; then
AGENT_PROMPT="$AGENT_PROMPT
Review fix round: $BOT_REVIEWER_USERNAME reviewed PR #$STORY_PR_NUMBER and its review asks for changes
(review $FIX_ROUND of at most $BOT_REVIEW_MAX_ROUNDS). Answer that review through the Review Response
Workflow in docs/workflow-pushed.md and push the result. Do not ask for another review and do not wait for
one: run.sh does both once you have pushed. A comment that is wrong or already answered gets a reply, not a
change; if nothing needs pushing, push nothing and say why in progress.txt."
fi
if [ -n "$NIGHTLY_VERSION" ]; then
AGENT_PROMPT="$AGENT_PROMPT
Nightly version: $NIGHTLY_VERSION (use this for milestone names like '$NIGHTLY_VERSION - Nightly', do NOT fetch the release schedule)."
fi
if [ -n "$EXTRA_PROMPT" ]; then
AGENT_PROMPT="$AGENT_PROMPT
Additional context: $EXTRA_PROMPT"
fi
# Log the prompt to the iteration log so it can be audited later
if [ "$USE_TUI" != true ]; then
jq -n --arg storyId "$STORY_ID" --arg status "$STORY_STATUS" --arg tier "$TIER_NAME" \
--arg agent "$BOT_AGENT" --arg prompt "$AGENT_PROMPT" \
'{"type":"prompt","storyId":$storyId,"status":$status,"tier":$tier,"agent":$agent,"prompt":$prompt}' >> "$ITERATION_LOG"
fi
# The second the base run starts. Only Claude is told its session id; every
# other agent's session has to be found afterwards, and the search needs to
# know how far back to look (scripts/find-agent-session.py).
BASE_STARTED_AT=$(date +%s)
# A concurrent run in this directory writes sessions of its own, so a bravebot
# session is told from theirs by the prompt's first line, which becomes its
# title: "You are working on story <id> (current status: ...)".
SESSION_TITLE_MATCH="story $STORY_ID "
# Where the progress log ends now, so an entry the session writes can be told
# from the ones already there (scripts/iteration-stopped-short.py).
PROGRESS_OFFSET=0
[ -f "$PROGRESS_FILE" ] && PROGRESS_OFFSET=$(wc -c < "$PROGRESS_FILE" | tr -d ' ')
# Run the agent from the bot directory so it picks up project instructions.
#
# Every stage goes through exec-clean.sh, which closes all inherited fds
# above stdio. fd 200 is this run's slot lock, and a flock lives on the open
# file description — so any child that inherits the fd keeps the slot held
# after the run is gone. `somecmd 200>&-` does not fix that on bash 3.2 (the
# macOS default): to apply the redirection bash first duplicates fd 200 to a
# free fd near 10, and children inherit *that*. tee needs the same treatment
# — it sits waiting on the pipe and outlives a killed run.
#
# AGENT_RC is 75 when timeout-tree.sh's quiet watchdog stopped the session.
AGENT_RC=0
if [ "$BOT_AGENT" = "codex" ]; then
CODEX_MODEL_FLAG=""
if [ -n "$BOT_CODEX_MODEL" ]; then
CODEX_MODEL_FLAG="--model $BOT_CODEX_MODEL"
fi
if [ "$USE_TUI" = true ]; then
# TUI mode: let codex own the terminal directly (no piping).
"$SCRIPT_DIR/scripts/exec-clean.sh" --cd "$SCRIPT_DIR" "$SCRIPT_DIR/scripts/timeout-tree.sh" "$ITERATION_SECONDS" $BOT_CODEX_BIN $CODEX_MODEL_FLAG --dangerously-bypass-approvals-and-sandbox "$AGENT_PROMPT" || true
else
# Non-interactive: stream JSONL events to the iteration log.
"$SCRIPT_DIR/scripts/exec-clean.sh" --cd "$SCRIPT_DIR" "$SCRIPT_DIR/scripts/timeout-tree.sh" "$ITERATION_SECONDS" $BOT_CODEX_BIN exec $CODEX_MODEL_FLAG --dangerously-bypass-approvals-and-sandbox --json --skip-git-repo-check "$AGENT_PROMPT" </dev/null 2>&1 \
| "$SCRIPT_DIR/scripts/exec-clean.sh" tee -a "$ITERATION_LOG" > /dev/null || true
fi
elif [ "$BOT_AGENT" = "bravebot" ]; then
BRAVEBOT_MODEL_FLAG=""
if [ -n "$BOT_BRAVEBOT_MODEL" ]; then
BRAVEBOT_MODEL_FLAG="--model $BOT_BRAVEBOT_MODEL"
fi
# --dangerously-bypass-approvals-and-sandbox is --dangerously-skip-permissions
# with --sandbox off, as codex's flag of the same name above. The session starts
# here but its work is in the target repo and its worktrees, which the default
# sandbox would confine its programs away from.
#
# A bare prompt is a one-shot run, so TUI mode needs -i to open the interactive
# session with the prompt as its first turn. bravebot reads -i only as the first
# argument and --model only after the prompt; the bypass flag is taken out
# wherever it sits. -i needs a terminal on stdin and stdout.
if [ "$USE_TUI" = true ]; then
"$SCRIPT_DIR/scripts/exec-clean.sh" --cd "$SCRIPT_DIR" "$SCRIPT_DIR/scripts/timeout-tree.sh" "$ITERATION_SECONDS" $BOT_BRAVEBOT_BIN --dangerously-bypass-approvals-and-sandbox -i "$AGENT_PROMPT" $BRAVEBOT_MODEL_FLAG || true
else
# bravebot keeps stdout to the final reply alone and puts progress on stderr;
# both go to the log.
"$SCRIPT_DIR/scripts/exec-clean.sh" --cd "$SCRIPT_DIR" "$SCRIPT_DIR/scripts/timeout-tree.sh" "$ITERATION_SECONDS" $BOT_BRAVEBOT_BIN $BRAVEBOT_MODEL_FLAG --dangerously-bypass-approvals-and-sandbox "$AGENT_PROMPT" </dev/null 2>>"$ITERATION_LOG" \
| "$SCRIPT_DIR/scripts/exec-clean.sh" tee -a "$ITERATION_LOG" > /dev/null || true
fi
elif [ "$BOT_AGENT" = "cursor" ]; then
CURSOR_MODEL_FLAG=""
if [ -n "$BOT_CURSOR_MODEL" ]; then
CURSOR_MODEL_FLAG="--model $BOT_CURSOR_MODEL"
fi
if [ "$USE_TUI" = true ]; then
# TUI mode: let cursor-agent own the terminal directly (no piping).
# --force bypasses approvals (headless autonomy).
"$SCRIPT_DIR/scripts/exec-clean.sh" --cd "$SCRIPT_DIR" "$SCRIPT_DIR/scripts/timeout-tree.sh" "$ITERATION_SECONDS" $BOT_CURSOR_BIN $CURSOR_MODEL_FLAG --force "$AGENT_PROMPT" || true
else
# Non-interactive: -p/--print with plain-text output. --force bypasses approvals,
# --trust trusts the workspace (headless only).
"$SCRIPT_DIR/scripts/exec-clean.sh" --cd "$SCRIPT_DIR" "$SCRIPT_DIR/scripts/timeout-tree.sh" "$ITERATION_SECONDS" $BOT_CURSOR_BIN -p --output-format text $CURSOR_MODEL_FLAG --force --trust "$AGENT_PROMPT" </dev/null 2>&1 \
| "$SCRIPT_DIR/scripts/exec-clean.sh" tee -a "$ITERATION_LOG" > /dev/null || true