agent-control-plane 0.1.14 → 0.2.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 (53) hide show
  1. package/README.md +323 -349
  2. package/bin/pr-risk.sh +28 -6
  3. package/hooks/heartbeat-hooks.sh +62 -22
  4. package/npm/bin/agent-control-plane.js +434 -12
  5. package/package.json +1 -1
  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 +77 -21
  12. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +123 -0
  13. package/tools/bin/agent-project-cleanup-session +84 -0
  14. package/tools/bin/agent-project-heartbeat-loop +10 -3
  15. package/tools/bin/agent-project-reconcile-issue-session +45 -12
  16. package/tools/bin/agent-project-reconcile-pr-session +25 -0
  17. package/tools/bin/agent-project-run-claude-session +2 -2
  18. package/tools/bin/agent-project-run-codex-resilient +57 -2
  19. package/tools/bin/agent-project-run-kilo-session +346 -14
  20. package/tools/bin/agent-project-run-ollama-session +658 -0
  21. package/tools/bin/agent-project-run-openclaw-session +73 -25
  22. package/tools/bin/agent-project-run-opencode-session +354 -14
  23. package/tools/bin/agent-project-run-pi-session +479 -0
  24. package/tools/bin/agent-project-worker-status +38 -1
  25. package/tools/bin/flow-config-lib.sh +123 -3
  26. package/tools/bin/flow-resident-worker-lib.sh +1 -1
  27. package/tools/bin/flow-shell-lib.sh +7 -2
  28. package/tools/bin/heartbeat-recovery-preflight.sh +1 -0
  29. package/tools/bin/heartbeat-safe-auto.sh +105 -17
  30. package/tools/bin/install-project-launchd.sh +19 -2
  31. package/tools/bin/prepare-worktree.sh +4 -4
  32. package/tools/bin/profile-activate.sh +2 -2
  33. package/tools/bin/profile-adopt.sh +2 -2
  34. package/tools/bin/project-init.sh +1 -1
  35. package/tools/bin/project-runtimectl.sh +90 -7
  36. package/tools/bin/provider-cooldown-state.sh +14 -14
  37. package/tools/bin/render-flow-config.sh +30 -33
  38. package/tools/bin/run-codex-task.sh +53 -4
  39. package/tools/bin/scaffold-profile.sh +18 -3
  40. package/tools/bin/start-issue-worker.sh +4 -1
  41. package/tools/bin/start-pr-fix-worker.sh +33 -0
  42. package/tools/bin/start-pr-review-worker.sh +34 -0
  43. package/tools/bin/start-resident-issue-loop.sh +5 -4
  44. package/tools/bin/sync-agent-repo.sh +2 -2
  45. package/tools/bin/sync-dependency-baseline.sh +3 -3
  46. package/tools/bin/sync-shared-agent-home.sh +4 -1
  47. package/tools/dashboard/app.js +62 -0
  48. package/tools/dashboard/dashboard_snapshot.py +53 -4
  49. package/tools/dashboard/index.html +5 -1
  50. package/tools/dashboard/styles.css +97 -20
  51. package/tools/templates/pr-fix-template.md +4 -8
  52. package/tools/templates/pr-merge-repair-template.md +4 -8
  53. package/tools/templates/pr-review-template.md +2 -1
@@ -115,6 +115,7 @@ result_file="${artifact_dir}/result.env"
115
115
  runner_state_file="${artifact_dir}/runner.env"
116
116
  sandbox_artifact_dir="${worktree%/}/${sandbox_subdir}"
117
117
  sandbox_run_dir="${worktree%/}/${sandbox_subdir}/${session}"
118
+ retained_repo_root="${ACP_RETAINED_REPO_ROOT:-${F_LOSNING_RETAINED_REPO_ROOT:-}}"
118
119
  started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
119
120
  openclaw_bin="$(command -v openclaw)"
120
121
  default_openclaw_agent_id="$(
@@ -154,6 +155,7 @@ printf -v runner_state_q '%q' "$runner_state_file"
154
155
  printf -v branch_q '%q' "$branch_name"
155
156
  printf -v sandbox_artifact_dir_q '%q' "$sandbox_artifact_dir"
156
157
  printf -v sandbox_run_dir_q '%q' "$sandbox_run_dir"
158
+ printf -v retained_repo_root_q '%q' "$retained_repo_root"
157
159
  printf -v adapter_id_q '%q' "$adapter_id"
158
160
  printf -v started_at_q '%q' "$started_at"
159
161
  printf -v openclaw_bin_q '%q' "$openclaw_bin"
@@ -230,18 +232,21 @@ export AGENT_PROJECT_RUN_DIR=${sandbox_run_dir_q}
230
232
  export AGENT_PROJECT_HOST_RUN_DIR=${artifact_dir_q}
231
233
  export AGENT_PROJECT_RESULT_FILE=${sandbox_run_dir_q}/result.env
232
234
  export AGENT_PROJECT_OPENCLAW_BIN=${openclaw_bin_q}
235
+ export AGENT_PROJECT_RETAINED_REPO_ROOT=${retained_repo_root_q}
233
236
  export ACP_SESSION=${session_q}
234
237
  export ACP_RUN_DIR=${sandbox_run_dir_q}
235
238
  export ACP_HOST_RUN_DIR=${artifact_dir_q}
236
239
  export ACP_RESULT_FILE=${sandbox_run_dir_q}/result.env
237
240
  export ACP_OPENCLAW_BIN=${openclaw_bin_q}
238
241
  export ACP_OPENCLAW_SESSION_ID=${openclaw_session_id_q}
242
+ export ACP_RETAINED_REPO_ROOT=${retained_repo_root_q}
239
243
  export F_LOSNING_SESSION=${session_q}
240
244
  export F_LOSNING_RUN_DIR=${sandbox_run_dir_q}
241
245
  export F_LOSNING_HOST_RUN_DIR=${artifact_dir_q}
242
246
  export F_LOSNING_RESULT_FILE=${sandbox_run_dir_q}/result.env
243
247
  export F_LOSNING_OPENCLAW_BIN=${openclaw_bin_q}
244
248
  export F_LOSNING_OPENCLAW_SESSION_ID=${openclaw_session_id_q}
249
+ export F_LOSNING_RETAINED_REPO_ROOT=${retained_repo_root_q}
245
250
  export OPENCLAW_STATE_DIR=${openclaw_state_dir_q}
246
251
  export OPENCLAW_CONFIG_PATH=${openclaw_config_path_q}
247
252
  EOF
@@ -294,6 +299,7 @@ runner_state_file=${runner_state_q}
294
299
  output_file=${output_q}
295
300
  sandbox_artifact_dir=${sandbox_artifact_dir_q}
296
301
  sandbox_run_dir=${sandbox_run_dir_q}
302
+ retained_repo_root=${retained_repo_root_q}
297
303
  artifact_dir=${artifact_dir_q}
298
304
  run_dir=${artifact_dir_q}
299
305
  task_kind=${task_kind_q}
@@ -554,6 +560,45 @@ recover_literal_runtime_artifacts() {
554
560
  return 0
555
561
  }
556
562
 
563
+ recover_retained_repo_artifact_leaks() {
564
+ local retained_worktree_root=""
565
+ local leaked_run_dir=""
566
+ local worktree_name=""
567
+ local session_name=""
568
+ local artifact_name=""
569
+ local recovered="no"
570
+
571
+ [[ -n "\${retained_repo_root}" ]] || return 0
572
+ worktree_name="\$(basename "\${worktree}")"
573
+ session_name="\${AGENT_PROJECT_SESSION:-}"
574
+ [[ -n "\${session_name}" ]] || return 0
575
+ retained_worktree_root="\${retained_repo_root%/}/worktrees"
576
+ leaked_run_dir="\${retained_worktree_root}/\${worktree_name}/.openclaw-artifacts/\${session_name}"
577
+
578
+ if [[ ! -d "\${leaked_run_dir}" || "\${leaked_run_dir}" == "\${sandbox_run_dir}" ]]; then
579
+ return 0
580
+ fi
581
+
582
+ for artifact_name in result.env verification.jsonl issue-comment.md pr-comment.md; do
583
+ if [[ -f "\${leaked_run_dir}/\${artifact_name}" ]]; then
584
+ cp "\${leaked_run_dir}/\${artifact_name}" "\${sandbox_run_dir}/\${artifact_name}" 2>/dev/null || true
585
+ cp "\${leaked_run_dir}/\${artifact_name}" "\${artifact_dir}/\${artifact_name}" 2>/dev/null || true
586
+ recovered="yes"
587
+ fi
588
+ done
589
+
590
+ rm -rf "\${leaked_run_dir}" 2>/dev/null || true
591
+ rmdir "\${retained_worktree_root}/\${worktree_name}/.openclaw-artifacts" 2>/dev/null || true
592
+ rmdir "\${retained_worktree_root}/\${worktree_name}" 2>/dev/null || true
593
+ rmdir "\${retained_worktree_root}" 2>/dev/null || true
594
+
595
+ if [[ "\${recovered}" == "yes" ]]; then
596
+ printf '[openclaw] recovered retained-repo artifact leak: %s\n' "\${leaked_run_dir}" >>"\${output_file}" 2>/dev/null || true
597
+ fi
598
+
599
+ return 0
600
+ }
601
+
557
602
  reset_sandbox_run_dir() {
558
603
  mkdir -p "\${sandbox_run_dir}"
559
604
  find "\${sandbox_run_dir}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
@@ -640,14 +685,16 @@ for key in ("OUTCOME", "ACTION", "DETAIL", "ISSUE_ID"):
640
685
  PY
641
686
  }
642
687
 
643
- # If sandbox result.env exists with implemented, validate it has verification
688
+ # If sandbox result.env already exists, trust the agent's contract.
689
+ # When the agent wrote OUTCOME=implemented but verification.jsonl is missing,
690
+ # keep the contract intact — the host reconcile will attempt verification
691
+ # recovery (extract_issue_host_recovery_commands) before publish, and that
692
+ # path can block if recovery also fails. Overriding to blocked here would
693
+ # skip reconcile's host-side recovery entirely and always produce a false
694
+ # block when the tool sandbox didn't inherit env vars for record-verification.
644
695
  if [[ -f "\${result_file_path}" ]]; then
645
- if grep -q 'OUTCOME=implemented' "\${result_file_path}" 2>/dev/null; then
646
- if [[ ! -f "\${verification_file}" ]]; then
647
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
648
- printf '[infer] BLOCKED: agent wrote OUTCOME=implemented but verification.jsonl is missing\n' >> "\${output_file}" 2>/dev/null || true
649
- return 0
650
- fi
696
+ if grep -q 'OUTCOME=implemented' "\${result_file_path}" 2>/dev/null && [[ ! -f "\${verification_file}" ]]; then
697
+ printf '[infer] WARN: agent wrote OUTCOME=implemented but verification.jsonl is missing — deferring to host reconcile recovery\n' >> "\${output_file}" 2>/dev/null || true
651
698
  fi
652
699
  return 0
653
700
  fi
@@ -663,15 +710,15 @@ PY
663
710
  return 0
664
711
  fi
665
712
 
666
- # Check if there are actual code changes (not just artifact files)
713
+ # Check if there are actual code changes (not just artifact files or docs)
667
714
  local has_product_changes="no"
668
- if git -C ${worktree_q} diff --name-only HEAD 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.md$' 2>/dev/null; then
715
+ 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
669
716
  has_product_changes="yes"
670
717
  fi
671
- if git -C ${worktree_q} diff --cached --name-only 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.md$' 2>/dev/null; then
718
+ 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
672
719
  has_product_changes="yes"
673
720
  fi
674
- if git -C ${worktree_q} log --oneline origin/main..HEAD 2>/dev/null | grep -q . 2>/dev/null; then
721
+ 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
675
722
  has_product_changes="yes"
676
723
  fi
677
724
 
@@ -681,32 +728,32 @@ PY
681
728
  return 0
682
729
  fi
683
730
 
684
- # If there are product changes but NO verification.jsonl, this is BLOCKED
685
- # The worker skipped verification do not allow publish
686
- if [[ "\${has_product_changes}" == "yes" && ! -f "\${verification_file}" ]]; then
687
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
731
+ # If there are product changes AND verification.jsonl exists with pass entries, allow implemented
732
+ if [[ "\${has_product_changes}" == "yes" && -f "\${verification_file}" ]] && grep -q '"status":"pass"' "\${verification_file}" 2>/dev/null; then
733
+ write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
688
734
  return 0
689
735
  fi
690
736
 
691
- # If there are product changes AND verification.jsonl exists with entries, allow implemented
692
- if [[ "\${has_product_changes}" == "yes" && -f "\${verification_file}" ]] && grep -q '"status":"pass"' "\${verification_file}" 2>/dev/null; then
737
+ # If there are product changes but NO verification.jsonl, still mark as
738
+ # implemented and let the host reconcile attempt verification recovery.
739
+ # Blocking here would prevent host-side recovery from ever running.
740
+ if [[ "\${has_product_changes}" == "yes" && ! -f "\${verification_file}" ]]; then
741
+ printf '[infer] product changes detected without verification.jsonl — marking implemented for host recovery\n' >> "\${output_file}" 2>/dev/null || true
693
742
  write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
694
743
  return 0
695
744
  fi
696
745
 
697
- # If output explicitly says blocked, trust that
698
- if grep -Eiq 'blocked|cannot proceed|unable to complete|not possible|missing spec' "\${output_file}" 2>/dev/null; then
746
+ # If output explicitly indicates the agent decided it is blocked.
747
+ # Use narrow patterns to avoid false positives from prompt context echoed in logs
748
+ # (e.g. "Prior Blocker Context" sections or issue comments mentioning "blocked").
749
+ 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
699
750
  write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
700
751
  return 0
701
752
  fi
702
753
 
703
- # If output suggests implemented but we couldn't verify, still block
754
+ # If output suggests implemented, mark as implemented and let reconcile verify
704
755
  if grep -Eiq 'created PR|opened PR|PR #|pull request|implemented' "\${output_file}" 2>/dev/null; then
705
- if [[ -f "\${verification_file}" ]]; then
706
- write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
707
- else
708
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
709
- fi
756
+ write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
710
757
  return 0
711
758
  fi
712
759
 
@@ -1217,6 +1264,7 @@ while true; do
1217
1264
  break
1218
1265
  done
1219
1266
  recover_literal_runtime_artifacts
1267
+ recover_retained_repo_artifact_leaks
1220
1268
  infer_result_from_output
1221
1269
  synthesize_comment_artifact_from_output
1222
1270
  if [[ "\${status}" -eq 0 ]]; then
@@ -4,24 +4,364 @@ 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
+ reconcile_snippet=""
244
+ if [[ -n "$reconcile_command" ]]; then
245
+ 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"
246
+ reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
247
+ fi
248
+
249
+ cat >"$inner_script" <<EOF
250
+ #!/usr/bin/env bash
251
+ set -euo pipefail
252
+ ${runtime_exports}
253
+ ${context_exports}cd ${worktree_q}
254
+
255
+ runner_state_file=${runner_state_q}
256
+ output_file=${output_q}
257
+ sandbox_run_dir=${sandbox_run_dir_q}
258
+ artifact_dir=${artifact_dir_q}
259
+ result_file_path=${sandbox_run_dir_q}/result.env
260
+ host_result_file=${result_q}
261
+ crush_bin=${crush_bin_q}
262
+ opencode_model=${opencode_model_q}
263
+ opencode_timeout=${opencode_timeout_q}
264
+ prompt_file=${prompt_q}
265
+ worktree=${worktree_q}
266
+
267
+ write_state() {
268
+ local runner_state="\${1:?runner state required}"
269
+ local last_exit_code="\${2:-}"
270
+ local failure_reason="\${3:-}"
271
+ local updated_at tmp_file
272
+
273
+ updated_at="\$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
274
+ tmp_file="\${runner_state_file}.tmp.\$\$"
275
+ {
276
+ printf 'RUNNER_STATE=%q\n' "\${runner_state}"
277
+ printf 'ATTEMPT=1\n'
278
+ printf 'RESUME_COUNT=0\n'
279
+ printf 'LAST_EXIT_CODE=%q\n' "\${last_exit_code}"
280
+ printf 'LAST_FAILURE_REASON=%q\n' "\${failure_reason}"
281
+ printf 'UPDATED_AT=%q\n' "\${updated_at}"
282
+ } >"\${tmp_file}"
283
+ mv "\${tmp_file}" "\${runner_state_file}"
284
+ }
285
+
286
+ record_final_git_state() {
287
+ local final_head final_branch tmp_file
288
+ final_head="\$(git -C ${worktree_q} rev-parse HEAD 2>/dev/null || true)"
289
+ final_branch="\$(git -C ${worktree_q} branch --show-current 2>/dev/null || true)"
290
+ tmp_file=${meta_file_q}.tmp.final.\$\$
291
+ grep -vE '^(FINAL_HEAD|FINAL_BRANCH)=' ${meta_file_q} >"\${tmp_file}" 2>/dev/null || true
292
+ {
293
+ printf 'FINAL_HEAD=%q\n' "\${final_head}"
294
+ printf 'FINAL_BRANCH=%q\n' "\${final_branch}"
295
+ } >>"\${tmp_file}"
296
+ mv "\${tmp_file}" ${meta_file_q}
297
+ }
298
+
299
+ write_state running
300
+
301
+ mkdir -p "\${sandbox_run_dir}"
302
+
303
+ # Crush (opencode) runs via 'crush run' in non-interactive mode.
304
+ # --quiet suppresses spinner, --yolo auto-approves all tool permissions,
305
+ # --model selects the provider/model, --cwd sets the working directory.
306
+ # Prompt is provided via stdin.
307
+ crush_exit_code=0
308
+ crush_args=(run --quiet --model "\${opencode_model}" --cwd ${worktree_q})
309
+ if [[ "${mode_q}" == "bypass" ]]; then
310
+ crush_args+=(--yolo)
311
+ fi
312
+
313
+ if command -v timeout >/dev/null 2>&1; then
314
+ timeout "\${opencode_timeout}" "\${crush_bin}" "\${crush_args[@]}" <"\${prompt_file}" 2>&1 | tee -a "\${output_file}" || crush_exit_code=\$?
315
+ elif command -v gtimeout >/dev/null 2>&1; then
316
+ gtimeout "\${opencode_timeout}" "\${crush_bin}" "\${crush_args[@]}" <"\${prompt_file}" 2>&1 | tee -a "\${output_file}" || crush_exit_code=\$?
317
+ else
318
+ "\${crush_bin}" "\${crush_args[@]}" <"\${prompt_file}" 2>&1 | tee -a "\${output_file}" &
319
+ _crush_pid=\$!
320
+ ( sleep "\${opencode_timeout}" && kill "\${_crush_pid}" 2>/dev/null ) &
321
+ _wd=\$!
322
+ wait "\${_crush_pid}" || crush_exit_code=\$?
323
+ kill "\${_wd}" 2>/dev/null || true; wait "\${_wd}" 2>/dev/null || true
324
+ [[ "\${crush_exit_code}" -eq 143 ]] && crush_exit_code=124
325
+ fi
326
+
327
+ if [[ "\${crush_exit_code}" -eq 0 ]]; then
328
+ write_state succeeded 0
329
+ # Crush has full tool access — it can write files and make commits.
330
+ # Infer result from git state if result.env was not written by the agent.
331
+ if [[ ! -f "\${result_file_path}" ]]; then
332
+ if git -C ${worktree_q} diff --name-only HEAD 2>/dev/null | grep -qvE '\.md$' 2>/dev/null \\
333
+ || git -C ${worktree_q} diff --cached --name-only 2>/dev/null | grep -qvE '\.md$' 2>/dev/null \\
334
+ || git -C ${worktree_q} diff --name-only origin/main..HEAD 2>/dev/null | grep -qvE '\.md$' 2>/dev/null; then
335
+ printf 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n' >"\${result_file_path}"
336
+ else
337
+ printf 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=missing-result-contract\n' >"\${result_file_path}"
338
+ fi
339
+ fi
340
+ else
341
+ failure_reason="opencode-exit-\${crush_exit_code}"
342
+ [[ "\${crush_exit_code}" -eq 124 ]] && failure_reason="timeout"
343
+ write_state failed "\${crush_exit_code}" "\${failure_reason}"
344
+ if [[ ! -f "\${result_file_path}" ]]; then
345
+ printf 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=%s\n' "\${failure_reason}" >"\${result_file_path}"
346
+ fi
347
+ fi
348
+
349
+ record_final_git_state
350
+
351
+ if [[ -f "\${result_file_path}" ]]; then
352
+ cp "\${result_file_path}" "\${host_result_file}"
353
+ fi
354
+ ${collect_copy_snippet}
355
+ ${reconcile_snippet}
356
+ printf '\n__CODEX_EXIT__:%s\n' "\${crush_exit_code}" | tee -a "\${output_file}"
357
+ exit "\${crush_exit_code}"
358
+ EOF
359
+
360
+ chmod +x "$inner_script"
361
+ tmux new-session -d -s "$session" "$inner_script"
362
+
363
+ printf 'SESSION=%s\n' "$session"
364
+ printf 'TASK_KIND=%s\n' "$task_kind"
365
+ printf 'TASK_ID=%s\n' "$task_id"
366
+ printf 'WORKTREE=%s\n' "$worktree"
367
+ printf 'OUTPUT=%s\n' "$output_file"