agent-control-plane 0.1.16 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +93 -14
  2. package/bin/pr-risk.sh +28 -6
  3. package/hooks/heartbeat-hooks.sh +62 -22
  4. package/npm/bin/agent-control-plane.js +360 -10
  5. package/package.json +6 -3
  6. package/references/architecture.md +8 -0
  7. package/references/control-plane-map.md +6 -2
  8. package/references/release-checklist.md +0 -2
  9. package/tools/bin/agent-github-update-labels +6 -1
  10. package/tools/bin/agent-project-catch-up-issue-pr-links +118 -0
  11. package/tools/bin/agent-project-catch-up-merged-prs +78 -21
  12. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +123 -0
  13. package/tools/bin/agent-project-cleanup-session +132 -4
  14. package/tools/bin/agent-project-heartbeat-loop +116 -1461
  15. package/tools/bin/agent-project-reconcile-issue-session +90 -117
  16. package/tools/bin/agent-project-reconcile-pr-session +76 -111
  17. package/tools/bin/agent-project-run-claude-session +12 -2
  18. package/tools/bin/agent-project-run-codex-resilient +86 -9
  19. package/tools/bin/agent-project-run-codex-session +16 -5
  20. package/tools/bin/agent-project-run-kilo-session +356 -14
  21. package/tools/bin/agent-project-run-ollama-session +658 -0
  22. package/tools/bin/agent-project-run-openclaw-session +37 -25
  23. package/tools/bin/agent-project-run-opencode-session +364 -14
  24. package/tools/bin/agent-project-run-pi-session +479 -0
  25. package/tools/bin/agent-project-worker-status +11 -8
  26. package/tools/bin/cleanup-worktree.sh +6 -1
  27. package/tools/bin/flow-config-lib.sh +196 -3
  28. package/tools/bin/flow-resident-worker-lib.sh +120 -2
  29. package/tools/bin/flow-shell-lib.sh +29 -2
  30. package/tools/bin/heartbeat-loop-cache-lib.sh +164 -0
  31. package/tools/bin/heartbeat-loop-counting-lib.sh +306 -0
  32. package/tools/bin/heartbeat-loop-pr-strategy-lib.sh +199 -0
  33. package/tools/bin/heartbeat-loop-scheduling-lib.sh +506 -0
  34. package/tools/bin/heartbeat-loop-worker-lib.sh +319 -0
  35. package/tools/bin/heartbeat-recovery-preflight.sh +13 -1
  36. package/tools/bin/heartbeat-safe-auto.sh +119 -20
  37. package/tools/bin/install-project-launchd.sh +19 -2
  38. package/tools/bin/prepare-worktree.sh +4 -4
  39. package/tools/bin/profile-activate.sh +2 -2
  40. package/tools/bin/profile-adopt.sh +2 -2
  41. package/tools/bin/project-init.sh +1 -1
  42. package/tools/bin/project-launchd-bootstrap.sh +11 -8
  43. package/tools/bin/project-runtimectl.sh +90 -7
  44. package/tools/bin/provider-cooldown-state.sh +14 -14
  45. package/tools/bin/reconcile-bootstrap-lib.sh +113 -0
  46. package/tools/bin/render-flow-config.sh +30 -33
  47. package/tools/bin/resident-issue-controller-lib.sh +448 -0
  48. package/tools/bin/resident-issue-queue-status.py +35 -0
  49. package/tools/bin/run-codex-task.sh +53 -4
  50. package/tools/bin/scaffold-profile.sh +18 -3
  51. package/tools/bin/start-issue-worker.sh +1 -1
  52. package/tools/bin/start-pr-fix-worker.sh +30 -0
  53. package/tools/bin/start-pr-review-worker.sh +31 -0
  54. package/tools/bin/start-resident-issue-loop.sh +27 -438
  55. package/tools/bin/sync-agent-repo.sh +2 -2
  56. package/tools/bin/sync-dependency-baseline.sh +3 -3
  57. package/tools/bin/sync-shared-agent-home.sh +4 -1
  58. package/tools/dashboard/app.js +7 -0
  59. package/tools/dashboard/dashboard_snapshot.py +13 -29
  60. package/tools/templates/pr-fix-template.md +3 -7
  61. package/tools/templates/pr-merge-repair-template.md +3 -7
  62. package/tools/templates/pr-review-template.md +2 -1
  63. package/SKILL.md +0 -149
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat <<'EOF'
6
+ Usage:
7
+ agent-project-run-pi-session --mode safe|bypass --session <id> --worktree <path> --prompt-file <path> --runs-root <path> --adapter-id <id> --task-kind <kind> --task-id <id> [options]
8
+
9
+ Launch a Pi coding agent worker session inside tmux for a project adapter and
10
+ persist the standard run artifacts.
11
+
12
+ Pi supports any OpenRouter model (or other providers) via --model provider/id.
13
+
14
+ Options:
15
+ --env-prefix <prefix> Export prefixed runtime/context env vars inside the worker
16
+ --context <KEY=VALUE> Extra metadata written to run.env and exported to the worker
17
+ --collect-file <name> Copy sandbox artifact file into the host run dir after execution
18
+ --reconcile-command <cmd> Host-side command queued after the worker exits
19
+ --sandbox-subdir <name> Subdir under the worktree for worker artifacts (default: .pi-artifacts)
20
+ --pi-model <id> Model ID for pi (e.g. openrouter/qwen/qwen3.6-plus:free)
21
+ --pi-thinking <level> Thinking level: off, minimal, low, medium, high, xhigh (default: low)
22
+ --pi-timeout-seconds <secs> Hard timeout in seconds (default: 900)
23
+ --pi-stall-seconds <secs> Abort if no output for this long, 0 disables (default: 300)
24
+ --help Show this help
25
+ EOF
26
+ }
27
+
28
+ mode=""
29
+ session=""
30
+ worktree=""
31
+ prompt_file=""
32
+ runs_root=""
33
+ adapter_id=""
34
+ task_kind=""
35
+ task_id=""
36
+ env_prefix=""
37
+ sandbox_subdir=".pi-artifacts"
38
+ reconcile_command=""
39
+ pi_model="${ACP_PI_MODEL:-${F_LOSNING_PI_MODEL:-openrouter/qwen/qwen3.6-plus:free}}"
40
+ pi_thinking="${ACP_PI_THINKING:-${F_LOSNING_PI_THINKING:-low}}"
41
+ pi_timeout_seconds="${ACP_PI_TIMEOUT_SECONDS:-${F_LOSNING_PI_TIMEOUT_SECONDS:-900}}"
42
+ pi_stall_seconds="${ACP_PI_STALL_SECONDS:-${F_LOSNING_PI_STALL_SECONDS:-300}}"
43
+ declare -a context_items=()
44
+ declare -a collect_files=()
45
+
46
+ while [[ $# -gt 0 ]]; do
47
+ case "$1" in
48
+ --mode) mode="${2:-}"; shift 2 ;;
49
+ --session) session="${2:-}"; shift 2 ;;
50
+ --worktree) worktree="${2:-}"; shift 2 ;;
51
+ --prompt-file) prompt_file="${2:-}"; shift 2 ;;
52
+ --runs-root) runs_root="${2:-}"; shift 2 ;;
53
+ --adapter-id) adapter_id="${2:-}"; shift 2 ;;
54
+ --task-kind) task_kind="${2:-}"; shift 2 ;;
55
+ --task-id) task_id="${2:-}"; shift 2 ;;
56
+ --env-prefix) env_prefix="${2:-}"; shift 2 ;;
57
+ --context) context_items+=("${2:-}"); shift 2 ;;
58
+ --collect-file) collect_files+=("${2:-}"); shift 2 ;;
59
+ --reconcile-command) reconcile_command="${2:-}"; shift 2 ;;
60
+ --sandbox-subdir) sandbox_subdir="${2:-}"; shift 2 ;;
61
+ --pi-model) pi_model="${2:-}"; shift 2 ;;
62
+ --pi-thinking) pi_thinking="${2:-}"; shift 2 ;;
63
+ --pi-timeout-seconds) pi_timeout_seconds="${2:-}"; shift 2 ;;
64
+ --pi-stall-seconds) pi_stall_seconds="${2:-}"; shift 2 ;;
65
+ --help|-h) usage; exit 0 ;;
66
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
67
+ esac
68
+ done
69
+
70
+ if [[ -z "$mode" || -z "$session" || -z "$worktree" || -z "$prompt_file" || -z "$runs_root" || -z "$adapter_id" || -z "$task_kind" || -z "$task_id" ]]; then
71
+ usage >&2
72
+ exit 1
73
+ fi
74
+
75
+ case "$mode" in
76
+ safe|bypass) ;;
77
+ *) echo "--mode must be safe or bypass" >&2; exit 1 ;;
78
+ esac
79
+
80
+ case "$pi_thinking" in
81
+ off|minimal|low|medium|high|xhigh) ;;
82
+ *) echo "--pi-thinking must be one of: off, minimal, low, medium, high, xhigh" >&2; exit 1 ;;
83
+ esac
84
+
85
+ case "$pi_timeout_seconds" in
86
+ ''|*[!0-9]*|0) echo "--pi-timeout-seconds must be a positive integer" >&2; exit 1 ;;
87
+ esac
88
+
89
+ case "$pi_stall_seconds" in
90
+ ''|*[!0-9]*) echo "--pi-stall-seconds must be numeric" >&2; exit 1 ;;
91
+ esac
92
+
93
+ resolve_pi_bin() {
94
+ local configured_bin="${PI_BIN:-${ACP_PI_BIN:-${F_LOSNING_PI_BIN:-}}}"
95
+ if [[ -n "${configured_bin}" && -x "${configured_bin}" ]]; then
96
+ printf '%s\n' "${configured_bin}"
97
+ return 0
98
+ fi
99
+ if command -v pi >/dev/null 2>&1; then
100
+ command -v pi
101
+ return 0
102
+ fi
103
+ local -a fallback_paths=(
104
+ "/opt/homebrew/bin/pi"
105
+ "/usr/local/bin/pi"
106
+ "${HOME}/.local/bin/pi"
107
+ "${HOME}/.nvm/versions/node/$(node --version 2>/dev/null || true)/bin/pi"
108
+ )
109
+ local p
110
+ for p in "${fallback_paths[@]}"; do
111
+ if [[ -x "${p}" ]]; then
112
+ printf '%s\n' "${p}"
113
+ return 0
114
+ fi
115
+ done
116
+ return 1
117
+ }
118
+
119
+ pi_bin="$(resolve_pi_bin || true)"
120
+ if [[ -z "${pi_bin}" || ! -x "${pi_bin}" ]]; then
121
+ echo "unable to resolve a runnable pi binary — install with: npm install -g @mariozechner/pi-coding-agent" >&2
122
+ exit 1
123
+ fi
124
+
125
+ artifact_dir="${runs_root}/${session}"
126
+ output_file="${artifact_dir}/${session}.log"
127
+ inner_script="${artifact_dir}/${session}.sh"
128
+ meta_file="${artifact_dir}/run.env"
129
+ result_file="${artifact_dir}/result.env"
130
+ runner_state_file="${artifact_dir}/runner.env"
131
+ sandbox_run_dir="${worktree%/}/${sandbox_subdir}/${session}"
132
+ retained_repo_root="${ACP_RETAINED_REPO_ROOT:-${F_LOSNING_RETAINED_REPO_ROOT:-}}"
133
+ started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
134
+
135
+ mkdir -p "$artifact_dir"
136
+ mkdir -p "$sandbox_run_dir"
137
+
138
+ if tmux has-session -t "$session" 2>/dev/null; then
139
+ echo "tmux session already exists: $session" >&2
140
+ exit 1
141
+ fi
142
+
143
+ branch_name="$(git -C "$worktree" branch --show-current 2>/dev/null || true)"
144
+
145
+ printf -v session_q '%q' "$session"
146
+ printf -v task_kind_q '%q' "$task_kind"
147
+ printf -v task_id_q '%q' "$task_id"
148
+ printf -v mode_q '%q' "$mode"
149
+ printf -v worktree_q '%q' "$worktree"
150
+ printf -v prompt_q '%q' "$prompt_file"
151
+ printf -v output_q '%q' "$output_file"
152
+ printf -v artifact_dir_q '%q' "$artifact_dir"
153
+ printf -v script_q '%q' "$inner_script"
154
+ printf -v result_q '%q' "$result_file"
155
+ printf -v meta_file_q '%q' "$meta_file"
156
+ printf -v runner_state_q '%q' "$runner_state_file"
157
+ printf -v branch_q '%q' "$branch_name"
158
+ printf -v sandbox_run_dir_q '%q' "$sandbox_run_dir"
159
+ printf -v retained_repo_root_q '%q' "$retained_repo_root"
160
+ printf -v adapter_id_q '%q' "$adapter_id"
161
+ printf -v started_at_q '%q' "$started_at"
162
+ printf -v pi_bin_q '%q' "$pi_bin"
163
+ printf -v pi_model_q '%q' "$pi_model"
164
+ printf -v pi_thinking_q '%q' "$pi_thinking"
165
+ printf -v pi_timeout_q '%q' "$pi_timeout_seconds"
166
+ printf -v pi_stall_q '%q' "$pi_stall_seconds"
167
+
168
+ {
169
+ printf 'TASK_KIND=%s\n' "$task_kind_q"
170
+ printf 'TASK_ID=%s\n' "$task_id_q"
171
+ printf 'SESSION=%s\n' "$session_q"
172
+ printf 'MODE=%s\n' "$mode_q"
173
+ printf 'WORKTREE=%s\n' "$worktree_q"
174
+ printf 'PROMPT_FILE=%s\n' "$prompt_q"
175
+ printf 'OUTPUT_FILE=%s\n' "$output_q"
176
+ printf 'SCRIPT=%s\n' "$script_q"
177
+ printf 'BRANCH=%s\n' "$branch_q"
178
+ printf 'RESULT_FILE=%s\n' "$result_q"
179
+ printf 'RUNNER_STATE_FILE=%s\n' "$runner_state_q"
180
+ printf 'SANDBOX_RUN_DIR=%s\n' "$sandbox_run_dir_q"
181
+ printf 'ADAPTER_ID=%s\n' "$adapter_id_q"
182
+ printf 'STARTED_AT=%s\n' "$started_at_q"
183
+ printf 'PI_BIN=%s\n' "$pi_bin_q"
184
+ printf 'PI_MODEL=%s\n' "$pi_model_q"
185
+ printf 'PI_THINKING=%s\n' "$pi_thinking_q"
186
+ printf 'PI_TIMEOUT_SECONDS=%s\n' "$pi_timeout_q"
187
+ printf 'PI_STALL_SECONDS=%s\n' "$pi_stall_q"
188
+ } >"$meta_file"
189
+
190
+ context_exports=""
191
+ if ((${#context_items[@]} > 0)); then
192
+ for item in "${context_items[@]}"; do
193
+ if [[ "$item" != *=* ]]; then
194
+ echo "--context must use KEY=VALUE syntax: $item" >&2
195
+ exit 1
196
+ fi
197
+ key="${item%%=*}"
198
+ value="${item#*=}"
199
+ if [[ ! "$key" =~ ^[A-Z0-9_]+$ ]]; then
200
+ echo "Invalid context key: $key" >&2
201
+ exit 1
202
+ fi
203
+ printf -v value_q '%q' "$value"
204
+ printf '%s=%s\n' "$key" "$value_q" >>"$meta_file"
205
+ if [[ -n "$env_prefix" ]]; then
206
+ context_exports+="export ${env_prefix}${key}=${value_q}"$'\n'
207
+ fi
208
+ context_exports+="export ACP_${key}=${value_q}"$'\n'
209
+ if [[ "$env_prefix" != "F_LOSNING_" ]]; then
210
+ context_exports+="export F_LOSNING_${key}=${value_q}"$'\n'
211
+ fi
212
+ done
213
+ fi
214
+
215
+ runtime_exports=$(
216
+ cat <<EOF
217
+ export AGENT_PROJECT_SESSION=${session_q}
218
+ export AGENT_PROJECT_RUN_DIR=${sandbox_run_dir_q}
219
+ export AGENT_PROJECT_HOST_RUN_DIR=${artifact_dir_q}
220
+ export AGENT_PROJECT_RESULT_FILE=${sandbox_run_dir_q}/result.env
221
+ export AGENT_PROJECT_PI_BIN=${pi_bin_q}
222
+ export AGENT_PROJECT_RETAINED_REPO_ROOT=${retained_repo_root_q}
223
+ export ACP_SESSION=${session_q}
224
+ export ACP_RUN_DIR=${sandbox_run_dir_q}
225
+ export ACP_HOST_RUN_DIR=${artifact_dir_q}
226
+ export ACP_RESULT_FILE=${sandbox_run_dir_q}/result.env
227
+ export ACP_PI_BIN=${pi_bin_q}
228
+ export ACP_PI_MODEL=${pi_model_q}
229
+ export ACP_PI_THINKING=${pi_thinking_q}
230
+ export ACP_PI_TIMEOUT_SECONDS=${pi_timeout_q}
231
+ export ACP_RETAINED_REPO_ROOT=${retained_repo_root_q}
232
+ export F_LOSNING_SESSION=${session_q}
233
+ export F_LOSNING_RUN_DIR=${sandbox_run_dir_q}
234
+ export F_LOSNING_HOST_RUN_DIR=${artifact_dir_q}
235
+ export F_LOSNING_RESULT_FILE=${sandbox_run_dir_q}/result.env
236
+ export F_LOSNING_PI_BIN=${pi_bin_q}
237
+ export F_LOSNING_PI_MODEL=${pi_model_q}
238
+ export F_LOSNING_PI_THINKING=${pi_thinking_q}
239
+ export F_LOSNING_PI_TIMEOUT_SECONDS=${pi_timeout_q}
240
+ export F_LOSNING_RETAINED_REPO_ROOT=${retained_repo_root_q}
241
+ EOF
242
+ )
243
+
244
+ if [[ -n "$env_prefix" ]]; then
245
+ runtime_exports+=$'\n'
246
+ runtime_exports+=$(cat <<EOF
247
+ export ${env_prefix}SESSION=${session_q}
248
+ export ${env_prefix}RUN_DIR=${sandbox_run_dir_q}
249
+ export ${env_prefix}HOST_RUN_DIR=${artifact_dir_q}
250
+ export ${env_prefix}RESULT_FILE=${sandbox_run_dir_q}/result.env
251
+ export ${env_prefix}PI_BIN=${pi_bin_q}
252
+ export ${env_prefix}PI_MODEL=${pi_model_q}
253
+ export ${env_prefix}PI_THINKING=${pi_thinking_q}
254
+ export ${env_prefix}PI_TIMEOUT_SECONDS=${pi_timeout_q}
255
+ EOF
256
+ )
257
+ fi
258
+
259
+ collect_copy_snippet=""
260
+ if ((${#collect_files[@]} > 0)); then
261
+ for artifact_name in "${collect_files[@]}"; do
262
+ [[ -z "$artifact_name" ]] && continue
263
+ printf -v artifact_q '%q' "$artifact_name"
264
+ collect_copy_snippet+=$(
265
+ cat <<EOF
266
+ if [[ -f ${sandbox_run_dir_q}/${artifact_q} ]]; then
267
+ cp ${sandbox_run_dir_q}/${artifact_q} ${artifact_dir_q}/${artifact_q}
268
+ fi
269
+ EOF
270
+ )
271
+ collect_copy_snippet+=$'\n'
272
+ done
273
+ fi
274
+
275
+ # Always collect result.env from sandbox to artifact_dir
276
+ collect_copy_snippet+=$(
277
+ cat <<EOF
278
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
279
+ cp ${sandbox_run_dir_q}/result.env ${result_q}
280
+ fi
281
+ EOF
282
+ )
283
+
284
+ reconcile_snippet=""
285
+ if [[ -n "$reconcile_command" ]]; then
286
+ printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
287
+ reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
288
+ fi
289
+
290
+ cat >"$inner_script" <<EOF
291
+ #!/usr/bin/env bash
292
+ set -euo pipefail
293
+ ${runtime_exports}
294
+ ${context_exports}cd ${worktree_q}
295
+
296
+ runner_state_file=${runner_state_q}
297
+ output_file=${output_q}
298
+ sandbox_run_dir=${sandbox_run_dir_q}
299
+ artifact_dir=${artifact_dir_q}
300
+ result_file_path=${sandbox_run_dir_q}/result.env
301
+ host_result_file=${result_q}
302
+ pi_bin=${pi_bin_q}
303
+ pi_model=${pi_model_q}
304
+ pi_thinking=${pi_thinking_q}
305
+ pi_timeout_seconds=${pi_timeout_q}
306
+ pi_stall_seconds=${pi_stall_q}
307
+ prompt_file=${prompt_q}
308
+ worktree=${worktree_q}
309
+
310
+ write_state() {
311
+ local runner_state="\${1:?runner state required}"
312
+ local last_exit_code="\${2:-}"
313
+ local failure_reason="\${3:-}"
314
+ local updated_at tmp_file
315
+
316
+ updated_at="\$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
317
+ tmp_file="\${runner_state_file}.tmp.\$\$"
318
+ {
319
+ printf 'RUNNER_STATE=%q\n' "\${runner_state}"
320
+ printf 'ATTEMPT=1\n'
321
+ printf 'RESUME_COUNT=0\n'
322
+ printf 'LAST_EXIT_CODE=%q\n' "\${last_exit_code}"
323
+ printf 'LAST_FAILURE_REASON=%q\n' "\${failure_reason}"
324
+ printf 'LAST_TRIGGER_REASON=%q\n' ''
325
+ printf 'AUTH_WAIT_STARTED_AT=%q\n' ''
326
+ printf 'UPDATED_AT=%q\n' "\${updated_at}"
327
+ } >"\${tmp_file}"
328
+ mv "\${tmp_file}" "\${runner_state_file}"
329
+ }
330
+
331
+ write_result_fallback() {
332
+ local detail="\${1:-missing-result-contract}"
333
+ local tmp_file
334
+ tmp_file="\${result_file_path}.tmp.\$\$"
335
+ {
336
+ printf 'OUTCOME=blocked\n'
337
+ printf 'ACTION=host-comment-blocker\n'
338
+ printf 'DETAIL=%s\n' "\${detail}"
339
+ } >"\${tmp_file}"
340
+ mv "\${tmp_file}" "\${result_file_path}"
341
+ }
342
+
343
+ record_final_git_state() {
344
+ local final_head final_branch tmp_file
345
+ final_head="\$(git -C ${worktree_q} rev-parse HEAD 2>/dev/null || true)"
346
+ final_branch="\$(git -C ${worktree_q} branch --show-current 2>/dev/null || true)"
347
+ tmp_file=${meta_file_q}.tmp.final.$$
348
+ grep -vE '^(FINAL_HEAD|FINAL_BRANCH)=' ${meta_file_q} >"\${tmp_file}" 2>/dev/null || true
349
+ {
350
+ printf 'FINAL_HEAD=%q\n' "\${final_head}"
351
+ printf 'FINAL_BRANCH=%q\n' "\${final_branch}"
352
+ } >>"\${tmp_file}"
353
+ mv "\${tmp_file}" ${meta_file_q}
354
+ }
355
+
356
+ ${reconcile_snippet}
357
+
358
+ write_state running
359
+
360
+ mkdir -p "\${sandbox_run_dir}"
361
+
362
+ # Run pi in print mode (non-interactive, single-shot)
363
+ # --no-session: ephemeral, don't persist session state
364
+ # --thinking: reasoning depth
365
+ # @prompt_file: pass prompt as file reference
366
+ pi_exit_code=0
367
+ # macOS does not ship 'timeout'; prefer it when available, else use background watchdog
368
+ if command -v timeout >/dev/null 2>&1; then
369
+ timeout "\${pi_timeout_seconds}" \\
370
+ "\${pi_bin}" --print --no-session \\
371
+ --model "\${pi_model}" \\
372
+ --thinking "\${pi_thinking}" \\
373
+ "@\${prompt_file}" \\
374
+ 2>&1 | tee -a "\${output_file}" || pi_exit_code=\$?
375
+ elif command -v gtimeout >/dev/null 2>&1; then
376
+ gtimeout "\${pi_timeout_seconds}" \\
377
+ "\${pi_bin}" --print --no-session \\
378
+ --model "\${pi_model}" \\
379
+ --thinking "\${pi_thinking}" \\
380
+ "@\${prompt_file}" \\
381
+ 2>&1 | tee -a "\${output_file}" || pi_exit_code=\$?
382
+ else
383
+ "\${pi_bin}" --print --no-session \\
384
+ --model "\${pi_model}" \\
385
+ --thinking "\${pi_thinking}" \\
386
+ "@\${prompt_file}" \\
387
+ 2>&1 >> "\${output_file}" &
388
+ _pi_bgpid=\$!
389
+ # Hard timeout watchdog
390
+ ( sleep "\${pi_timeout_seconds}" && kill "\${_pi_bgpid}" 2>/dev/null \\
391
+ && printf '[pi] timed out after %s seconds\n' "\${pi_timeout_seconds}" >> "\${output_file}" ) &
392
+ _pi_timeout_wd=\$!
393
+ # Stall watchdog: kill if output file stops growing for pi_stall_seconds
394
+ if [[ "\${pi_stall_seconds}" -gt 0 ]]; then
395
+ (
396
+ _prev_size=-1
397
+ while kill -0 "\${_pi_bgpid}" 2>/dev/null; do
398
+ _cur_size="\$(wc -c < "\${output_file}" 2>/dev/null || echo 0)"
399
+ if [[ "\${_cur_size}" -eq "\${_prev_size}" ]]; then
400
+ if [[ -z "\${_idle_since:-}" ]]; then _idle_since="\$(date +%s)"; fi
401
+ if (( \$(date +%s) - _idle_since >= pi_stall_seconds )); then
402
+ printf '[pi] no output for %s seconds — aborting stalled worker\n' "\${pi_stall_seconds}" >> "\${output_file}"
403
+ kill "\${_pi_bgpid}" 2>/dev/null
404
+ break
405
+ fi
406
+ else
407
+ _idle_since=""
408
+ _prev_size="\${_cur_size}"
409
+ fi
410
+ sleep 5
411
+ done
412
+ ) &
413
+ _pi_stall_wd=\$!
414
+ fi
415
+ wait "\${_pi_bgpid}" || pi_exit_code=\$?
416
+ kill "\${_pi_timeout_wd}" 2>/dev/null || true
417
+ wait "\${_pi_timeout_wd}" 2>/dev/null || true
418
+ if [[ -n "\${_pi_stall_wd:-}" ]]; then
419
+ kill "\${_pi_stall_wd}" 2>/dev/null || true
420
+ wait "\${_pi_stall_wd}" 2>/dev/null || true
421
+ fi
422
+ if [[ "\${pi_exit_code}" -eq 143 ]]; then
423
+ pi_exit_code=124
424
+ fi
425
+ fi
426
+
427
+ if [[ "\${pi_exit_code}" -eq 0 ]]; then
428
+ # Pi runs in --print mode and cannot write result.env itself.
429
+ # If the result file is missing, write a blocked fallback so reconcile
430
+ # sees a valid contract instead of an invalid-result-contract failure.
431
+ if [[ ! -f "\${result_file_path}" ]]; then
432
+ write_result_fallback "missing-result-contract"
433
+ fi
434
+ write_state succeeded 0
435
+ else
436
+ failure_reason="pi-exit-\${pi_exit_code}"
437
+ if [[ "\${pi_exit_code}" -eq 124 ]]; then
438
+ failure_reason="timeout"
439
+ fi
440
+ if [[ ! -f "\${result_file_path}" ]]; then
441
+ write_result_fallback "\${failure_reason}"
442
+ fi
443
+ write_state failed "\${pi_exit_code}" "\${failure_reason}"
444
+ fi
445
+
446
+ record_final_git_state
447
+
448
+ ${collect_copy_snippet}
449
+ printf '\n__CODEX_EXIT__:%s\n' "\${pi_exit_code}" | tee -a "\${output_file}"
450
+ exit "\${pi_exit_code}"
451
+ EOF
452
+
453
+ chmod +x "$inner_script"
454
+
455
+ # Write initial runner state
456
+ {
457
+ printf 'RUNNER_STATE=%q\n' "running"
458
+ printf 'ATTEMPT=1\n'
459
+ printf 'RESUME_COUNT=0\n'
460
+ printf "LAST_EXIT_CODE=''\n"
461
+ printf "LAST_FAILURE_REASON=''\n"
462
+ printf "LAST_TRIGGER_REASON=''\n"
463
+ printf "AUTH_WAIT_STARTED_AT=''\n"
464
+ printf 'UPDATED_AT=%q\n' "$started_at"
465
+ printf 'RUNNER_STATE_FILE=%q\n' "$runner_state_file"
466
+ } >"$runner_state_file"
467
+
468
+ # Append pi-specific metadata to run.env for dashboard/status visibility
469
+ {
470
+ printf 'PI_MODEL=%s\n' "$pi_model_q"
471
+ printf 'PI_THINKING=%s\n' "$pi_thinking_q"
472
+ printf 'PI_TIMEOUT_SECONDS=%s\n' "$pi_timeout_q"
473
+ printf 'PI_STALL_SECONDS=%s\n' "$pi_stall_q"
474
+ printf 'PI_BIN=%s\n' "$pi_bin_q"
475
+ } >>"$meta_file"
476
+
477
+ tmux new-session -d -s "$session" -x 220 -y 50 \
478
+ "bash -l $script_q >> $output_q 2>&1; tmux wait-for -S $session_q-done" \; \
479
+ wait-for "$session-done" || true
@@ -13,7 +13,7 @@ EOF
13
13
 
14
14
  runs_root="${AGENT_PROJECT_RUNS_ROOT:-}"
15
15
  session=""
16
- exit_marker="__CODEX_EXIT__:"
16
+ exit_marker="__\\w+_EXIT__:"
17
17
 
18
18
  while [[ $# -gt 0 ]]; do
19
19
  case "$1" in
@@ -117,13 +117,6 @@ if [[ "$status" == "UNKNOWN" && -f "$output_file" ]]; then
117
117
  fi
118
118
  fi
119
119
 
120
- if [[ "$status" == "UNKNOWN" && -z "$failure_reason" ]]; then
121
- failure_reason="$(failure_reason_from_output || true)"
122
- if [[ -n "$failure_reason" ]]; then
123
- status="FAILED"
124
- fi
125
- fi
126
-
127
120
  if [[ "$status" == "UNKNOWN" && -n "$runner_state" ]]; then
128
121
  case "$runner_state" in
129
122
  running|waiting-auth-refresh|switching-account)
@@ -133,6 +126,7 @@ if [[ "$status" == "UNKNOWN" && -n "$runner_state" ]]; then
133
126
  # Check BEFORE stale result.env to avoid false SUCCEEDED when a prior
134
127
  # cycle's result.env happens to exist.
135
128
  status="FAILED"
129
+ failure_reason="$(failure_reason_from_output || true)"
136
130
  if [[ -z "$failure_reason" ]]; then
137
131
  failure_reason="runner-aborted-before-completion"
138
132
  fi
@@ -146,10 +140,19 @@ fi
146
140
  if [[ "$status" == "UNKNOWN" && -f "$result_file" ]]; then
147
141
  # A worker that managed to persist result.env already completed its contract,
148
142
  # even if the tmux session disappeared before the exit marker was flushed.
143
+ # Check BEFORE failure_reason_from_output so that a completed result.env
144
+ # is not overridden by transient failure text in the log.
149
145
  status="SUCCEEDED"
150
146
  result_only_completion="yes"
151
147
  fi
152
148
 
149
+ if [[ "$status" == "UNKNOWN" && -z "$failure_reason" ]]; then
150
+ failure_reason="$(failure_reason_from_output || true)"
151
+ if [[ -n "$failure_reason" ]]; then
152
+ status="FAILED"
153
+ fi
154
+ fi
155
+
153
156
  if [[ "$status" == "UNKNOWN" && -f "$output_file" ]]; then
154
157
  if rg -qi "You've hit your usage limit|You have reached your Codex usage limits|visit https://chatgpt.com/codex/settings/usage|Upgrade to Pro|rate limit exceeded|quota exceeded|usage cap (reached|exceeded)|usage quota (reached|exceeded)" "$output_file"; then
155
158
  status="FAILED"
@@ -37,11 +37,16 @@ if [[ -n "$SESSION" ]]; then
37
37
  ARGS+=(--session "$SESSION")
38
38
  fi
39
39
 
40
+ cleanup_exit=0
40
41
  AGENT_PROJECT_WORKTREE_ROOT="$WORKTREE_ROOT" \
41
42
  F_LOSNING_WORKTREE_ROOT="$WORKTREE_ROOT" \
42
- bash "${FLOW_TOOLS_DIR}/agent-project-cleanup-session" "${ARGS[@]}" >/dev/null
43
+ bash "${FLOW_TOOLS_DIR}/agent-project-cleanup-session" "${ARGS[@]}" >/dev/null || cleanup_exit=$?
43
44
 
44
45
  F_LOSNING_AGENT_REPO_ROOT="$AGENT_REPO_ROOT" \
45
46
  F_LOSNING_RETAINED_REPO_ROOT="$RETAINED_REPO_ROOT" \
46
47
  F_LOSNING_VSCODE_WORKSPACE_FILE="$VSCODE_WORKSPACE_FILE" \
47
48
  "${FLOW_TOOLS_DIR}/sync-vscode-workspace.sh" >/dev/null 2>&1 || true
49
+
50
+ if [[ "$cleanup_exit" -ne 0 ]]; then
51
+ exit "$cleanup_exit"
52
+ fi