agent-control-plane 0.3.0 → 0.6.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 (106) hide show
  1. package/README.md +141 -28
  2. package/assets/workflow-catalog.json +1 -1
  3. package/bin/pr-risk.sh +22 -7
  4. package/bin/sync-pr-labels.sh +1 -1
  5. package/hooks/heartbeat-hooks.sh +125 -12
  6. package/hooks/issue-reconcile-hooks.sh +1 -1
  7. package/hooks/pr-reconcile-hooks.sh +1 -1
  8. package/npm/bin/agent-control-plane.js +257 -59
  9. package/package.json +39 -32
  10. package/tools/bin/debug-session.sh +106 -0
  11. package/tools/bin/flow-config-lib.sh +1203 -60
  12. package/tools/bin/flow-runtime-doctor-linux.sh +136 -0
  13. package/tools/bin/flow-runtime-doctor.sh +5 -1
  14. package/tools/bin/flow-shell-lib.sh +32 -0
  15. package/tools/bin/github-core-rate-limit-state.sh +77 -0
  16. package/tools/bin/github-write-outbox.sh +470 -0
  17. package/tools/bin/heartbeat-loop-scheduling-lib.sh +7 -7
  18. package/tools/bin/heartbeat-safe-auto.sh +42 -0
  19. package/tools/bin/install-project-launchd.sh +17 -2
  20. package/tools/bin/install-project-systemd.sh +255 -0
  21. package/tools/bin/project-init.sh +21 -1
  22. package/tools/bin/project-launchd-bootstrap.sh +5 -1
  23. package/tools/bin/project-runtimectl.sh +91 -2
  24. package/tools/bin/project-systemd-bootstrap.sh +74 -0
  25. package/tools/bin/scaffold-profile.sh +61 -3
  26. package/tools/bin/uninstall-project-systemd.sh +87 -0
  27. package/tools/dashboard/app.js +228 -6
  28. package/tools/dashboard/dashboard_snapshot.py +55 -0
  29. package/tools/dashboard/issue_queue_state.py +101 -0
  30. package/tools/dashboard/server.py +123 -1
  31. package/tools/dashboard/styles.css +526 -455
  32. package/tools/templates/pr-fix-template.md +3 -1
  33. package/tools/templates/pr-merge-repair-template.md +2 -1
  34. package/references/architecture.md +0 -217
  35. package/references/commands.md +0 -128
  36. package/references/control-plane-map.md +0 -124
  37. package/references/docs-map.md +0 -73
  38. package/references/release-checklist.md +0 -65
  39. package/references/repo-map.md +0 -36
  40. package/tools/bin/agent-cleanup-worktree +0 -247
  41. package/tools/bin/agent-github-update-labels +0 -71
  42. package/tools/bin/agent-init-worktree +0 -216
  43. package/tools/bin/agent-project-archive-run +0 -52
  44. package/tools/bin/agent-project-capture-worker +0 -46
  45. package/tools/bin/agent-project-catch-up-issue-pr-links +0 -118
  46. package/tools/bin/agent-project-catch-up-merged-prs +0 -194
  47. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +0 -123
  48. package/tools/bin/agent-project-cleanup-session +0 -513
  49. package/tools/bin/agent-project-detached-launch +0 -127
  50. package/tools/bin/agent-project-heartbeat-loop +0 -1029
  51. package/tools/bin/agent-project-open-issue-worktree +0 -89
  52. package/tools/bin/agent-project-open-pr-worktree +0 -80
  53. package/tools/bin/agent-project-publish-issue-pr +0 -465
  54. package/tools/bin/agent-project-reconcile-issue-session +0 -1398
  55. package/tools/bin/agent-project-reconcile-pr-session +0 -1230
  56. package/tools/bin/agent-project-retry-state +0 -147
  57. package/tools/bin/agent-project-run-claude-session +0 -805
  58. package/tools/bin/agent-project-run-codex-resilient +0 -955
  59. package/tools/bin/agent-project-run-codex-session +0 -435
  60. package/tools/bin/agent-project-run-kilo-session +0 -369
  61. package/tools/bin/agent-project-run-ollama-session +0 -658
  62. package/tools/bin/agent-project-run-openclaw-session +0 -1309
  63. package/tools/bin/agent-project-run-opencode-session +0 -377
  64. package/tools/bin/agent-project-run-pi-session +0 -479
  65. package/tools/bin/agent-project-sync-anchor-repo +0 -139
  66. package/tools/bin/agent-project-worker-status +0 -188
  67. package/tools/bin/branch-verification-guard.sh +0 -364
  68. package/tools/bin/capture-worker.sh +0 -18
  69. package/tools/bin/cleanup-worktree.sh +0 -52
  70. package/tools/bin/codex-quota +0 -31
  71. package/tools/bin/create-follow-up-issue.sh +0 -114
  72. package/tools/bin/dashboard-launchd-bootstrap.sh +0 -50
  73. package/tools/bin/issue-publish-localization-guard.sh +0 -142
  74. package/tools/bin/issue-publish-scope-guard.sh +0 -242
  75. package/tools/bin/issue-requires-local-workspace-install.sh +0 -31
  76. package/tools/bin/issue-resource-class.sh +0 -12
  77. package/tools/bin/kick-scheduler.sh +0 -75
  78. package/tools/bin/label-follow-up-issues.sh +0 -14
  79. package/tools/bin/new-pr-worktree.sh +0 -50
  80. package/tools/bin/new-worktree.sh +0 -49
  81. package/tools/bin/pr-risk.sh +0 -12
  82. package/tools/bin/prepare-worktree.sh +0 -142
  83. package/tools/bin/provider-cooldown-state.sh +0 -204
  84. package/tools/bin/publish-issue-worker.sh +0 -31
  85. package/tools/bin/reconcile-bootstrap-lib.sh +0 -113
  86. package/tools/bin/reconcile-issue-worker.sh +0 -34
  87. package/tools/bin/reconcile-pr-worker.sh +0 -34
  88. package/tools/bin/record-verification.sh +0 -71
  89. package/tools/bin/render-flow-config.sh +0 -98
  90. package/tools/bin/resident-issue-controller-lib.sh +0 -448
  91. package/tools/bin/resident-issue-queue-status.py +0 -35
  92. package/tools/bin/retry-state.sh +0 -31
  93. package/tools/bin/reuse-issue-worktree.sh +0 -121
  94. package/tools/bin/run-codex-bypass.sh +0 -3
  95. package/tools/bin/run-codex-safe.sh +0 -3
  96. package/tools/bin/run-codex-task.sh +0 -280
  97. package/tools/bin/serve-dashboard.sh +0 -5
  98. package/tools/bin/split-retained-slice.sh +0 -124
  99. package/tools/bin/start-issue-worker.sh +0 -943
  100. package/tools/bin/start-pr-fix-worker.sh +0 -491
  101. package/tools/bin/start-pr-merge-repair-worker.sh +0 -8
  102. package/tools/bin/start-pr-review-worker.sh +0 -261
  103. package/tools/bin/start-resident-issue-loop.sh +0 -499
  104. package/tools/bin/update-github-labels.sh +0 -14
  105. package/tools/bin/worker-status.sh +0 -19
  106. package/tools/bin/workflow-catalog.sh +0 -77
@@ -1,1309 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- usage() {
5
- cat <<'EOF'
6
- Usage:
7
- agent-project-run-openclaw-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 an OpenClaw local agent worker inside tmux for a project adapter and
10
- persist the standard run artifacts.
11
-
12
- Options:
13
- --env-prefix <prefix> Export prefixed runtime/context env vars inside the worker
14
- --context <KEY=VALUE> Extra metadata written to run.env and exported to the worker
15
- --collect-file <name> Copy worker artifact file into the host run dir after execution
16
- --reconcile-command <cmd> Host-side command queued after the worker exits
17
- --sandbox-subdir <name> Subdir under the worktree for worker artifacts (default: .openclaw-artifacts)
18
- --openclaw-model <id> Model id for the isolated OpenClaw agent
19
- --openclaw-thinking <level> OpenClaw thinking level
20
- --openclaw-timeout-seconds <secs> OpenClaw local-agent timeout
21
- --openclaw-stall-seconds <secs> Fail when the agent produces no output for too long (0 disables)
22
- --help Show this help
23
- EOF
24
- }
25
-
26
- mode=""
27
- session=""
28
- worktree=""
29
- prompt_file=""
30
- runs_root=""
31
- adapter_id=""
32
- task_kind=""
33
- task_id=""
34
- env_prefix=""
35
- sandbox_subdir=".openclaw-artifacts"
36
- reconcile_command=""
37
- keep_agent="false"
38
- openclaw_model="${ACP_OPENCLAW_MODEL:-${F_LOSNING_OPENCLAW_MODEL:-openrouter/qwen/qwen3.6-plus-preview:free}}"
39
- openclaw_thinking="${ACP_OPENCLAW_THINKING:-${F_LOSNING_OPENCLAW_THINKING:-low}}"
40
- openclaw_timeout_seconds="${ACP_OPENCLAW_TIMEOUT_SECONDS:-${F_LOSNING_OPENCLAW_TIMEOUT_SECONDS:-900}}"
41
- openclaw_stall_seconds="${ACP_OPENCLAW_STALL_SECONDS:-${F_LOSNING_OPENCLAW_STALL_SECONDS:-180}}"
42
- openclaw_progress_heartbeat_seconds="${ACP_OPENCLAW_PROGRESS_HEARTBEAT_SECONDS:-${F_LOSNING_OPENCLAW_PROGRESS_HEARTBEAT_SECONDS:-30}}"
43
- provided_openclaw_agent_id=""
44
- provided_openclaw_session_id=""
45
- provided_openclaw_agent_dir=""
46
- provided_openclaw_state_dir=""
47
- provided_openclaw_config_path=""
48
- declare -a context_items=()
49
- declare -a collect_files=()
50
-
51
- while [[ $# -gt 0 ]]; do
52
- case "$1" in
53
- --mode) mode="${2:-}"; shift 2 ;;
54
- --session) session="${2:-}"; shift 2 ;;
55
- --worktree) worktree="${2:-}"; shift 2 ;;
56
- --prompt-file) prompt_file="${2:-}"; shift 2 ;;
57
- --runs-root) runs_root="${2:-}"; shift 2 ;;
58
- --adapter-id) adapter_id="${2:-}"; shift 2 ;;
59
- --task-kind) task_kind="${2:-}"; shift 2 ;;
60
- --task-id) task_id="${2:-}"; shift 2 ;;
61
- --env-prefix) env_prefix="${2:-}"; shift 2 ;;
62
- --context) context_items+=("${2:-}"); shift 2 ;;
63
- --collect-file) collect_files+=("${2:-}"); shift 2 ;;
64
- --reconcile-command) reconcile_command="${2:-}"; shift 2 ;;
65
- --sandbox-subdir) sandbox_subdir="${2:-}"; shift 2 ;;
66
- --keep-agent) keep_agent="true"; shift ;;
67
- --openclaw-model) openclaw_model="${2:-}"; shift 2 ;;
68
- --openclaw-thinking) openclaw_thinking="${2:-}"; shift 2 ;;
69
- --openclaw-timeout-seconds) openclaw_timeout_seconds="${2:-}"; shift 2 ;;
70
- --openclaw-stall-seconds) openclaw_stall_seconds="${2:-}"; shift 2 ;;
71
- --openclaw-agent-id) provided_openclaw_agent_id="${2:-}"; shift 2 ;;
72
- --openclaw-session-id) provided_openclaw_session_id="${2:-}"; shift 2 ;;
73
- --openclaw-agent-dir) provided_openclaw_agent_dir="${2:-}"; shift 2 ;;
74
- --openclaw-state-dir) provided_openclaw_state_dir="${2:-}"; shift 2 ;;
75
- --openclaw-config-path) provided_openclaw_config_path="${2:-}"; shift 2 ;;
76
- --help|-h) usage; exit 0 ;;
77
- *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
78
- esac
79
- done
80
-
81
- if [[ -z "$mode" || -z "$session" || -z "$worktree" || -z "$prompt_file" || -z "$runs_root" || -z "$adapter_id" || -z "$task_kind" || -z "$task_id" ]]; then
82
- usage >&2
83
- exit 1
84
- fi
85
-
86
- case "$mode" in
87
- safe|bypass) ;;
88
- *)
89
- echo "--mode must be safe or bypass" >&2
90
- exit 1
91
- ;;
92
- esac
93
-
94
- case "$openclaw_timeout_seconds" in
95
- ''|*[!0-9]*) echo "--openclaw-timeout-seconds must be numeric" >&2; exit 1 ;;
96
- esac
97
- case "$openclaw_stall_seconds" in
98
- ''|*[!0-9]*) echo "--openclaw-stall-seconds must be numeric" >&2; exit 1 ;;
99
- esac
100
- case "$openclaw_progress_heartbeat_seconds" in
101
- ''|*[!0-9]*) echo "OpenClaw progress heartbeat seconds must be numeric" >&2; exit 1 ;;
102
- 0) echo "OpenClaw progress heartbeat seconds must be greater than zero" >&2; exit 1 ;;
103
- esac
104
-
105
- if ! command -v openclaw >/dev/null 2>&1; then
106
- echo "unable to resolve a runnable openclaw binary" >&2
107
- exit 1
108
- fi
109
-
110
- artifact_dir="${runs_root}/${session}"
111
- output_file="${artifact_dir}/${session}.log"
112
- inner_script="${artifact_dir}/${session}.sh"
113
- meta_file="${artifact_dir}/run.env"
114
- result_file="${artifact_dir}/result.env"
115
- runner_state_file="${artifact_dir}/runner.env"
116
- sandbox_artifact_dir="${worktree%/}/${sandbox_subdir}"
117
- sandbox_run_dir="${worktree%/}/${sandbox_subdir}/${session}"
118
- retained_repo_root="${ACP_RETAINED_REPO_ROOT:-${F_LOSNING_RETAINED_REPO_ROOT:-}}"
119
- started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
120
- openclaw_bin="$(command -v openclaw)"
121
- default_openclaw_agent_id="$(
122
- printf '%s-%s' "$adapter_id" "$session" \
123
- | tr '[:upper:]' '[:lower:]' \
124
- | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-+/-/g' \
125
- | cut -c1-63
126
- )"
127
- openclaw_agent_id="${provided_openclaw_agent_id:-${default_openclaw_agent_id}}"
128
- openclaw_session_id="${provided_openclaw_session_id:-${openclaw_agent_id}}"
129
- openclaw_state_dir="${provided_openclaw_state_dir:-${artifact_dir}/openclaw-state}"
130
- openclaw_config_path="${provided_openclaw_config_path:-${artifact_dir}/openclaw-config/openclaw.json}"
131
- openclaw_agent_dir="${provided_openclaw_agent_dir:-${artifact_dir}/openclaw-agent}"
132
-
133
- mkdir -p "$artifact_dir"
134
- mkdir -p "$sandbox_run_dir"
135
-
136
- if tmux has-session -t "$session" 2>/dev/null; then
137
- echo "tmux session already exists: $session" >&2
138
- exit 1
139
- fi
140
-
141
- branch_name="$(git -C "$worktree" branch --show-current 2>/dev/null || true)"
142
-
143
- printf -v session_q '%q' "$session"
144
- printf -v task_kind_q '%q' "$task_kind"
145
- printf -v task_id_q '%q' "$task_id"
146
- printf -v mode_q '%q' "$mode"
147
- printf -v worktree_q '%q' "$worktree"
148
- printf -v prompt_q '%q' "$prompt_file"
149
- printf -v output_q '%q' "$output_file"
150
- printf -v artifact_dir_q '%q' "$artifact_dir"
151
- printf -v script_q '%q' "$inner_script"
152
- printf -v result_q '%q' "$result_file"
153
- printf -v meta_file_q '%q' "$meta_file"
154
- printf -v runner_state_q '%q' "$runner_state_file"
155
- printf -v branch_q '%q' "$branch_name"
156
- printf -v sandbox_artifact_dir_q '%q' "$sandbox_artifact_dir"
157
- printf -v sandbox_run_dir_q '%q' "$sandbox_run_dir"
158
- printf -v retained_repo_root_q '%q' "$retained_repo_root"
159
- printf -v adapter_id_q '%q' "$adapter_id"
160
- printf -v started_at_q '%q' "$started_at"
161
- printf -v openclaw_bin_q '%q' "$openclaw_bin"
162
- printf -v openclaw_state_dir_q '%q' "$openclaw_state_dir"
163
- printf -v openclaw_config_path_q '%q' "$openclaw_config_path"
164
- printf -v openclaw_agent_dir_q '%q' "$openclaw_agent_dir"
165
- printf -v openclaw_agent_id_q '%q' "$openclaw_agent_id"
166
- printf -v openclaw_session_id_q '%q' "$openclaw_session_id"
167
- printf -v openclaw_model_q '%q' "$openclaw_model"
168
- printf -v openclaw_thinking_q '%q' "$openclaw_thinking"
169
- printf -v openclaw_timeout_q '%q' "$openclaw_timeout_seconds"
170
- printf -v openclaw_stall_q '%q' "$openclaw_stall_seconds"
171
- printf -v openclaw_progress_heartbeat_q '%q' "$openclaw_progress_heartbeat_seconds"
172
- printf -v keep_agent_q '%q' "$keep_agent"
173
-
174
- {
175
- printf 'TASK_KIND=%s\n' "$task_kind_q"
176
- printf 'TASK_ID=%s\n' "$task_id_q"
177
- printf 'SESSION=%s\n' "$session_q"
178
- printf 'MODE=%s\n' "$mode_q"
179
- printf 'WORKTREE=%s\n' "$worktree_q"
180
- printf 'PROMPT_FILE=%s\n' "$prompt_q"
181
- printf 'OUTPUT_FILE=%s\n' "$output_q"
182
- printf 'SCRIPT=%s\n' "$script_q"
183
- printf 'BRANCH=%s\n' "$branch_q"
184
- printf 'RESULT_FILE=%s\n' "$result_q"
185
- printf 'RUNNER_STATE_FILE=%s\n' "$runner_state_q"
186
- printf 'SANDBOX_RUN_DIR=%s\n' "$sandbox_run_dir_q"
187
- printf 'ADAPTER_ID=%s\n' "$adapter_id_q"
188
- printf 'STARTED_AT=%s\n' "$started_at_q"
189
- printf 'OPENCLAW_BIN=%s\n' "$openclaw_bin_q"
190
- printf 'OPENCLAW_STATE_DIR=%s\n' "$openclaw_state_dir_q"
191
- printf 'OPENCLAW_CONFIG_PATH=%s\n' "$openclaw_config_path_q"
192
- printf 'OPENCLAW_AGENT_DIR=%s\n' "$openclaw_agent_dir_q"
193
- printf 'OPENCLAW_AGENT_ID=%s\n' "$openclaw_agent_id_q"
194
- printf 'OPENCLAW_SESSION_ID=%s\n' "$openclaw_session_id_q"
195
- printf 'OPENCLAW_MODEL=%s\n' "$openclaw_model_q"
196
- printf 'OPENCLAW_THINKING=%s\n' "$openclaw_thinking_q"
197
- printf 'OPENCLAW_TIMEOUT_SECONDS=%s\n' "$openclaw_timeout_q"
198
- printf 'OPENCLAW_STALL_SECONDS=%s\n' "$openclaw_stall_q"
199
- printf 'OPENCLAW_PROGRESS_HEARTBEAT_SECONDS=%s\n' "$openclaw_progress_heartbeat_q"
200
- printf 'OPENCLAW_KEEP_AGENT=%s\n' "$keep_agent_q"
201
- } >"$meta_file"
202
-
203
- context_exports=""
204
- if ((${#context_items[@]} > 0)); then
205
- for item in "${context_items[@]}"; do
206
- if [[ "$item" != *=* ]]; then
207
- echo "--context must use KEY=VALUE syntax: $item" >&2
208
- exit 1
209
- fi
210
- key="${item%%=*}"
211
- value="${item#*=}"
212
- if [[ ! "$key" =~ ^[A-Z0-9_]+$ ]]; then
213
- echo "Invalid context key: $key" >&2
214
- exit 1
215
- fi
216
- printf -v value_q '%q' "$value"
217
- printf '%s=%s\n' "$key" "$value_q" >>"$meta_file"
218
- if [[ -n "$env_prefix" ]]; then
219
- context_exports+="export ${env_prefix}${key}=${value_q}"$'\n'
220
- fi
221
- context_exports+="export ACP_${key}=${value_q}"$'\n'
222
- if [[ "$env_prefix" != "F_LOSNING_" ]]; then
223
- context_exports+="export F_LOSNING_${key}=${value_q}"$'\n'
224
- fi
225
- done
226
- fi
227
-
228
- runtime_exports=$(
229
- cat <<EOF
230
- export AGENT_PROJECT_SESSION=${session_q}
231
- export AGENT_PROJECT_RUN_DIR=${sandbox_run_dir_q}
232
- export AGENT_PROJECT_HOST_RUN_DIR=${artifact_dir_q}
233
- export AGENT_PROJECT_RESULT_FILE=${sandbox_run_dir_q}/result.env
234
- export AGENT_PROJECT_OPENCLAW_BIN=${openclaw_bin_q}
235
- export AGENT_PROJECT_RETAINED_REPO_ROOT=${retained_repo_root_q}
236
- export ACP_SESSION=${session_q}
237
- export ACP_RUN_DIR=${sandbox_run_dir_q}
238
- export ACP_HOST_RUN_DIR=${artifact_dir_q}
239
- export ACP_RESULT_FILE=${sandbox_run_dir_q}/result.env
240
- export ACP_OPENCLAW_BIN=${openclaw_bin_q}
241
- export ACP_OPENCLAW_SESSION_ID=${openclaw_session_id_q}
242
- export ACP_RETAINED_REPO_ROOT=${retained_repo_root_q}
243
- export F_LOSNING_SESSION=${session_q}
244
- export F_LOSNING_RUN_DIR=${sandbox_run_dir_q}
245
- export F_LOSNING_HOST_RUN_DIR=${artifact_dir_q}
246
- export F_LOSNING_RESULT_FILE=${sandbox_run_dir_q}/result.env
247
- export F_LOSNING_OPENCLAW_BIN=${openclaw_bin_q}
248
- export F_LOSNING_OPENCLAW_SESSION_ID=${openclaw_session_id_q}
249
- export F_LOSNING_RETAINED_REPO_ROOT=${retained_repo_root_q}
250
- export OPENCLAW_STATE_DIR=${openclaw_state_dir_q}
251
- export OPENCLAW_CONFIG_PATH=${openclaw_config_path_q}
252
- EOF
253
- )
254
-
255
- if [[ -n "$env_prefix" ]]; then
256
- runtime_exports+=$'\n'
257
- runtime_exports+=$(cat <<EOF
258
- export ${env_prefix}SESSION=${session_q}
259
- export ${env_prefix}RUN_DIR=${sandbox_run_dir_q}
260
- export ${env_prefix}HOST_RUN_DIR=${artifact_dir_q}
261
- export ${env_prefix}RESULT_FILE=${sandbox_run_dir_q}/result.env
262
- export ${env_prefix}OPENCLAW_BIN=${openclaw_bin_q}
263
- export ${env_prefix}OPENCLAW_SESSION_ID=${openclaw_session_id_q}
264
- EOF
265
- )
266
- fi
267
-
268
- collect_copy_snippet=""
269
- if ((${#collect_files[@]} > 0)); then
270
- for artifact_name in "${collect_files[@]}"; do
271
- if [[ -z "$artifact_name" ]]; then
272
- continue
273
- fi
274
- printf -v artifact_q '%q' "$artifact_name"
275
- collect_copy_snippet+=$(
276
- cat <<EOF
277
- if [[ -f ${sandbox_run_dir_q}/${artifact_q} ]]; then
278
- cp ${sandbox_run_dir_q}/${artifact_q} ${artifact_dir_q}/${artifact_q}
279
- fi
280
- EOF
281
- )
282
- collect_copy_snippet+=$'\n'
283
- done
284
- fi
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
-
296
- reconcile_snippet=""
297
- if [[ -n "$reconcile_command" ]]; then
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"
299
- reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
300
- fi
301
-
302
- cat >"$inner_script" <<EOF
303
- #!/usr/bin/env bash
304
- set -euo pipefail
305
- ${runtime_exports}
306
- ${context_exports}cd ${worktree_q}
307
-
308
- runner_state_file=${runner_state_q}
309
- output_file=${output_q}
310
- sandbox_artifact_dir=${sandbox_artifact_dir_q}
311
- sandbox_run_dir=${sandbox_run_dir_q}
312
- retained_repo_root=${retained_repo_root_q}
313
- artifact_dir=${artifact_dir_q}
314
- run_dir=${artifact_dir_q}
315
- task_kind=${task_kind_q}
316
- worktree=${worktree_q}
317
- prompt_file_path=${prompt_q}
318
- openclaw_state_dir=${openclaw_state_dir_q}
319
- openclaw_config_path=${openclaw_config_path_q}
320
- openclaw_agent_dir=${openclaw_agent_dir_q}
321
- openclaw_agent_id=${openclaw_agent_id_q}
322
- openclaw_session_id=${openclaw_session_id_q}
323
- openclaw_model=${openclaw_model_q}
324
- openclaw_bin=${openclaw_bin_q}
325
- openclaw_timeout=${openclaw_timeout_q}
326
- openclaw_stall_seconds=${openclaw_stall_q}
327
- openclaw_progress_heartbeat_seconds=${openclaw_progress_heartbeat_q}
328
- openclaw_thinking=${openclaw_thinking_q}
329
- keep_agent=${keep_agent_q}
330
- openclaw_add_log="\${sandbox_run_dir}/openclaw-agents-add.log"
331
-
332
- write_state() {
333
- local runner_state="\${1:?runner state required}"
334
- local last_exit_code="\${2:-}"
335
- local failure_reason="\${3:-}"
336
- local updated_at tmp_file
337
-
338
- updated_at="\$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
339
- tmp_file="\${runner_state_file}.tmp.\$\$"
340
- {
341
- printf 'RUNNER_STATE=%q\n' "\${runner_state}"
342
- printf 'THREAD_ID=%q\n' "\${openclaw_session_id}"
343
- printf 'ATTEMPT=1\n'
344
- printf 'RESUME_COUNT=0\n'
345
- printf 'LAST_EXIT_CODE=%q\n' "\${last_exit_code}"
346
- printf 'LAST_FAILURE_REASON=%q\n' "\${failure_reason}"
347
- printf 'LAST_TRIGGER_REASON=%q\n' ''
348
- printf 'AUTH_WAIT_STARTED_AT=%q\n' ''
349
- printf 'LAST_AUTH_FINGERPRINT=%q\n' ''
350
- printf 'UPDATED_AT=%q\n' "\${updated_at}"
351
- } >"\${tmp_file}"
352
- mv "\${tmp_file}" "\${runner_state_file}"
353
- }
354
-
355
- record_final_git_state() {
356
- local final_head final_branch tmp_file
357
-
358
- final_head="\$(git -C ${worktree_q} rev-parse HEAD 2>/dev/null || true)"
359
- final_branch="\$(git -C ${worktree_q} branch --show-current 2>/dev/null || true)"
360
- tmp_file=${meta_file_q}.tmp.final.$$
361
- grep -vE '^(FINAL_HEAD|FINAL_BRANCH)=' ${meta_file_q} >"\${tmp_file}" 2>/dev/null || true
362
- {
363
- printf 'FINAL_HEAD=%q\n' "\${final_head}"
364
- printf 'FINAL_BRANCH=%q\n' "\${final_branch}"
365
- } >>"\${tmp_file}"
366
- mv "\${tmp_file}" ${meta_file_q}
367
- }
368
-
369
- ensure_openclaw_workspace_excludes() {
370
- local exclude_file line
371
- if ! git -C ${worktree_q} rev-parse --git-dir >/dev/null 2>&1; then
372
- return 0
373
- fi
374
- exclude_file="\$(git -C ${worktree_q} config --worktree --get core.excludesFile 2>/dev/null || true)"
375
- if [[ -z "\${exclude_file}" ]]; then
376
- exclude_file="\${sandbox_artifact_dir}/git-exclude"
377
- git -C ${worktree_q} config extensions.worktreeConfig true >/dev/null 2>&1 || true
378
- git -C ${worktree_q} config --worktree core.excludesFile "\${exclude_file}"
379
- fi
380
-
381
- mkdir -p "\$(dirname "\${exclude_file}")"
382
- touch "\${exclude_file}"
383
- while IFS= read -r line; do
384
- [[ -n "\${line}" ]] || continue
385
- if ! grep -Fqx "\${line}" "\${exclude_file}" 2>/dev/null; then
386
- printf '%s\n' "\${line}" >>"\${exclude_file}"
387
- fi
388
- done <<'PATTERNS'
389
- .openclaw-artifacts
390
- .openclaw
391
- SOUL.md
392
- TOOLS.md
393
- IDENTITY.md
394
- USER.md
395
- HEARTBEAT.md
396
- BOOTSTRAP.md
397
- AGENTS.md
398
- .agent-session.env
399
- \$ACP_RUN_DIR
400
- \$AGENT_PROJECT_RUN_DIR
401
- \$F_LOSNING_RUN_DIR
402
- \$ACP_HOST_RUN_DIR
403
- \$AGENT_PROJECT_HOST_RUN_DIR
404
- \$F_LOSNING_HOST_RUN_DIR
405
- \$ACP_RESULT_FILE
406
- \$AGENT_PROJECT_RESULT_FILE
407
- \$F_LOSNING_RESULT_FILE
408
- PATTERNS
409
- }
410
-
411
- install_pre_commit_scope_hook() {
412
- local hooks_dir="\$(git -C ${worktree_q} rev-parse --git-path hooks 2>/dev/null || true)"
413
- if [[ -z "\${hooks_dir}" ]]; then
414
- hooks_dir="\$(git -C ${worktree_q} config --get core.hooksPath 2>/dev/null || true)"
415
- fi
416
- if [[ -z "\${hooks_dir}" ]]; then
417
- hooks_dir="${worktree_q}/.git-hooks"
418
- fi
419
- # Resolve relative path against worktree root
420
- if [[ "\${hooks_dir}" != /* ]]; then
421
- hooks_dir="${worktree_q}/\${hooks_dir}"
422
- fi
423
- mkdir -p "\${hooks_dir}"
424
- cat > "\${hooks_dir}/pre-commit" <<'HOOK_EOF'
425
- #!/usr/bin/env bash
426
- # Pre-commit scope guard: reject commits that touch too many product surfaces
427
- set -euo pipefail
428
-
429
- changed_files="\$(
430
- git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true
431
- )"
432
-
433
- if [[ -z "\${changed_files}" ]]; then
434
- exit 0
435
- fi
436
-
437
- # Count non-test product files by surface
438
- api_count=0
439
- web_count=0
440
- mobile_count=0
441
- package_count=0
442
- doc_count=0
443
- other_count=0
444
-
445
- while IFS= read -r file; do
446
- [[ -n "\${file}" ]] || continue
447
- case "\${file}" in
448
- apps/api/*) api_count=\$((api_count + 1)) ;;
449
- apps/web/*) web_count=\$((web_count + 1)) ;;
450
- apps/mobile/*) mobile_count=\$((mobile_count + 1)) ;;
451
- packages/*) package_count=\$((package_count + 1)) ;;
452
- openspec/*|docs/*|*.md) doc_count=\$((doc_count + 1)) ;;
453
- *) other_count=\$((other_count + 1)) ;;
454
- esac
455
- done <<< "\${changed_files}"
456
-
457
- # Count how many product surfaces are touched (excluding docs-only)
458
- surfaces=0
459
- [[ \$api_count -gt 0 ]] && surfaces=\$((surfaces + 1))
460
- [[ \$web_count -gt 0 ]] && surfaces=\$((surfaces + 1))
461
- [[ \$mobile_count -gt 0 ]] && surfaces=\$((surfaces + 1))
462
-
463
- # If touching 3+ product surfaces, warn but allow (scope guard at publish is stricter)
464
- if [[ \$surfaces -ge 3 ]]; then
465
- echo "[pre-commit scope warning] This commit touches \$surfaces product surfaces (api=\$api_count web=\$web_count mobile=\$mobile_count). Consider splitting into focused commits." >&2
466
- fi
467
-
468
- # Hard block: more than 20 non-doc product files in one commit
469
- total_product=\$((api_count + web_count + mobile_count + package_count + other_count))
470
- if [[ \$total_product -gt 20 ]]; then
471
- echo "[pre-commit scope BLOCK] This commit touches \$total_product product files across \$surfaces surfaces. Split into smaller focused commits." >&2
472
- exit 1
473
- fi
474
-
475
- # Hard block: more than 8 mobile product files in one commit
476
- if [[ \$mobile_count -gt 8 ]]; then
477
- echo "[pre-commit scope BLOCK] This commit touches \$mobile_product mobile product files. Keep mobile changes to one focused route family (max 8 files)." >&2
478
- exit 1
479
- fi
480
-
481
- exit 0
482
- HOOK_EOF
483
- chmod +x "\${hooks_dir}/pre-commit"
484
- }
485
-
486
- ensure_runtime_artifact_alias() {
487
- local literal_name="\${1:?literal name required}"
488
- local target_path="\${2:?target path required}"
489
- local alias_path="\${worktree}/\${literal_name}"
490
- local current_target=""
491
-
492
- if [[ -L "\${alias_path}" ]]; then
493
- current_target="\$(readlink "\${alias_path}" 2>/dev/null || true)"
494
- if [[ "\${current_target}" == "\${target_path}" ]]; then
495
- return 0
496
- fi
497
- rm -f "\${alias_path}"
498
- elif [[ -e "\${alias_path}" ]]; then
499
- printf '[openclaw] runtime alias path already exists, leaving untouched: %s\n' "\${alias_path}" >>"\${output_file}" 2>/dev/null || true
500
- return 0
501
- fi
502
-
503
- ln -s "\${target_path}" "\${alias_path}"
504
- }
505
-
506
- ensure_runtime_artifact_aliases() {
507
- ensure_runtime_artifact_alias '\$ACP_RUN_DIR' "\${sandbox_run_dir}"
508
- ensure_runtime_artifact_alias '\$AGENT_PROJECT_RUN_DIR' "\${sandbox_run_dir}"
509
- ensure_runtime_artifact_alias '\$F_LOSNING_RUN_DIR' "\${sandbox_run_dir}"
510
- ensure_runtime_artifact_alias '\$ACP_HOST_RUN_DIR' "\${artifact_dir}"
511
- ensure_runtime_artifact_alias '\$AGENT_PROJECT_HOST_RUN_DIR' "\${artifact_dir}"
512
- ensure_runtime_artifact_alias '\$F_LOSNING_HOST_RUN_DIR' "\${artifact_dir}"
513
- ensure_runtime_artifact_alias '\$ACP_RESULT_FILE' "\${sandbox_run_dir}/result.env"
514
- ensure_runtime_artifact_alias '\$AGENT_PROJECT_RESULT_FILE' "\${sandbox_run_dir}/result.env"
515
- ensure_runtime_artifact_alias '\$F_LOSNING_RESULT_FILE' "\${sandbox_run_dir}/result.env"
516
- }
517
-
518
- cleanup_runtime_artifact_aliases() {
519
- local literal_name alias_path
520
- for literal_name in \
521
- '\$ACP_RUN_DIR' \
522
- '\$AGENT_PROJECT_RUN_DIR' \
523
- '\$F_LOSNING_RUN_DIR' \
524
- '\$ACP_HOST_RUN_DIR' \
525
- '\$AGENT_PROJECT_HOST_RUN_DIR' \
526
- '\$F_LOSNING_HOST_RUN_DIR' \
527
- '\$ACP_RESULT_FILE' \
528
- '\$AGENT_PROJECT_RESULT_FILE' \
529
- '\$F_LOSNING_RESULT_FILE'
530
- do
531
- alias_path="\${worktree}/\${literal_name}"
532
- if [[ -L "\${alias_path}" ]]; then
533
- rm -f "\${alias_path}"
534
- fi
535
- done
536
- }
537
-
538
- recover_literal_runtime_artifacts() {
539
- local literal_name literal_path actual_result_file recovered="no"
540
-
541
- actual_result_file="\${sandbox_run_dir}/result.env"
542
- for literal_name in '\$ACP_RESULT_FILE' '\$AGENT_PROJECT_RESULT_FILE' '\$F_LOSNING_RESULT_FILE'; do
543
- literal_path="\${worktree}/\${literal_name}"
544
- if [[ -f "\${literal_path}" && ! -L "\${literal_path}" ]]; then
545
- cp "\${literal_path}" "\${actual_result_file}" 2>/dev/null || true
546
- cp "\${literal_path}" "\${run_dir}/result.env" 2>/dev/null || true
547
- rm -f "\${literal_path}" 2>/dev/null || true
548
- printf '[openclaw] recovered literal result artifact: %s\n' "\${literal_path}" >>"\${output_file}" 2>/dev/null || true
549
- recovered="yes"
550
- break
551
- fi
552
- done
553
-
554
- for literal_name in '\$ACP_RUN_DIR' '\$AGENT_PROJECT_RUN_DIR' '\$F_LOSNING_RUN_DIR'; do
555
- literal_path="\${worktree}/\${literal_name}"
556
- if [[ -d "\${literal_path}" && ! -L "\${literal_path}" ]]; then
557
- for artifact_name in result.env verification.jsonl issue-comment.md pr-comment.md; do
558
- if [[ -f "\${literal_path}/\${artifact_name}" ]]; then
559
- cp "\${literal_path}/\${artifact_name}" "\${sandbox_run_dir}/\${artifact_name}" 2>/dev/null || true
560
- cp "\${literal_path}/\${artifact_name}" "\${artifact_dir}/\${artifact_name}" 2>/dev/null || true
561
- recovered="yes"
562
- fi
563
- done
564
- rm -rf "\${literal_path}" 2>/dev/null || true
565
- printf '[openclaw] recovered literal run-dir artifact tree: %s\n' "\${literal_path}" >>"\${output_file}" 2>/dev/null || true
566
- fi
567
- done
568
-
569
- [[ "\${recovered}" == "yes" ]] && return 0
570
- return 0
571
- }
572
-
573
- recover_retained_repo_artifact_leaks() {
574
- local retained_worktree_root=""
575
- local leaked_run_dir=""
576
- local worktree_name=""
577
- local session_name=""
578
- local artifact_name=""
579
- local recovered="no"
580
-
581
- [[ -n "\${retained_repo_root}" ]] || return 0
582
- worktree_name="\$(basename "\${worktree}")"
583
- session_name="\${AGENT_PROJECT_SESSION:-}"
584
- [[ -n "\${session_name}" ]] || return 0
585
- retained_worktree_root="\${retained_repo_root%/}/worktrees"
586
- leaked_run_dir="\${retained_worktree_root}/\${worktree_name}/.openclaw-artifacts/\${session_name}"
587
-
588
- if [[ ! -d "\${leaked_run_dir}" || "\${leaked_run_dir}" == "\${sandbox_run_dir}" ]]; then
589
- return 0
590
- fi
591
-
592
- for artifact_name in result.env verification.jsonl issue-comment.md pr-comment.md; do
593
- if [[ -f "\${leaked_run_dir}/\${artifact_name}" ]]; then
594
- cp "\${leaked_run_dir}/\${artifact_name}" "\${sandbox_run_dir}/\${artifact_name}" 2>/dev/null || true
595
- cp "\${leaked_run_dir}/\${artifact_name}" "\${artifact_dir}/\${artifact_name}" 2>/dev/null || true
596
- recovered="yes"
597
- fi
598
- done
599
-
600
- rm -rf "\${leaked_run_dir}" 2>/dev/null || true
601
- rmdir "\${retained_worktree_root}/\${worktree_name}/.openclaw-artifacts" 2>/dev/null || true
602
- rmdir "\${retained_worktree_root}/\${worktree_name}" 2>/dev/null || true
603
- rmdir "\${retained_worktree_root}" 2>/dev/null || true
604
-
605
- if [[ "\${recovered}" == "yes" ]]; then
606
- printf '[openclaw] recovered retained-repo artifact leak: %s\n' "\${leaked_run_dir}" >>"\${output_file}" 2>/dev/null || true
607
- fi
608
-
609
- return 0
610
- }
611
-
612
- reset_sandbox_run_dir() {
613
- mkdir -p "\${sandbox_run_dir}"
614
- find "\${sandbox_run_dir}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
615
- }
616
-
617
- classify_failure_reason() {
618
- if grep -Eiq 'Config was last written by a newer OpenClaw' "\${output_file}" 2>/dev/null; then
619
- printf 'openclaw-version-mismatch\n'
620
- return 0
621
- fi
622
- if grep -Eiq 'invalid api key|authentication failed|unauthorized|provider api key|login required|please authenticate|api_key_invalid' "\${output_file}" 2>/dev/null; then
623
- printf 'auth-failure\n'
624
- return 0
625
- fi
626
- if grep -Eiq 'rate limit exceeded|quota exceeded|usage limit|insufficient credits|payment required|too many requests|429' "\${output_file}" 2>/dev/null; then
627
- printf 'provider-quota-limit\n'
628
- return 0
629
- fi
630
- if grep -Eiq 'model not found|model .* not available|unsupported model|invalid model' "\${output_file}" 2>/dev/null; then
631
- printf 'model-unavailable\n'
632
- return 0
633
- fi
634
- if grep -Eiq 'context length exceeded|token limit|maximum context|too many tokens' "\${output_file}" 2>/dev/null; then
635
- printf 'context-length-exceeded\n'
636
- return 0
637
- fi
638
- if grep -Eiq 'stale-run no-agent-output-before-stall-threshold|no-agent-output-before-stall-threshold' "\${output_file}" 2>/dev/null; then
639
- printf 'no-agent-output-before-stall-threshold\n'
640
- return 0
641
- fi
642
- if grep -Eiq 'stale-run no-agent-progress-before-stall-threshold|no-agent-progress-before-stall-threshold' "\${output_file}" 2>/dev/null; then
643
- printf 'no-agent-progress-before-stall-threshold\n'
644
- return 0
645
- fi
646
- if grep -Eiq 'timeout|timed out|ETIMEDOUT|ECONNREFUSED' "\${output_file}" 2>/dev/null; then
647
- printf 'timeout\n'
648
- return 0
649
- fi
650
- printf 'openclaw-exit-failed\n'
651
- }
652
-
653
- infer_result_from_output() {
654
- local result_file_path="\${sandbox_run_dir}/result.env"
655
- local verification_file="\${sandbox_run_dir}/verification.jsonl"
656
- # Host-side result file (always writable, never inside worktree)
657
- local host_result_file="\${run_dir}/result.env"
658
- local recovered_contract=""
659
- local write_result=''
660
-
661
- write_result() {
662
- printf '%b' "\$1" > "\${result_file_path}" 2>/dev/null || true
663
- printf '%b' "\$1" > "\${host_result_file}" 2>/dev/null || true
664
- }
665
-
666
- recover_result_contract_from_output() {
667
- python3 - "\${output_file}" <<'PY'
668
- import re
669
- import sys
670
-
671
- log_path = sys.argv[1]
672
- try:
673
- raw = open(log_path, "r", encoding="utf-8", errors="replace").read()
674
- except Exception:
675
- raise SystemExit(1)
676
-
677
- matches = re.findall(r"Result file written:\s*([^\r\n]+)", raw, flags=re.IGNORECASE)
678
- if not matches:
679
- raise SystemExit(1)
680
-
681
- line = matches[-1]
682
- fields = {}
683
- for key in ("OUTCOME", "ACTION", "DETAIL", "ISSUE_ID"):
684
- match = re.search(rf"{key}=([A-Za-z0-9._/-]+)", line)
685
- if match:
686
- fields[key] = match.group(1).strip()
687
-
688
- if "OUTCOME" not in fields or "ACTION" not in fields:
689
- raise SystemExit(1)
690
-
691
- for key in ("OUTCOME", "ACTION", "DETAIL", "ISSUE_ID"):
692
- value = fields.get(key)
693
- if value:
694
- print(f"{key}={value}")
695
- PY
696
- }
697
-
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.
705
- if [[ -f "\${result_file_path}" ]]; then
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
708
- fi
709
- return 0
710
- fi
711
-
712
- if grep -Fq '[tools] exec failed: Provide a command to start.' "\${output_file}" 2>/dev/null; then
713
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=worker-tool-exec-empty-command\n'
714
- return 0
715
- fi
716
-
717
- recovered_contract="\$(recover_result_contract_from_output 2>/dev/null || true)"
718
- if [[ -n "\${recovered_contract}" ]]; then
719
- write_result "\${recovered_contract}"$'\n'
720
- return 0
721
- fi
722
-
723
- # Check if there are actual code changes (not just artifact files or docs)
724
- local has_product_changes="no"
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
726
- has_product_changes="yes"
727
- fi
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
729
- has_product_changes="yes"
730
- fi
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
732
- has_product_changes="yes"
733
- fi
734
-
735
- # If no product changes and output suggests nothing to do, report no-changes
736
- if [[ "\${has_product_changes}" == "no" ]] && grep -Eiq 'already done|no changes needed|up to date|nothing to do|no changes' "\${output_file}" 2>/dev/null; then
737
- write_result 'OUTCOME=reported\nACTION=host-comment-scheduled-report\nDETAIL=no-changes-needed\n'
738
- return 0
739
- fi
740
-
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'
744
- return 0
745
- fi
746
-
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
752
- write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
753
- return 0
754
- fi
755
-
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
760
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
761
- return 0
762
- fi
763
-
764
- # If output suggests implemented, mark as implemented and let reconcile verify
765
- if grep -Eiq 'created PR|opened PR|PR #|pull request|implemented' "\${output_file}" 2>/dev/null; then
766
- write_result 'OUTCOME=implemented\nACTION=host-publish-issue-pr\n'
767
- return 0
768
- fi
769
-
770
- # Default fallback: block (safe default)
771
- write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
772
- }
773
-
774
- synthesize_comment_artifact_from_output() {
775
- local target_file=""
776
- local result_file_path="\${sandbox_run_dir}/result.env"
777
-
778
- if [[ ! -f "\${result_file_path}" ]] || ! grep -Eq '^ACTION=host-comment-' "\${result_file_path}" 2>/dev/null; then
779
- return 0
780
- fi
781
-
782
- case "\${task_kind}" in
783
- issue|task)
784
- target_file="\${sandbox_run_dir}/issue-comment.md"
785
- ;;
786
- pr)
787
- target_file="\${sandbox_run_dir}/pr-comment.md"
788
- ;;
789
- *)
790
- return 0
791
- ;;
792
- esac
793
-
794
- [[ -n "\${target_file}" ]] || return 0
795
- [[ ! -f "\${target_file}" ]] || return 0
796
-
797
- python3 - "\${output_file}" "\${target_file}" <<'PY2'
798
- import json
799
- import os
800
- import sys
801
-
802
- log_path, target_path = sys.argv[1:3]
803
-
804
- try:
805
- raw = open(log_path, 'r', encoding='utf-8', errors='replace').read()
806
- except Exception:
807
- raise SystemExit(0)
808
-
809
- decoder = json.JSONDecoder()
810
- message = ''
811
- idx = 0
812
- while idx < len(raw):
813
- start = raw.find('{', idx)
814
- if start == -1:
815
- break
816
- try:
817
- payload, end = decoder.raw_decode(raw, start)
818
- except Exception:
819
- idx = start + 1
820
- continue
821
- idx = end
822
- if not isinstance(payload, dict):
823
- continue
824
- payloads = payload.get('payloads')
825
- if not isinstance(payloads, list):
826
- continue
827
- parts = []
828
- for item in payloads:
829
- if not isinstance(item, dict):
830
- continue
831
- value = item.get('text')
832
- if isinstance(value, str) and value.strip():
833
- parts.append(value.rstrip())
834
- if parts:
835
- message = '\n\n'.join(parts).strip()
836
-
837
- if not message:
838
- raise SystemExit(0)
839
-
840
- os.makedirs(os.path.dirname(target_path), exist_ok=True)
841
- with open(target_path, 'w', encoding='utf-8') as handle:
842
- handle.write(message)
843
- handle.write('\n')
844
- PY2
845
- }
846
-
847
- cleanup_agent() {
848
- # --force required for non-interactive (tmux) sessions, otherwise delete waits for confirmation
849
- "\${openclaw_bin}" agents delete "\${openclaw_agent_id}" --json --force >/dev/null 2>&1 || true
850
- }
851
-
852
- openclaw_agent_exists() {
853
- local agents_json=""
854
-
855
- agents_json="\$("\${openclaw_bin}" agents list --json 2>/dev/null || true)"
856
- [[ -n "\${agents_json}" ]] || return 1
857
-
858
- OPENCLAW_AGENTS_JSON="\${agents_json}" python3 - "\${openclaw_agent_id}" <<'PY'
859
- import json
860
- import os
861
- import sys
862
-
863
- agent_id = sys.argv[1]
864
-
865
- try:
866
- payload = json.loads(os.environ.get("OPENCLAW_AGENTS_JSON", ""))
867
- except Exception:
868
- raise SystemExit(1)
869
-
870
- agents = payload if isinstance(payload, list) else payload.get("agents", [])
871
- for agent in agents:
872
- if not isinstance(agent, dict):
873
- continue
874
- if str(agent.get("id", "")) == agent_id or str(agent.get("name", "")) == agent_id:
875
- raise SystemExit(0)
876
-
877
- raise SystemExit(1)
878
- PY
879
- }
880
-
881
- sync_openclaw_agent_config() {
882
- mkdir -p "\$(dirname "\${openclaw_config_path}")"
883
-
884
- python3 - "\${openclaw_config_path}" "\${openclaw_agent_id}" "\${worktree}" "\${openclaw_agent_dir}" "\${openclaw_model}" <<'PY'
885
- import json
886
- import os
887
- import sys
888
-
889
- config_path, agent_id, workspace, agent_dir, model = sys.argv[1:6]
890
-
891
- payload = {}
892
- if os.path.exists(config_path):
893
- try:
894
- with open(config_path, "r", encoding="utf-8") as handle:
895
- loaded = json.load(handle)
896
- if isinstance(loaded, dict):
897
- payload = loaded
898
- except Exception:
899
- payload = {}
900
-
901
- agents = payload.get("agents")
902
- if not isinstance(agents, dict):
903
- agents = {}
904
- payload["agents"] = agents
905
-
906
- agent_list = agents.get("list")
907
- if not isinstance(agent_list, list):
908
- agent_list = []
909
- agents["list"] = agent_list
910
-
911
- entry = None
912
- for candidate in agent_list:
913
- if not isinstance(candidate, dict):
914
- continue
915
- candidate_id = str(candidate.get("id", ""))
916
- candidate_name = str(candidate.get("name", ""))
917
- if candidate_id == agent_id or candidate_name == agent_id:
918
- entry = candidate
919
- break
920
-
921
- if entry is None:
922
- entry = {"id": agent_id}
923
- agent_list.append(entry)
924
-
925
- entry["id"] = agent_id
926
- entry["name"] = agent_id
927
- entry["workspace"] = workspace
928
- entry["agentDir"] = agent_dir
929
- if model:
930
- entry["model"] = model
931
-
932
- tmp_path = f"{config_path}.tmp.{os.getpid()}"
933
- with open(tmp_path, "w", encoding="utf-8") as handle:
934
- json.dump(payload, handle, indent=2)
935
- handle.write("\n")
936
- os.replace(tmp_path, config_path)
937
- PY
938
- }
939
-
940
- run_openclaw_agent_command() {
941
- python3 - "\${output_file}" "\${runner_state_file}" "\${openclaw_timeout}" "\${openclaw_stall_seconds}" "\${openclaw_progress_heartbeat_seconds}" "\${openclaw_bin}" "\${openclaw_agent_id}" "\${openclaw_session_id}" "\${openclaw_thinking}" "\${prompt_file_path}" <<'PY'
942
- import os
943
- import re
944
- import selectors
945
- import signal
946
- import subprocess
947
- import sys
948
- import time
949
-
950
- output_path, runner_state_path, timeout_seconds_raw, stall_seconds_raw, heartbeat_seconds_raw, openclaw_bin, agent_id, session_id, thinking, prompt_path = sys.argv[1:11]
951
-
952
- with open(prompt_path, "r", encoding="utf-8") as handle:
953
- prompt = handle.read()
954
-
955
- cmd = [
956
- openclaw_bin,
957
- "agent",
958
- "--agent",
959
- agent_id,
960
- "--session-id",
961
- session_id,
962
- "--local",
963
- "--json",
964
- "--timeout",
965
- timeout_seconds_raw,
966
- "--thinking",
967
- thinking,
968
- "--message",
969
- prompt,
970
- ]
971
-
972
- timeout_seconds = float(timeout_seconds_raw)
973
- stall_seconds = float(stall_seconds_raw)
974
- heartbeat_seconds = max(float(heartbeat_seconds_raw), 1.0)
975
- hard_deadline = time.monotonic() + timeout_seconds + 15.0
976
- started_at = time.monotonic()
977
- next_heartbeat = time.monotonic() + heartbeat_seconds
978
- seen_agent_progress = False
979
- last_agent_progress_at = started_at
980
- last_progress_source = "none"
981
- terminal_patterns = [
982
- re.compile(r"Config was last written by a newer OpenClaw", re.I),
983
- re.compile(r"invalid api key|authentication failed|unauthorized|provider api key|login required|please authenticate|api_key_invalid", re.I),
984
- re.compile(r"rate limit exceeded|quota exceeded|usage limit|insufficient credits|payment required|too many requests|429", re.I),
985
- re.compile(r"model not found|model .* not available|unsupported model|invalid model", re.I),
986
- re.compile(r"context length exceeded|token limit|maximum context|too many tokens", re.I),
987
- ]
988
-
989
- proc = None
990
- sel = selectors.DefaultSelector()
991
- matched_terminal_error = False
992
- tail = ""
993
- openclaw_state_dir = os.environ.get("OPENCLAW_STATE_DIR", "")
994
- sandbox_run_dir = (
995
- os.environ.get("ACP_RUN_DIR")
996
- or os.environ.get("AGENT_PROJECT_RUN_DIR")
997
- or os.environ.get("F_LOSNING_RUN_DIR")
998
- or ""
999
- )
1000
- host_managed_prefixes = tuple(
1001
- prefix
1002
- for prefix in (
1003
- os.path.realpath(runner_state_path) if runner_state_path else "",
1004
- os.path.realpath(output_path) if output_path else "",
1005
- )
1006
- if prefix
1007
- )
1008
-
1009
- def write_running_heartbeat() -> None:
1010
- updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
1011
- tmp_path = f"{runner_state_path}.tmp.{os.getpid()}"
1012
- with open(tmp_path, "w", encoding="utf-8") as handle:
1013
- handle.write("RUNNER_STATE=running\n")
1014
- handle.write(f"THREAD_ID={sh_quote(session_id)}\n")
1015
- handle.write("ATTEMPT=1\n")
1016
- handle.write("RESUME_COUNT=0\n")
1017
- handle.write("LAST_EXIT_CODE=''\n")
1018
- handle.write("LAST_FAILURE_REASON=''\n")
1019
- handle.write("LAST_TRIGGER_REASON=''\n")
1020
- handle.write("AUTH_WAIT_STARTED_AT=''\n")
1021
- handle.write("LAST_AUTH_FINGERPRINT=''\n")
1022
- handle.write(f"UPDATED_AT={sh_quote(updated_at)}\n")
1023
- os.replace(tmp_path, runner_state_path)
1024
-
1025
- def sh_quote(value: str) -> str:
1026
- return "'" + value.replace("'", "'\"'\"'") + "'"
1027
-
1028
- def terminate_process_group(process: subprocess.Popen) -> None:
1029
- try:
1030
- os.killpg(process.pid, signal.SIGTERM)
1031
- except ProcessLookupError:
1032
- return
1033
- deadline = time.monotonic() + 2.0
1034
- while time.monotonic() < deadline:
1035
- if process.poll() is not None:
1036
- return
1037
- time.sleep(0.1)
1038
- try:
1039
- os.killpg(process.pid, signal.SIGKILL)
1040
- except ProcessLookupError:
1041
- return
1042
-
1043
- def progress_signature() -> tuple[tuple[str, int, int], ...]:
1044
- entries: list[tuple[str, int, int]] = []
1045
-
1046
- def add_file(path: str) -> None:
1047
- real_path = ""
1048
- if not path:
1049
- return
1050
- try:
1051
- stat_result = os.stat(path)
1052
- except OSError:
1053
- return
1054
- if not os.path.isfile(path):
1055
- return
1056
- real_path = os.path.realpath(path)
1057
- for prefix in host_managed_prefixes:
1058
- if real_path == prefix or real_path.startswith(f"{prefix}.tmp."):
1059
- return
1060
- entries.append((real_path, stat_result.st_mtime_ns, stat_result.st_size))
1061
-
1062
- if sandbox_run_dir:
1063
- try:
1064
- for name in os.listdir(sandbox_run_dir):
1065
- add_file(os.path.join(sandbox_run_dir, name))
1066
- except OSError:
1067
- pass
1068
-
1069
- if openclaw_state_dir:
1070
- sessions_dir = os.path.join(openclaw_state_dir, "agents", agent_id, "sessions")
1071
- add_file(os.path.join(sessions_dir, "sessions.json"))
1072
- try:
1073
- for name in os.listdir(sessions_dir):
1074
- if name.endswith(".jsonl") and not name.endswith(".lock"):
1075
- add_file(os.path.join(sessions_dir, name))
1076
- except OSError:
1077
- pass
1078
-
1079
- entries.sort()
1080
- return tuple(entries)
1081
-
1082
- last_progress_signature = progress_signature()
1083
-
1084
- with open(output_path, "ab", buffering=0) as log_handle:
1085
- proc = subprocess.Popen(
1086
- cmd,
1087
- stdin=subprocess.PIPE,
1088
- stdout=subprocess.PIPE,
1089
- stderr=subprocess.STDOUT,
1090
- start_new_session=True,
1091
- )
1092
- if proc.stdin is not None:
1093
- proc.stdin.write(prompt.encode("utf-8"))
1094
- proc.stdin.close()
1095
-
1096
- if proc.stdout is None:
1097
- raise SystemExit(1)
1098
-
1099
- sel.register(proc.stdout, selectors.EVENT_READ)
1100
-
1101
- while True:
1102
- if time.monotonic() >= hard_deadline:
1103
- terminate_process_group(proc)
1104
- break
1105
-
1106
- current_progress_signature = progress_signature()
1107
- if current_progress_signature != last_progress_signature:
1108
- last_progress_signature = current_progress_signature
1109
- seen_agent_progress = True
1110
- last_agent_progress_at = time.monotonic()
1111
- last_progress_source = "session-state"
1112
- next_heartbeat = time.monotonic() + heartbeat_seconds
1113
-
1114
- events = sel.select(timeout=0.2)
1115
- if not events:
1116
- if proc.poll() is None and not seen_agent_progress and stall_seconds > 0 and (time.monotonic() - started_at) >= stall_seconds:
1117
- elapsed = int(time.monotonic() - started_at)
1118
- write_running_heartbeat()
1119
- log_handle.write(f"[openclaw] stale-run no-agent-output-before-stall-threshold elapsed={elapsed}s\n".encode("utf-8"))
1120
- terminate_process_group(proc)
1121
- break
1122
- if proc.poll() is None and seen_agent_progress and stall_seconds > 0 and (time.monotonic() - last_agent_progress_at) >= stall_seconds:
1123
- elapsed = int(time.monotonic() - started_at)
1124
- idle_for = int(time.monotonic() - last_agent_progress_at)
1125
- write_running_heartbeat()
1126
- log_handle.write(f"[openclaw] stale-run no-agent-progress-before-stall-threshold elapsed={elapsed}s idle={idle_for}s\n".encode("utf-8"))
1127
- terminate_process_group(proc)
1128
- break
1129
- if proc.poll() is None and not seen_agent_progress and time.monotonic() >= next_heartbeat:
1130
- elapsed = int(time.monotonic() - started_at)
1131
- write_running_heartbeat()
1132
- log_handle.write(f"[openclaw] heartbeat waiting-for-agent-output elapsed={elapsed}s\n".encode("utf-8"))
1133
- next_heartbeat = time.monotonic() + heartbeat_seconds
1134
- if proc.poll() is None and seen_agent_progress and time.monotonic() >= next_heartbeat:
1135
- elapsed = int(time.monotonic() - started_at)
1136
- idle_for = int(time.monotonic() - last_agent_progress_at)
1137
- write_running_heartbeat()
1138
- log_handle.write(
1139
- f"[openclaw] heartbeat progress source={last_progress_source} elapsed={elapsed}s idle={idle_for}s\n".encode("utf-8")
1140
- )
1141
- next_heartbeat = time.monotonic() + heartbeat_seconds
1142
- if proc.poll() is not None:
1143
- break
1144
- continue
1145
-
1146
- for key, _ in events:
1147
- chunk = os.read(key.fd, 4096)
1148
- if not chunk:
1149
- continue
1150
-
1151
- log_handle.write(chunk)
1152
- text = chunk.decode("utf-8", errors="replace")
1153
- tail = (tail + text)[-8192:]
1154
- next_heartbeat = time.monotonic() + heartbeat_seconds
1155
- seen_agent_progress = True
1156
- last_agent_progress_at = time.monotonic()
1157
- last_progress_source = "stdout"
1158
-
1159
- if not matched_terminal_error and any(pattern.search(tail) for pattern in terminal_patterns):
1160
- matched_terminal_error = True
1161
- terminate_process_group(proc)
1162
-
1163
- if proc.poll() is not None:
1164
- break
1165
-
1166
- while True:
1167
- if proc.stdout is None:
1168
- break
1169
- chunk = os.read(proc.stdout.fileno(), 4096)
1170
- if not chunk:
1171
- break
1172
- log_handle.write(chunk)
1173
-
1174
- return_code = proc.wait()
1175
- if matched_terminal_error and return_code == 0:
1176
- raise SystemExit(1)
1177
- raise SystemExit(return_code)
1178
- PY
1179
- }
1180
-
1181
- ensure_openclaw_auth_profiles() {
1182
- if [[ ! -s "\${openclaw_agent_dir}/auth-profiles.json" && -n "\${OPENROUTER_API_KEY:-}" ]]; then
1183
- mkdir -p "\$(dirname "\${openclaw_agent_dir}/auth-profiles.json" 2>/dev/null || true)"
1184
- cat > "\${openclaw_agent_dir}/auth-profiles.json" <<AUTH_EOF
1185
- {
1186
- "version": 1,
1187
- "profiles": {
1188
- "openrouter:default": {
1189
- "type": "api_key",
1190
- "provider": "openrouter",
1191
- "keyRef": {
1192
- "source": "direct",
1193
- "provider": "default",
1194
- "id": "OPENROUTER_API_KEY",
1195
- "secret": "\${OPENROUTER_API_KEY}"
1196
- }
1197
- }
1198
- }
1199
- }
1200
- AUTH_EOF
1201
- fi
1202
-
1203
- if [[ ! -s "\${openclaw_agent_dir}/auth-profiles.json" && -z "\${OPENROUTER_API_KEY:-}" && -f "\${openclaw_agent_dir}/../auth-profiles.json" ]]; then
1204
- cp "\${openclaw_agent_dir}/../auth-profiles.json" "\${openclaw_agent_dir}/auth-profiles.json" 2>/dev/null || true
1205
- fi
1206
- }
1207
-
1208
- reset_openclaw_resident_state() {
1209
- rm -rf "\${openclaw_state_dir}" "\${openclaw_agent_dir}" "\$(dirname "\${openclaw_config_path}")"
1210
- mkdir -p "\${openclaw_state_dir}" "\$(dirname "\${openclaw_config_path}")" "\${openclaw_agent_dir}"
1211
- ensure_openclaw_auth_profiles
1212
- }
1213
-
1214
- mkdir -p "\${sandbox_run_dir}" "\${artifact_dir}" "\${openclaw_state_dir}" "\$(dirname "\${openclaw_config_path}")" "\${openclaw_agent_dir}"
1215
- reset_sandbox_run_dir
1216
- ensure_openclaw_workspace_excludes
1217
- install_pre_commit_scope_hook
1218
- ensure_runtime_artifact_aliases
1219
- trap cleanup_runtime_artifact_aliases EXIT
1220
- write_state running "" ""
1221
-
1222
- ensure_openclaw_auth_profiles
1223
-
1224
- # Export API key as env var for --local mode (required alongside auth-profiles.json)
1225
- export OPENROUTER_API_KEY="\${OPENROUTER_API_KEY:-}"
1226
-
1227
- prompt_content="\$(cat ${prompt_q})"
1228
- set +e
1229
- status=0
1230
- failure_reason=""
1231
- resident_reset_attempted="no"
1232
- while true; do
1233
- status=0
1234
- if [[ "\${keep_agent}" != "true" ]] || ! openclaw_agent_exists; then
1235
- : >"\${openclaw_add_log}"
1236
- if "\${openclaw_bin}" agents add "\${openclaw_agent_id}" \
1237
- --model "\${openclaw_model}" \\
1238
- --workspace ${worktree_q} \\
1239
- --agent-dir "\${openclaw_agent_dir}" \\
1240
- --non-interactive --json >"\${openclaw_add_log}" 2>&1; then
1241
- status=0
1242
- else
1243
- status=\$?
1244
- if grep -Eiq 'already exists' "\${openclaw_add_log}" 2>/dev/null; then
1245
- printf '[openclaw] reusing existing agent after add race: %s\n' "\${openclaw_agent_id}" >>"\${output_file}" 2>/dev/null || true
1246
- status=0
1247
- fi
1248
- fi
1249
- cat "\${openclaw_add_log}" >>"\${output_file}" 2>/dev/null || true
1250
- fi
1251
- if [[ "\${status}" -eq 0 ]]; then
1252
- if sync_openclaw_agent_config; then
1253
- :
1254
- else
1255
- status=1
1256
- failure_reason="openclaw-config-sync-failed"
1257
- printf '[openclaw] failed to sync agent config for %s\n' "\${openclaw_agent_id}" >>"\${output_file}" 2>/dev/null || true
1258
- fi
1259
- fi
1260
- if [[ "\${status}" -eq 0 ]]; then
1261
- run_openclaw_agent_command
1262
- status=\$?
1263
- fi
1264
- if [[ "\${status}" -eq 0 ]]; then
1265
- break
1266
- fi
1267
-
1268
- failure_reason="\$(classify_failure_reason)"
1269
- if [[ "\${keep_agent}" == "true" && "\${resident_reset_attempted}" != "yes" && "\${failure_reason}" == "openclaw-version-mismatch" ]]; then
1270
- resident_reset_attempted="yes"
1271
- reset_openclaw_resident_state
1272
- continue
1273
- fi
1274
- break
1275
- done
1276
- recover_literal_runtime_artifacts
1277
- recover_retained_repo_artifact_leaks
1278
- infer_result_from_output
1279
- synthesize_comment_artifact_from_output
1280
- if [[ "\${status}" -eq 0 ]]; then
1281
- write_state succeeded "0" ""
1282
- else
1283
- if [[ -z "\${failure_reason}" ]]; then
1284
- failure_reason="\$(classify_failure_reason)"
1285
- fi
1286
- write_state failed "\${status}" "\${failure_reason}"
1287
- fi
1288
- record_final_git_state
1289
- if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
1290
- cp ${sandbox_run_dir_q}/result.env ${result_q}
1291
- fi
1292
- if [[ "\${keep_agent}" != "true" ]]; then
1293
- cleanup_agent
1294
- fi
1295
- ${collect_copy_snippet}${reconcile_snippet}
1296
- printf '\n__CODEX_EXIT__:%s\n' "\${status}" | tee -a ${output_q}
1297
- exit "\${status}"
1298
- EOF
1299
-
1300
- chmod +x "$inner_script"
1301
- tmux new-session -d -s "$session" "$inner_script"
1302
-
1303
- printf 'SESSION=%s\n' "$session"
1304
- printf 'TASK_KIND=%s\n' "$task_kind"
1305
- printf 'TASK_ID=%s\n' "$task_id"
1306
- printf 'WORKTREE=%s\n' "$worktree"
1307
- printf 'OUTPUT=%s\n' "$output_file"
1308
- printf 'SCRIPT=%s\n' "$inner_script"
1309
- printf 'META=%s\n' "$meta_file"