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.
- package/README.md +323 -349
- package/bin/pr-risk.sh +28 -6
- package/hooks/heartbeat-hooks.sh +62 -22
- package/npm/bin/agent-control-plane.js +434 -12
- package/package.json +1 -1
- package/references/architecture.md +8 -0
- package/references/control-plane-map.md +6 -2
- package/references/release-checklist.md +0 -2
- package/tools/bin/agent-github-update-labels +6 -1
- package/tools/bin/agent-project-catch-up-issue-pr-links +118 -0
- package/tools/bin/agent-project-catch-up-merged-prs +77 -21
- package/tools/bin/agent-project-catch-up-scheduled-issue-retries +123 -0
- package/tools/bin/agent-project-cleanup-session +84 -0
- package/tools/bin/agent-project-heartbeat-loop +10 -3
- package/tools/bin/agent-project-reconcile-issue-session +45 -12
- package/tools/bin/agent-project-reconcile-pr-session +25 -0
- package/tools/bin/agent-project-run-claude-session +2 -2
- package/tools/bin/agent-project-run-codex-resilient +57 -2
- package/tools/bin/agent-project-run-kilo-session +346 -14
- package/tools/bin/agent-project-run-ollama-session +658 -0
- package/tools/bin/agent-project-run-openclaw-session +73 -25
- package/tools/bin/agent-project-run-opencode-session +354 -14
- package/tools/bin/agent-project-run-pi-session +479 -0
- package/tools/bin/agent-project-worker-status +38 -1
- package/tools/bin/flow-config-lib.sh +123 -3
- package/tools/bin/flow-resident-worker-lib.sh +1 -1
- package/tools/bin/flow-shell-lib.sh +7 -2
- package/tools/bin/heartbeat-recovery-preflight.sh +1 -0
- package/tools/bin/heartbeat-safe-auto.sh +105 -17
- package/tools/bin/install-project-launchd.sh +19 -2
- package/tools/bin/prepare-worktree.sh +4 -4
- package/tools/bin/profile-activate.sh +2 -2
- package/tools/bin/profile-adopt.sh +2 -2
- package/tools/bin/project-init.sh +1 -1
- package/tools/bin/project-runtimectl.sh +90 -7
- package/tools/bin/provider-cooldown-state.sh +14 -14
- package/tools/bin/render-flow-config.sh +30 -33
- package/tools/bin/run-codex-task.sh +53 -4
- package/tools/bin/scaffold-profile.sh +18 -3
- package/tools/bin/start-issue-worker.sh +4 -1
- package/tools/bin/start-pr-fix-worker.sh +33 -0
- package/tools/bin/start-pr-review-worker.sh +34 -0
- package/tools/bin/start-resident-issue-loop.sh +5 -4
- package/tools/bin/sync-agent-repo.sh +2 -2
- package/tools/bin/sync-dependency-baseline.sh +3 -3
- package/tools/bin/sync-shared-agent-home.sh +4 -1
- package/tools/dashboard/app.js +62 -0
- package/tools/dashboard/dashboard_snapshot.py +53 -4
- package/tools/dashboard/index.html +5 -1
- package/tools/dashboard/styles.css +97 -20
- package/tools/templates/pr-fix-template.md +4 -8
- package/tools/templates/pr-merge-repair-template.md +4 -8
- 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
|
|
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
|
-
|
|
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}
|
|
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
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
692
|
-
|
|
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
|
|
698
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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"
|