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
@@ -283,6 +283,16 @@ EOF
283
283
  done
284
284
  fi
285
285
 
286
+ # Always collect result.env from sandbox to artifact_dir
287
+ collect_copy_snippet+=$(
288
+ cat <<EOF
289
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
290
+ cp ${sandbox_run_dir_q}/result.env ${artifact_dir_q}/result.env
291
+ fi
292
+ EOF
293
+ )
294
+ collect_copy_snippet+=$'\n'
295
+
286
296
  reconcile_snippet=""
287
297
  if [[ -n "$reconcile_command" ]]; then
288
298
  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"
@@ -685,14 +695,16 @@ for key in ("OUTCOME", "ACTION", "DETAIL", "ISSUE_ID"):
685
695
  PY
686
696
  }
687
697
 
688
- # If sandbox result.env exists with implemented, validate it has verification
698
+ # If sandbox result.env already exists, trust the agent's contract.
699
+ # When the agent wrote OUTCOME=implemented but verification.jsonl is missing,
700
+ # keep the contract intact — the host reconcile will attempt verification
701
+ # recovery (extract_issue_host_recovery_commands) before publish, and that
702
+ # path can block if recovery also fails. Overriding to blocked here would
703
+ # skip reconcile's host-side recovery entirely and always produce a false
704
+ # block when the tool sandbox didn't inherit env vars for record-verification.
689
705
  if [[ -f "\${result_file_path}" ]]; then
690
- if grep -q 'OUTCOME=implemented' "\${result_file_path}" 2>/dev/null; then
691
- if [[ ! -f "\${verification_file}" ]]; then
692
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
693
- printf '[infer] BLOCKED: agent wrote OUTCOME=implemented but verification.jsonl is missing\n' >> "\${output_file}" 2>/dev/null || true
694
- return 0
695
- fi
706
+ if grep -q 'OUTCOME=implemented' "\${result_file_path}" 2>/dev/null && [[ ! -f "\${verification_file}" ]]; then
707
+ printf '[infer] WARN: agent wrote OUTCOME=implemented but verification.jsonl is missing — deferring to host reconcile recovery\n' >> "\${output_file}" 2>/dev/null || true
696
708
  fi
697
709
  return 0
698
710
  fi
@@ -708,15 +720,15 @@ PY
708
720
  return 0
709
721
  fi
710
722
 
711
- # Check if there are actual code changes (not just artifact files)
723
+ # Check if there are actual code changes (not just artifact files or docs)
712
724
  local has_product_changes="no"
713
- if git -C ${worktree_q} diff --name-only HEAD 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.md$' 2>/dev/null; then
725
+ if git -C ${worktree_q} diff --name-only HEAD 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.openclaw/|\.agent-session\.env$|\.md$' 2>/dev/null; then
714
726
  has_product_changes="yes"
715
727
  fi
716
- if git -C ${worktree_q} diff --cached --name-only 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.md$' 2>/dev/null; then
728
+ if git -C ${worktree_q} diff --cached --name-only 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.openclaw/|\.agent-session\.env$|\.md$' 2>/dev/null; then
717
729
  has_product_changes="yes"
718
730
  fi
719
- if git -C ${worktree_q} log --oneline origin/main..HEAD 2>/dev/null | grep -q . 2>/dev/null; then
731
+ if git -C ${worktree_q} diff --name-only origin/main..HEAD 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.openclaw/|\.agent-session\.env$|\.md$' 2>/dev/null; then
720
732
  has_product_changes="yes"
721
733
  fi
722
734
 
@@ -726,32 +738,32 @@ PY
726
738
  return 0
727
739
  fi
728
740
 
729
- # If there are product changes but NO verification.jsonl, this is BLOCKED
730
- # The worker skipped verification do not allow publish
731
- if [[ "\${has_product_changes}" == "yes" && ! -f "\${verification_file}" ]]; then
732
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
741
+ # If there are product changes AND verification.jsonl exists with pass entries, allow implemented
742
+ if [[ "\${has_product_changes}" == "yes" && -f "\${verification_file}" ]] && grep -q '"status":"pass"' "\${verification_file}" 2>/dev/null; then
743
+ write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
733
744
  return 0
734
745
  fi
735
746
 
736
- # If there are product changes AND verification.jsonl exists with entries, allow implemented
737
- if [[ "\${has_product_changes}" == "yes" && -f "\${verification_file}" ]] && grep -q '"status":"pass"' "\${verification_file}" 2>/dev/null; then
747
+ # If there are product changes but NO verification.jsonl, still mark as
748
+ # implemented and let the host reconcile attempt verification recovery.
749
+ # Blocking here would prevent host-side recovery from ever running.
750
+ if [[ "\${has_product_changes}" == "yes" && ! -f "\${verification_file}" ]]; then
751
+ printf '[infer] product changes detected without verification.jsonl — marking implemented for host recovery\n' >> "\${output_file}" 2>/dev/null || true
738
752
  write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
739
753
  return 0
740
754
  fi
741
755
 
742
- # If output explicitly says blocked, trust that
743
- if grep -Eiq 'blocked|cannot proceed|unable to complete|not possible|missing spec' "\${output_file}" 2>/dev/null; then
756
+ # If output explicitly indicates the agent decided it is blocked.
757
+ # Use narrow patterns to avoid false positives from prompt context echoed in logs
758
+ # (e.g. "Prior Blocker Context" sections or issue comments mentioning "blocked").
759
+ if grep -Eiq '^(OUTCOME=blocked|I am blocked|This issue is blocked|Cannot proceed with implementation|Unable to complete the task)' "\${output_file}" 2>/dev/null; then
744
760
  write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
745
761
  return 0
746
762
  fi
747
763
 
748
- # If output suggests implemented but we couldn't verify, still block
764
+ # If output suggests implemented, mark as implemented and let reconcile verify
749
765
  if grep -Eiq 'created PR|opened PR|PR #|pull request|implemented' "\${output_file}" 2>/dev/null; then
750
- if [[ -f "\${verification_file}" ]]; then
751
- write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
752
- else
753
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
754
- fi
766
+ write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
755
767
  return 0
756
768
  fi
757
769
 
@@ -4,24 +4,374 @@ set -euo pipefail
4
4
  usage() {
5
5
  cat <<'EOF'
6
6
  Usage:
7
- agent-project-run-opencode-session [shared session-wrapper options]
7
+ agent-project-run-opencode-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
8
 
9
- Placeholder adapter for the roadmap. The public package ships this command so
10
- ACP can expose and test worker routing for `opencode`, but live session
11
- execution is not implemented yet.
9
+ Launch a Crush (formerly OpenCode) worker session inside tmux for a project
10
+ adapter and persist the standard run artifacts.
12
11
 
13
- Use `codex`, `claude`, or `openclaw` for real runs today. See ROADMAP.md for
14
- current backend maturity.
12
+ Crush is the Go-based coding agent by Charm (charmbracelet/crush).
13
+ It executes via `crush run` in non-interactive mode.
14
+
15
+ Options:
16
+ --env-prefix <prefix> Export prefixed runtime/context env vars inside the worker
17
+ --context <KEY=VALUE> Extra metadata written to run.env and exported to the worker
18
+ --collect-file <name> Copy sandbox artifact file into the host run dir after execution
19
+ --reconcile-command <cmd> Host-side command queued after the worker exits
20
+ --sandbox-subdir <name> Subdir under the worktree for worker artifacts (default: .opencode-artifacts)
21
+ --opencode-model <id> Model in provider/name format (default: anthropic/claude-sonnet-4-20250514)
22
+ --opencode-timeout-seconds <secs> Hard timeout in seconds (default: 900)
23
+ --help Show this help
15
24
  EOF
16
25
  }
17
26
 
18
- case "${1:-}" in
19
- --help|-h)
20
- usage
21
- exit 0
22
- ;;
27
+ mode=""
28
+ session=""
29
+ worktree=""
30
+ prompt_file=""
31
+ runs_root=""
32
+ adapter_id=""
33
+ task_kind=""
34
+ task_id=""
35
+ env_prefix=""
36
+ sandbox_subdir=".opencode-artifacts"
37
+ reconcile_command=""
38
+ opencode_model="${ACP_OPENCODE_MODEL:-${F_LOSNING_OPENCODE_MODEL:-anthropic/claude-sonnet-4-20250514}}"
39
+ opencode_timeout_seconds="${ACP_OPENCODE_TIMEOUT_SECONDS:-${F_LOSNING_OPENCODE_TIMEOUT_SECONDS:-900}}"
40
+ declare -a context_items=()
41
+ declare -a collect_files=()
42
+
43
+ while [[ $# -gt 0 ]]; do
44
+ case "$1" in
45
+ --mode) mode="${2:-}"; shift 2 ;;
46
+ --session) session="${2:-}"; shift 2 ;;
47
+ --worktree) worktree="${2:-}"; shift 2 ;;
48
+ --prompt-file) prompt_file="${2:-}"; shift 2 ;;
49
+ --runs-root) runs_root="${2:-}"; shift 2 ;;
50
+ --adapter-id) adapter_id="${2:-}"; shift 2 ;;
51
+ --task-kind) task_kind="${2:-}"; shift 2 ;;
52
+ --task-id) task_id="${2:-}"; shift 2 ;;
53
+ --env-prefix) env_prefix="${2:-}"; shift 2 ;;
54
+ --context) context_items+=("${2:-}"); shift 2 ;;
55
+ --collect-file) collect_files+=("${2:-}"); shift 2 ;;
56
+ --reconcile-command) reconcile_command="${2:-}"; shift 2 ;;
57
+ --sandbox-subdir) sandbox_subdir="${2:-}"; shift 2 ;;
58
+ --opencode-model) opencode_model="${2:-}"; shift 2 ;;
59
+ --opencode-timeout-seconds) opencode_timeout_seconds="${2:-}"; shift 2 ;;
60
+ --help|-h) usage; exit 0 ;;
61
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
62
+ esac
63
+ done
64
+
65
+ if [[ -z "$mode" || -z "$session" || -z "$worktree" || -z "$prompt_file" || -z "$runs_root" || -z "$adapter_id" || -z "$task_kind" || -z "$task_id" ]]; then
66
+ usage >&2
67
+ exit 1
68
+ fi
69
+
70
+ case "$mode" in
71
+ safe|bypass) ;;
72
+ *) echo "--mode must be safe or bypass" >&2; exit 1 ;;
73
+ esac
74
+
75
+ case "$opencode_timeout_seconds" in
76
+ ''|*[!0-9]*|0) echo "--opencode-timeout-seconds must be a positive integer" >&2; exit 1 ;;
23
77
  esac
24
78
 
25
- echo "agent-project-run-opencode-session: opencode support is scaffolded, but execution is not implemented yet" >&2
26
- echo "Choose codex, claude, or openclaw for live runs today." >&2
27
- exit 1
79
+ resolve_crush_bin() {
80
+ local configured_bin="${CRUSH_BIN:-${ACP_CRUSH_BIN:-${ACP_OPENCODE_BIN:-}}}"
81
+ if [[ -n "${configured_bin}" && -x "${configured_bin}" ]]; then
82
+ printf '%s\n' "${configured_bin}"
83
+ return 0
84
+ fi
85
+ # Try crush first (current name), then opencode (legacy name)
86
+ if command -v crush >/dev/null 2>&1; then
87
+ command -v crush
88
+ return 0
89
+ fi
90
+ if command -v opencode >/dev/null 2>&1; then
91
+ command -v opencode
92
+ return 0
93
+ fi
94
+ local -a fallback_paths=(
95
+ "/opt/homebrew/bin/crush"
96
+ "/usr/local/bin/crush"
97
+ "${HOME}/.local/bin/crush"
98
+ "${HOME}/go/bin/crush"
99
+ "/opt/homebrew/bin/opencode"
100
+ "/usr/local/bin/opencode"
101
+ )
102
+ local p
103
+ for p in "${fallback_paths[@]}"; do
104
+ if [[ -x "${p}" ]]; then
105
+ printf '%s\n' "${p}"
106
+ return 0
107
+ fi
108
+ done
109
+ return 1
110
+ }
111
+
112
+ crush_bin="$(resolve_crush_bin || true)"
113
+ if [[ -z "${crush_bin}" || ! -x "${crush_bin}" ]]; then
114
+ echo "unable to resolve a runnable crush/opencode binary — install with: brew install charmbracelet/tap/crush" >&2
115
+ exit 1
116
+ fi
117
+
118
+ artifact_dir="${runs_root}/${session}"
119
+ output_file="${artifact_dir}/${session}.log"
120
+ inner_script="${artifact_dir}/${session}.sh"
121
+ meta_file="${artifact_dir}/run.env"
122
+ result_file="${artifact_dir}/result.env"
123
+ runner_state_file="${artifact_dir}/runner.env"
124
+ sandbox_run_dir="${worktree%/}/${sandbox_subdir}/${session}"
125
+ started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
126
+
127
+ mkdir -p "$artifact_dir" "$sandbox_run_dir"
128
+
129
+ if tmux has-session -t "$session" 2>/dev/null; then
130
+ echo "tmux session already exists: $session" >&2
131
+ exit 1
132
+ fi
133
+
134
+ branch_name="$(git -C "$worktree" branch --show-current 2>/dev/null || true)"
135
+
136
+ printf -v session_q '%q' "$session"
137
+ printf -v task_kind_q '%q' "$task_kind"
138
+ printf -v task_id_q '%q' "$task_id"
139
+ printf -v mode_q '%q' "$mode"
140
+ printf -v worktree_q '%q' "$worktree"
141
+ printf -v prompt_q '%q' "$prompt_file"
142
+ printf -v output_q '%q' "$output_file"
143
+ printf -v artifact_dir_q '%q' "$artifact_dir"
144
+ printf -v script_q '%q' "$inner_script"
145
+ printf -v result_q '%q' "$result_file"
146
+ printf -v meta_file_q '%q' "$meta_file"
147
+ printf -v runner_state_q '%q' "$runner_state_file"
148
+ printf -v branch_q '%q' "$branch_name"
149
+ printf -v sandbox_run_dir_q '%q' "$sandbox_run_dir"
150
+ printf -v adapter_id_q '%q' "$adapter_id"
151
+ printf -v started_at_q '%q' "$started_at"
152
+ printf -v crush_bin_q '%q' "$crush_bin"
153
+ printf -v opencode_model_q '%q' "$opencode_model"
154
+ printf -v opencode_timeout_q '%q' "$opencode_timeout_seconds"
155
+
156
+ {
157
+ printf 'TASK_KIND=%s\n' "$task_kind_q"
158
+ printf 'TASK_ID=%s\n' "$task_id_q"
159
+ printf 'SESSION=%s\n' "$session_q"
160
+ printf 'MODE=%s\n' "$mode_q"
161
+ printf 'WORKTREE=%s\n' "$worktree_q"
162
+ printf 'PROMPT_FILE=%s\n' "$prompt_q"
163
+ printf 'OUTPUT_FILE=%s\n' "$output_q"
164
+ printf 'BRANCH=%s\n' "$branch_q"
165
+ printf 'RESULT_FILE=%s\n' "$result_q"
166
+ printf 'RUNNER_STATE_FILE=%s\n' "$runner_state_q"
167
+ printf 'SANDBOX_RUN_DIR=%s\n' "$sandbox_run_dir_q"
168
+ printf 'ADAPTER_ID=%s\n' "$adapter_id_q"
169
+ printf 'STARTED_AT=%s\n' "$started_at_q"
170
+ printf 'CRUSH_BIN=%s\n' "$crush_bin_q"
171
+ printf 'OPENCODE_MODEL=%s\n' "$opencode_model_q"
172
+ printf 'OPENCODE_TIMEOUT_SECONDS=%s\n' "$opencode_timeout_q"
173
+ } >"$meta_file"
174
+
175
+ context_exports=""
176
+ if ((${#context_items[@]} > 0)); then
177
+ for item in "${context_items[@]}"; do
178
+ if [[ "$item" != *=* ]]; then
179
+ echo "--context must use KEY=VALUE syntax: $item" >&2
180
+ exit 1
181
+ fi
182
+ key="${item%%=*}"
183
+ value="${item#*=}"
184
+ if [[ ! "$key" =~ ^[A-Z0-9_]+$ ]]; then
185
+ echo "Invalid context key: $key" >&2
186
+ exit 1
187
+ fi
188
+ printf -v value_q '%q' "$value"
189
+ printf '%s=%s\n' "$key" "$value_q" >>"$meta_file"
190
+ if [[ -n "$env_prefix" ]]; then
191
+ context_exports+="export ${env_prefix}${key}=${value_q}"$'\n'
192
+ fi
193
+ context_exports+="export ACP_${key}=${value_q}"$'\n'
194
+ if [[ "$env_prefix" != "F_LOSNING_" ]]; then
195
+ context_exports+="export F_LOSNING_${key}=${value_q}"$'\n'
196
+ fi
197
+ done
198
+ fi
199
+
200
+ runtime_exports=$(
201
+ cat <<EOF
202
+ export AGENT_PROJECT_SESSION=${session_q}
203
+ export AGENT_PROJECT_RUN_DIR=${sandbox_run_dir_q}
204
+ export AGENT_PROJECT_HOST_RUN_DIR=${artifact_dir_q}
205
+ export AGENT_PROJECT_RESULT_FILE=${sandbox_run_dir_q}/result.env
206
+ export ACP_SESSION=${session_q}
207
+ export ACP_RUN_DIR=${sandbox_run_dir_q}
208
+ export ACP_HOST_RUN_DIR=${artifact_dir_q}
209
+ export ACP_RESULT_FILE=${sandbox_run_dir_q}/result.env
210
+ export F_LOSNING_SESSION=${session_q}
211
+ export F_LOSNING_RUN_DIR=${sandbox_run_dir_q}
212
+ export F_LOSNING_HOST_RUN_DIR=${artifact_dir_q}
213
+ export F_LOSNING_RESULT_FILE=${sandbox_run_dir_q}/result.env
214
+ EOF
215
+ )
216
+
217
+ if [[ -n "$env_prefix" ]]; then
218
+ runtime_exports+=$'\n'
219
+ runtime_exports+=$(cat <<EOF
220
+ export ${env_prefix}SESSION=${session_q}
221
+ export ${env_prefix}RUN_DIR=${sandbox_run_dir_q}
222
+ export ${env_prefix}HOST_RUN_DIR=${artifact_dir_q}
223
+ export ${env_prefix}RESULT_FILE=${sandbox_run_dir_q}/result.env
224
+ EOF
225
+ )
226
+ fi
227
+
228
+ collect_copy_snippet=""
229
+ if ((${#collect_files[@]} > 0)); then
230
+ for artifact_name in "${collect_files[@]}"; do
231
+ [[ -z "$artifact_name" ]] && continue
232
+ printf -v artifact_q '%q' "$artifact_name"
233
+ collect_copy_snippet+=$(cat <<EOF
234
+ if [[ -f ${sandbox_run_dir_q}/${artifact_q} ]]; then
235
+ cp ${sandbox_run_dir_q}/${artifact_q} ${artifact_dir_q}/${artifact_q}
236
+ fi
237
+ EOF
238
+ )
239
+ collect_copy_snippet+=$'\n'
240
+ done
241
+ fi
242
+
243
+ # Always collect result.env from sandbox to artifact_dir
244
+ collect_copy_snippet+=$(
245
+ cat <<EOF
246
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
247
+ cp ${sandbox_run_dir_q}/result.env ${artifact_dir_q}/result.env
248
+ fi
249
+ EOF
250
+ )
251
+ collect_copy_snippet+=$'\n'
252
+
253
+ reconcile_snippet=""
254
+ if [[ -n "$reconcile_command" ]]; then
255
+ 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"
256
+ reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
257
+ fi
258
+
259
+ cat >"$inner_script" <<EOF
260
+ #!/usr/bin/env bash
261
+ set -euo pipefail
262
+ ${runtime_exports}
263
+ ${context_exports}cd ${worktree_q}
264
+
265
+ runner_state_file=${runner_state_q}
266
+ output_file=${output_q}
267
+ sandbox_run_dir=${sandbox_run_dir_q}
268
+ artifact_dir=${artifact_dir_q}
269
+ result_file_path=${sandbox_run_dir_q}/result.env
270
+ host_result_file=${result_q}
271
+ crush_bin=${crush_bin_q}
272
+ opencode_model=${opencode_model_q}
273
+ opencode_timeout=${opencode_timeout_q}
274
+ prompt_file=${prompt_q}
275
+ worktree=${worktree_q}
276
+
277
+ write_state() {
278
+ local runner_state="\${1:?runner state required}"
279
+ local last_exit_code="\${2:-}"
280
+ local failure_reason="\${3:-}"
281
+ local updated_at tmp_file
282
+
283
+ updated_at="\$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
284
+ tmp_file="\${runner_state_file}.tmp.\$\$"
285
+ {
286
+ printf 'RUNNER_STATE=%q\n' "\${runner_state}"
287
+ printf 'ATTEMPT=1\n'
288
+ printf 'RESUME_COUNT=0\n'
289
+ printf 'LAST_EXIT_CODE=%q\n' "\${last_exit_code}"
290
+ printf 'LAST_FAILURE_REASON=%q\n' "\${failure_reason}"
291
+ printf 'UPDATED_AT=%q\n' "\${updated_at}"
292
+ } >"\${tmp_file}"
293
+ mv "\${tmp_file}" "\${runner_state_file}"
294
+ }
295
+
296
+ record_final_git_state() {
297
+ local final_head final_branch tmp_file
298
+ final_head="\$(git -C ${worktree_q} rev-parse HEAD 2>/dev/null || true)"
299
+ final_branch="\$(git -C ${worktree_q} branch --show-current 2>/dev/null || true)"
300
+ tmp_file=${meta_file_q}.tmp.final.\$\$
301
+ grep -vE '^(FINAL_HEAD|FINAL_BRANCH)=' ${meta_file_q} >"\${tmp_file}" 2>/dev/null || true
302
+ {
303
+ printf 'FINAL_HEAD=%q\n' "\${final_head}"
304
+ printf 'FINAL_BRANCH=%q\n' "\${final_branch}"
305
+ } >>"\${tmp_file}"
306
+ mv "\${tmp_file}" ${meta_file_q}
307
+ }
308
+
309
+ write_state running
310
+
311
+ mkdir -p "\${sandbox_run_dir}"
312
+
313
+ # Crush (opencode) runs via 'crush run' in non-interactive mode.
314
+ # --quiet suppresses spinner, --yolo auto-approves all tool permissions,
315
+ # --model selects the provider/model, --cwd sets the working directory.
316
+ # Prompt is provided via stdin.
317
+ crush_exit_code=0
318
+ crush_args=(run --quiet --model "\${opencode_model}" --cwd ${worktree_q})
319
+ if [[ "${mode_q}" == "bypass" ]]; then
320
+ crush_args+=(--yolo)
321
+ fi
322
+
323
+ if command -v timeout >/dev/null 2>&1; then
324
+ timeout "\${opencode_timeout}" "\${crush_bin}" "\${crush_args[@]}" <"\${prompt_file}" 2>&1 | tee -a "\${output_file}" || crush_exit_code=\$?
325
+ elif command -v gtimeout >/dev/null 2>&1; then
326
+ gtimeout "\${opencode_timeout}" "\${crush_bin}" "\${crush_args[@]}" <"\${prompt_file}" 2>&1 | tee -a "\${output_file}" || crush_exit_code=\$?
327
+ else
328
+ "\${crush_bin}" "\${crush_args[@]}" <"\${prompt_file}" 2>&1 | tee -a "\${output_file}" &
329
+ _crush_pid=\$!
330
+ ( sleep "\${opencode_timeout}" && kill "\${_crush_pid}" 2>/dev/null ) &
331
+ _wd=\$!
332
+ wait "\${_crush_pid}" || crush_exit_code=\$?
333
+ kill "\${_wd}" 2>/dev/null || true; wait "\${_wd}" 2>/dev/null || true
334
+ [[ "\${crush_exit_code}" -eq 143 ]] && crush_exit_code=124
335
+ fi
336
+
337
+ if [[ "\${crush_exit_code}" -eq 0 ]]; then
338
+ write_state succeeded 0
339
+ # Crush has full tool access — it can write files and make commits.
340
+ # Infer result from git state if result.env was not written by the agent.
341
+ if [[ ! -f "\${result_file_path}" ]]; then
342
+ if git -C ${worktree_q} diff --name-only HEAD 2>/dev/null | grep -qvE '\.md$' 2>/dev/null \\
343
+ || git -C ${worktree_q} diff --cached --name-only 2>/dev/null | grep -qvE '\.md$' 2>/dev/null \\
344
+ || git -C ${worktree_q} diff --name-only origin/main..HEAD 2>/dev/null | grep -qvE '\.md$' 2>/dev/null; then
345
+ printf 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n' >"\${result_file_path}"
346
+ else
347
+ printf 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=missing-result-contract\n' >"\${result_file_path}"
348
+ fi
349
+ fi
350
+ else
351
+ failure_reason="opencode-exit-\${crush_exit_code}"
352
+ [[ "\${crush_exit_code}" -eq 124 ]] && failure_reason="timeout"
353
+ write_state failed "\${crush_exit_code}" "\${failure_reason}"
354
+ if [[ ! -f "\${result_file_path}" ]]; then
355
+ printf 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=%s\n' "\${failure_reason}" >"\${result_file_path}"
356
+ fi
357
+ fi
358
+
359
+ record_final_git_state
360
+
361
+ if [[ -f "\${result_file_path}" ]]; then
362
+ cp "\${result_file_path}" "\${host_result_file}"
363
+ fi
364
+ ${collect_copy_snippet}
365
+ ${reconcile_snippet}
366
+ printf '\n__CODEX_EXIT__:%s\n' "\${crush_exit_code}" | tee -a "\${output_file}"
367
+ exit "\${crush_exit_code}"
368
+ EOF
369
+
370
+ chmod +x "$inner_script"
371
+ tmux new-session -d -s "$session" "$inner_script"
372
+
373
+ printf 'SESSION=%s\n' "$session"
374
+ printf 'TASK_KIND=%s\n' "$task_kind"
375
+ printf 'TASK_ID=%s\n' "$task_id"
376
+ printf 'WORKTREE=%s\n' "$worktree"
377
+ printf 'OUTPUT=%s\n' "$output_file"