agent-control-plane 0.1.8 → 0.1.12
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/bin/pr-risk.sh +54 -10
- package/hooks/heartbeat-hooks.sh +166 -13
- package/package.json +8 -2
- package/references/commands.md +1 -0
- package/tools/bin/agent-project-cleanup-session +143 -2
- package/tools/bin/agent-project-heartbeat-loop +29 -2
- package/tools/bin/agent-project-publish-issue-pr +178 -62
- package/tools/bin/agent-project-reconcile-issue-session +230 -5
- package/tools/bin/agent-project-reconcile-pr-session +104 -13
- package/tools/bin/agent-project-run-claude-session +19 -1
- package/tools/bin/agent-project-run-codex-resilient +121 -16
- package/tools/bin/agent-project-run-codex-session +61 -11
- package/tools/bin/agent-project-run-openclaw-session +274 -7
- package/tools/bin/agent-project-sync-anchor-repo +13 -2
- package/tools/bin/agent-project-worker-status +19 -14
- package/tools/bin/cleanup-worktree.sh +4 -1
- package/tools/bin/dashboard-launchd-bootstrap.sh +16 -4
- package/tools/bin/ensure-runtime-sync.sh +182 -0
- package/tools/bin/flow-config-lib.sh +76 -30
- package/tools/bin/flow-resident-worker-lib.sh +28 -2
- package/tools/bin/flow-shell-lib.sh +28 -8
- package/tools/bin/heartbeat-safe-auto.sh +32 -0
- package/tools/bin/issue-publish-localization-guard.sh +142 -0
- package/tools/bin/prepare-worktree.sh +3 -1
- package/tools/bin/project-launchd-bootstrap.sh +17 -4
- package/tools/bin/project-runtime-supervisor.sh +7 -1
- package/tools/bin/project-runtimectl.sh +78 -15
- package/tools/bin/provider-cooldown-state.sh +1 -1
- package/tools/bin/render-flow-config.sh +16 -1
- package/tools/bin/reuse-issue-worktree.sh +46 -0
- package/tools/bin/run-codex-task.sh +2 -2
- package/tools/bin/scaffold-profile.sh +2 -2
- package/tools/bin/start-issue-worker.sh +118 -16
- package/tools/bin/start-resident-issue-loop.sh +1 -0
- package/tools/bin/sync-shared-agent-home.sh +26 -0
- package/tools/bin/test-smoke.sh +6 -1
- package/tools/dashboard/app.js +91 -3
- package/tools/dashboard/dashboard_snapshot.py +119 -0
- package/tools/dashboard/styles.css +43 -0
- package/tools/templates/issue-prompt-template.md +18 -66
- package/tools/templates/legacy/issue-prompt-template-pre-slim.md +109 -0
- package/bin/audit-issue-routing.sh +0 -74
- package/tools/bin/audit-agent-worktrees.sh +0 -310
- package/tools/bin/audit-issue-routing.sh +0 -11
- package/tools/bin/audit-retained-layout.sh +0 -58
- package/tools/bin/audit-retained-overlap.sh +0 -135
- package/tools/bin/audit-retained-worktrees.sh +0 -228
- package/tools/bin/check-skill-contracts.sh +0 -324
|
@@ -18,6 +18,7 @@ Options:
|
|
|
18
18
|
--openclaw-model <id> Model id for the isolated OpenClaw agent
|
|
19
19
|
--openclaw-thinking <level> OpenClaw thinking level
|
|
20
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)
|
|
21
22
|
--help Show this help
|
|
22
23
|
EOF
|
|
23
24
|
}
|
|
@@ -34,9 +35,11 @@ env_prefix=""
|
|
|
34
35
|
sandbox_subdir=".openclaw-artifacts"
|
|
35
36
|
reconcile_command=""
|
|
36
37
|
keep_agent="false"
|
|
37
|
-
openclaw_model="${ACP_OPENCLAW_MODEL:-${F_LOSNING_OPENCLAW_MODEL:-openrouter/
|
|
38
|
-
openclaw_thinking="${ACP_OPENCLAW_THINKING:-${F_LOSNING_OPENCLAW_THINKING:-
|
|
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}}"
|
|
39
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}}"
|
|
40
43
|
provided_openclaw_agent_id=""
|
|
41
44
|
provided_openclaw_session_id=""
|
|
42
45
|
provided_openclaw_agent_dir=""
|
|
@@ -64,6 +67,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
64
67
|
--openclaw-model) openclaw_model="${2:-}"; shift 2 ;;
|
|
65
68
|
--openclaw-thinking) openclaw_thinking="${2:-}"; shift 2 ;;
|
|
66
69
|
--openclaw-timeout-seconds) openclaw_timeout_seconds="${2:-}"; shift 2 ;;
|
|
70
|
+
--openclaw-stall-seconds) openclaw_stall_seconds="${2:-}"; shift 2 ;;
|
|
67
71
|
--openclaw-agent-id) provided_openclaw_agent_id="${2:-}"; shift 2 ;;
|
|
68
72
|
--openclaw-session-id) provided_openclaw_session_id="${2:-}"; shift 2 ;;
|
|
69
73
|
--openclaw-agent-dir) provided_openclaw_agent_dir="${2:-}"; shift 2 ;;
|
|
@@ -90,6 +94,13 @@ esac
|
|
|
90
94
|
case "$openclaw_timeout_seconds" in
|
|
91
95
|
''|*[!0-9]*) echo "--openclaw-timeout-seconds must be numeric" >&2; exit 1 ;;
|
|
92
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
|
|
93
104
|
|
|
94
105
|
if ! command -v openclaw >/dev/null 2>&1; then
|
|
95
106
|
echo "unable to resolve a runnable openclaw binary" >&2
|
|
@@ -154,6 +165,8 @@ printf -v openclaw_session_id_q '%q' "$openclaw_session_id"
|
|
|
154
165
|
printf -v openclaw_model_q '%q' "$openclaw_model"
|
|
155
166
|
printf -v openclaw_thinking_q '%q' "$openclaw_thinking"
|
|
156
167
|
printf -v openclaw_timeout_q '%q' "$openclaw_timeout_seconds"
|
|
168
|
+
printf -v openclaw_stall_q '%q' "$openclaw_stall_seconds"
|
|
169
|
+
printf -v openclaw_progress_heartbeat_q '%q' "$openclaw_progress_heartbeat_seconds"
|
|
157
170
|
printf -v keep_agent_q '%q' "$keep_agent"
|
|
158
171
|
|
|
159
172
|
{
|
|
@@ -180,6 +193,8 @@ printf -v keep_agent_q '%q' "$keep_agent"
|
|
|
180
193
|
printf 'OPENCLAW_MODEL=%s\n' "$openclaw_model_q"
|
|
181
194
|
printf 'OPENCLAW_THINKING=%s\n' "$openclaw_thinking_q"
|
|
182
195
|
printf 'OPENCLAW_TIMEOUT_SECONDS=%s\n' "$openclaw_timeout_q"
|
|
196
|
+
printf 'OPENCLAW_STALL_SECONDS=%s\n' "$openclaw_stall_q"
|
|
197
|
+
printf 'OPENCLAW_PROGRESS_HEARTBEAT_SECONDS=%s\n' "$openclaw_progress_heartbeat_q"
|
|
183
198
|
printf 'OPENCLAW_KEEP_AGENT=%s\n' "$keep_agent_q"
|
|
184
199
|
} >"$meta_file"
|
|
185
200
|
|
|
@@ -265,7 +280,7 @@ fi
|
|
|
265
280
|
|
|
266
281
|
reconcile_snippet=""
|
|
267
282
|
if [[ -n "$reconcile_command" ]]; then
|
|
268
|
-
printf -v delayed_reconcile_q '%q' "sleep 2; $reconcile_command"
|
|
283
|
+
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"
|
|
269
284
|
reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
|
|
270
285
|
fi
|
|
271
286
|
|
|
@@ -281,6 +296,7 @@ sandbox_artifact_dir=${sandbox_artifact_dir_q}
|
|
|
281
296
|
sandbox_run_dir=${sandbox_run_dir_q}
|
|
282
297
|
artifact_dir=${artifact_dir_q}
|
|
283
298
|
run_dir=${artifact_dir_q}
|
|
299
|
+
task_kind=${task_kind_q}
|
|
284
300
|
worktree=${worktree_q}
|
|
285
301
|
prompt_file_path=${prompt_q}
|
|
286
302
|
openclaw_state_dir=${openclaw_state_dir_q}
|
|
@@ -291,6 +307,8 @@ openclaw_session_id=${openclaw_session_id_q}
|
|
|
291
307
|
openclaw_model=${openclaw_model_q}
|
|
292
308
|
openclaw_bin=${openclaw_bin_q}
|
|
293
309
|
openclaw_timeout=${openclaw_timeout_q}
|
|
310
|
+
openclaw_stall_seconds=${openclaw_stall_q}
|
|
311
|
+
openclaw_progress_heartbeat_seconds=${openclaw_progress_heartbeat_q}
|
|
294
312
|
openclaw_thinking=${openclaw_thinking_q}
|
|
295
313
|
keep_agent=${keep_agent_q}
|
|
296
314
|
openclaw_add_log="\${sandbox_run_dir}/openclaw-agents-add.log"
|
|
@@ -334,6 +352,9 @@ record_final_git_state() {
|
|
|
334
352
|
|
|
335
353
|
ensure_openclaw_workspace_excludes() {
|
|
336
354
|
local exclude_file line
|
|
355
|
+
if ! git -C ${worktree_q} rev-parse --git-dir >/dev/null 2>&1; then
|
|
356
|
+
return 0
|
|
357
|
+
fi
|
|
337
358
|
exclude_file="\$(git -C ${worktree_q} config --worktree --get core.excludesFile 2>/dev/null || true)"
|
|
338
359
|
if [[ -z "\${exclude_file}" ]]; then
|
|
339
360
|
exclude_file="\${sandbox_artifact_dir}/git-exclude"
|
|
@@ -357,6 +378,7 @@ IDENTITY.md
|
|
|
357
378
|
USER.md
|
|
358
379
|
HEARTBEAT.md
|
|
359
380
|
BOOTSTRAP.md
|
|
381
|
+
AGENTS.md
|
|
360
382
|
.agent-session.env
|
|
361
383
|
\$ACP_RUN_DIR
|
|
362
384
|
\$AGENT_PROJECT_RUN_DIR
|
|
@@ -558,6 +580,14 @@ classify_failure_reason() {
|
|
|
558
580
|
printf 'context-length-exceeded\n'
|
|
559
581
|
return 0
|
|
560
582
|
fi
|
|
583
|
+
if grep -Eiq 'stale-run no-agent-output-before-stall-threshold|no-agent-output-before-stall-threshold' "\${output_file}" 2>/dev/null; then
|
|
584
|
+
printf 'no-agent-output-before-stall-threshold\n'
|
|
585
|
+
return 0
|
|
586
|
+
fi
|
|
587
|
+
if grep -Eiq 'stale-run no-agent-progress-before-stall-threshold|no-agent-progress-before-stall-threshold' "\${output_file}" 2>/dev/null; then
|
|
588
|
+
printf 'no-agent-progress-before-stall-threshold\n'
|
|
589
|
+
return 0
|
|
590
|
+
fi
|
|
561
591
|
if grep -Eiq 'timeout|timed out|ETIMEDOUT|ECONNREFUSED' "\${output_file}" 2>/dev/null; then
|
|
562
592
|
printf 'timeout\n'
|
|
563
593
|
return 0
|
|
@@ -570,6 +600,7 @@ infer_result_from_output() {
|
|
|
570
600
|
local verification_file="\${sandbox_run_dir}/verification.jsonl"
|
|
571
601
|
# Host-side result file (always writable, never inside worktree)
|
|
572
602
|
local host_result_file="\${run_dir}/result.env"
|
|
603
|
+
local recovered_contract=""
|
|
573
604
|
local write_result=''
|
|
574
605
|
|
|
575
606
|
write_result() {
|
|
@@ -577,6 +608,38 @@ infer_result_from_output() {
|
|
|
577
608
|
printf '%b' "\$1" > "\${host_result_file}" 2>/dev/null || true
|
|
578
609
|
}
|
|
579
610
|
|
|
611
|
+
recover_result_contract_from_output() {
|
|
612
|
+
python3 - "\${output_file}" <<'PY'
|
|
613
|
+
import re
|
|
614
|
+
import sys
|
|
615
|
+
|
|
616
|
+
log_path = sys.argv[1]
|
|
617
|
+
try:
|
|
618
|
+
raw = open(log_path, "r", encoding="utf-8", errors="replace").read()
|
|
619
|
+
except Exception:
|
|
620
|
+
raise SystemExit(1)
|
|
621
|
+
|
|
622
|
+
matches = re.findall(r"Result file written:\s*([^\r\n]+)", raw, flags=re.IGNORECASE)
|
|
623
|
+
if not matches:
|
|
624
|
+
raise SystemExit(1)
|
|
625
|
+
|
|
626
|
+
line = matches[-1]
|
|
627
|
+
fields = {}
|
|
628
|
+
for key in ("OUTCOME", "ACTION", "DETAIL", "ISSUE_ID"):
|
|
629
|
+
match = re.search(rf"{key}=([A-Za-z0-9._/-]+)", line)
|
|
630
|
+
if match:
|
|
631
|
+
fields[key] = match.group(1).strip()
|
|
632
|
+
|
|
633
|
+
if "OUTCOME" not in fields or "ACTION" not in fields:
|
|
634
|
+
raise SystemExit(1)
|
|
635
|
+
|
|
636
|
+
for key in ("OUTCOME", "ACTION", "DETAIL", "ISSUE_ID"):
|
|
637
|
+
value = fields.get(key)
|
|
638
|
+
if value:
|
|
639
|
+
print(f"{key}={value}")
|
|
640
|
+
PY
|
|
641
|
+
}
|
|
642
|
+
|
|
580
643
|
# If sandbox result.env exists with implemented, validate it has verification
|
|
581
644
|
if [[ -f "\${result_file_path}" ]]; then
|
|
582
645
|
if grep -q 'OUTCOME=implemented' "\${result_file_path}" 2>/dev/null; then
|
|
@@ -589,6 +652,17 @@ infer_result_from_output() {
|
|
|
589
652
|
return 0
|
|
590
653
|
fi
|
|
591
654
|
|
|
655
|
+
if grep -Fq '[tools] exec failed: Provide a command to start.' "\${output_file}" 2>/dev/null; then
|
|
656
|
+
write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\nDETAIL=worker-tool-exec-empty-command\n'
|
|
657
|
+
return 0
|
|
658
|
+
fi
|
|
659
|
+
|
|
660
|
+
recovered_contract="\$(recover_result_contract_from_output 2>/dev/null || true)"
|
|
661
|
+
if [[ -n "\${recovered_contract}" ]]; then
|
|
662
|
+
write_result "\${recovered_contract}"$'\n'
|
|
663
|
+
return 0
|
|
664
|
+
fi
|
|
665
|
+
|
|
592
666
|
# Check if there are actual code changes (not just artifact files)
|
|
593
667
|
local has_product_changes="no"
|
|
594
668
|
if git -C ${worktree_q} diff --name-only HEAD 2>/dev/null | grep -qvE '\.openclaw-artifacts/|\.md$' 2>/dev/null; then
|
|
@@ -640,6 +714,79 @@ infer_result_from_output() {
|
|
|
640
714
|
write_result 'OUTCOME=blocked\nACTION=host-comment-blocker\n'
|
|
641
715
|
}
|
|
642
716
|
|
|
717
|
+
synthesize_comment_artifact_from_output() {
|
|
718
|
+
local target_file=""
|
|
719
|
+
local result_file_path="\${sandbox_run_dir}/result.env"
|
|
720
|
+
|
|
721
|
+
if [[ ! -f "\${result_file_path}" ]] || ! grep -Eq '^ACTION=host-comment-' "\${result_file_path}" 2>/dev/null; then
|
|
722
|
+
return 0
|
|
723
|
+
fi
|
|
724
|
+
|
|
725
|
+
case "\${task_kind}" in
|
|
726
|
+
issue|task)
|
|
727
|
+
target_file="\${sandbox_run_dir}/issue-comment.md"
|
|
728
|
+
;;
|
|
729
|
+
pr)
|
|
730
|
+
target_file="\${sandbox_run_dir}/pr-comment.md"
|
|
731
|
+
;;
|
|
732
|
+
*)
|
|
733
|
+
return 0
|
|
734
|
+
;;
|
|
735
|
+
esac
|
|
736
|
+
|
|
737
|
+
[[ -n "\${target_file}" ]] || return 0
|
|
738
|
+
[[ ! -f "\${target_file}" ]] || return 0
|
|
739
|
+
|
|
740
|
+
python3 - "\${output_file}" "\${target_file}" <<'PY2'
|
|
741
|
+
import json
|
|
742
|
+
import os
|
|
743
|
+
import sys
|
|
744
|
+
|
|
745
|
+
log_path, target_path = sys.argv[1:3]
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
raw = open(log_path, 'r', encoding='utf-8', errors='replace').read()
|
|
749
|
+
except Exception:
|
|
750
|
+
raise SystemExit(0)
|
|
751
|
+
|
|
752
|
+
decoder = json.JSONDecoder()
|
|
753
|
+
message = ''
|
|
754
|
+
idx = 0
|
|
755
|
+
while idx < len(raw):
|
|
756
|
+
start = raw.find('{', idx)
|
|
757
|
+
if start == -1:
|
|
758
|
+
break
|
|
759
|
+
try:
|
|
760
|
+
payload, end = decoder.raw_decode(raw, start)
|
|
761
|
+
except Exception:
|
|
762
|
+
idx = start + 1
|
|
763
|
+
continue
|
|
764
|
+
idx = end
|
|
765
|
+
if not isinstance(payload, dict):
|
|
766
|
+
continue
|
|
767
|
+
payloads = payload.get('payloads')
|
|
768
|
+
if not isinstance(payloads, list):
|
|
769
|
+
continue
|
|
770
|
+
parts = []
|
|
771
|
+
for item in payloads:
|
|
772
|
+
if not isinstance(item, dict):
|
|
773
|
+
continue
|
|
774
|
+
value = item.get('text')
|
|
775
|
+
if isinstance(value, str) and value.strip():
|
|
776
|
+
parts.append(value.rstrip())
|
|
777
|
+
if parts:
|
|
778
|
+
message = '\n\n'.join(parts).strip()
|
|
779
|
+
|
|
780
|
+
if not message:
|
|
781
|
+
raise SystemExit(0)
|
|
782
|
+
|
|
783
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
784
|
+
with open(target_path, 'w', encoding='utf-8') as handle:
|
|
785
|
+
handle.write(message)
|
|
786
|
+
handle.write('\n')
|
|
787
|
+
PY2
|
|
788
|
+
}
|
|
789
|
+
|
|
643
790
|
cleanup_agent() {
|
|
644
791
|
# --force required for non-interactive (tmux) sessions, otherwise delete waits for confirmation
|
|
645
792
|
"\${openclaw_bin}" agents delete "\${openclaw_agent_id}" --json --force >/dev/null 2>&1 || true
|
|
@@ -734,7 +881,7 @@ PY
|
|
|
734
881
|
}
|
|
735
882
|
|
|
736
883
|
run_openclaw_agent_command() {
|
|
737
|
-
python3 - "\${output_file}" "\${openclaw_timeout}" "\${openclaw_bin}" "\${openclaw_agent_id}" "\${openclaw_session_id}" "\${openclaw_thinking}" "\${prompt_file_path}" <<'PY'
|
|
884
|
+
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'
|
|
738
885
|
import os
|
|
739
886
|
import re
|
|
740
887
|
import selectors
|
|
@@ -743,7 +890,7 @@ import subprocess
|
|
|
743
890
|
import sys
|
|
744
891
|
import time
|
|
745
892
|
|
|
746
|
-
output_path, timeout_seconds_raw, openclaw_bin, agent_id, session_id, thinking, prompt_path = sys.argv[1:
|
|
893
|
+
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]
|
|
747
894
|
|
|
748
895
|
with open(prompt_path, "r", encoding="utf-8") as handle:
|
|
749
896
|
prompt = handle.read()
|
|
@@ -766,7 +913,14 @@ cmd = [
|
|
|
766
913
|
]
|
|
767
914
|
|
|
768
915
|
timeout_seconds = float(timeout_seconds_raw)
|
|
916
|
+
stall_seconds = float(stall_seconds_raw)
|
|
917
|
+
heartbeat_seconds = max(float(heartbeat_seconds_raw), 1.0)
|
|
769
918
|
hard_deadline = time.monotonic() + timeout_seconds + 15.0
|
|
919
|
+
started_at = time.monotonic()
|
|
920
|
+
next_heartbeat = time.monotonic() + heartbeat_seconds
|
|
921
|
+
seen_agent_progress = False
|
|
922
|
+
last_agent_progress_at = started_at
|
|
923
|
+
last_progress_source = "none"
|
|
770
924
|
terminal_patterns = [
|
|
771
925
|
re.compile(r"Config was last written by a newer OpenClaw", re.I),
|
|
772
926
|
re.compile(r"invalid api key|authentication failed|unauthorized|provider api key|login required|please authenticate|api_key_invalid", re.I),
|
|
@@ -779,6 +933,40 @@ proc = None
|
|
|
779
933
|
sel = selectors.DefaultSelector()
|
|
780
934
|
matched_terminal_error = False
|
|
781
935
|
tail = ""
|
|
936
|
+
openclaw_state_dir = os.environ.get("OPENCLAW_STATE_DIR", "")
|
|
937
|
+
sandbox_run_dir = (
|
|
938
|
+
os.environ.get("ACP_RUN_DIR")
|
|
939
|
+
or os.environ.get("AGENT_PROJECT_RUN_DIR")
|
|
940
|
+
or os.environ.get("F_LOSNING_RUN_DIR")
|
|
941
|
+
or ""
|
|
942
|
+
)
|
|
943
|
+
host_managed_prefixes = tuple(
|
|
944
|
+
prefix
|
|
945
|
+
for prefix in (
|
|
946
|
+
os.path.realpath(runner_state_path) if runner_state_path else "",
|
|
947
|
+
os.path.realpath(output_path) if output_path else "",
|
|
948
|
+
)
|
|
949
|
+
if prefix
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
def write_running_heartbeat() -> None:
|
|
953
|
+
updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
954
|
+
tmp_path = f"{runner_state_path}.tmp.{os.getpid()}"
|
|
955
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
956
|
+
handle.write("RUNNER_STATE=running\n")
|
|
957
|
+
handle.write(f"THREAD_ID={sh_quote(session_id)}\n")
|
|
958
|
+
handle.write("ATTEMPT=1\n")
|
|
959
|
+
handle.write("RESUME_COUNT=0\n")
|
|
960
|
+
handle.write("LAST_EXIT_CODE=''\n")
|
|
961
|
+
handle.write("LAST_FAILURE_REASON=''\n")
|
|
962
|
+
handle.write("LAST_TRIGGER_REASON=''\n")
|
|
963
|
+
handle.write("AUTH_WAIT_STARTED_AT=''\n")
|
|
964
|
+
handle.write("LAST_AUTH_FINGERPRINT=''\n")
|
|
965
|
+
handle.write(f"UPDATED_AT={sh_quote(updated_at)}\n")
|
|
966
|
+
os.replace(tmp_path, runner_state_path)
|
|
967
|
+
|
|
968
|
+
def sh_quote(value: str) -> str:
|
|
969
|
+
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
782
970
|
|
|
783
971
|
def terminate_process_group(process: subprocess.Popen) -> None:
|
|
784
972
|
try:
|
|
@@ -795,6 +983,47 @@ def terminate_process_group(process: subprocess.Popen) -> None:
|
|
|
795
983
|
except ProcessLookupError:
|
|
796
984
|
return
|
|
797
985
|
|
|
986
|
+
def progress_signature() -> tuple[tuple[str, int, int], ...]:
|
|
987
|
+
entries: list[tuple[str, int, int]] = []
|
|
988
|
+
|
|
989
|
+
def add_file(path: str) -> None:
|
|
990
|
+
real_path = ""
|
|
991
|
+
if not path:
|
|
992
|
+
return
|
|
993
|
+
try:
|
|
994
|
+
stat_result = os.stat(path)
|
|
995
|
+
except OSError:
|
|
996
|
+
return
|
|
997
|
+
if not os.path.isfile(path):
|
|
998
|
+
return
|
|
999
|
+
real_path = os.path.realpath(path)
|
|
1000
|
+
for prefix in host_managed_prefixes:
|
|
1001
|
+
if real_path == prefix or real_path.startswith(f"{prefix}.tmp."):
|
|
1002
|
+
return
|
|
1003
|
+
entries.append((real_path, stat_result.st_mtime_ns, stat_result.st_size))
|
|
1004
|
+
|
|
1005
|
+
if sandbox_run_dir:
|
|
1006
|
+
try:
|
|
1007
|
+
for name in os.listdir(sandbox_run_dir):
|
|
1008
|
+
add_file(os.path.join(sandbox_run_dir, name))
|
|
1009
|
+
except OSError:
|
|
1010
|
+
pass
|
|
1011
|
+
|
|
1012
|
+
if openclaw_state_dir:
|
|
1013
|
+
sessions_dir = os.path.join(openclaw_state_dir, "agents", agent_id, "sessions")
|
|
1014
|
+
add_file(os.path.join(sessions_dir, "sessions.json"))
|
|
1015
|
+
try:
|
|
1016
|
+
for name in os.listdir(sessions_dir):
|
|
1017
|
+
if name.endswith(".jsonl") and not name.endswith(".lock"):
|
|
1018
|
+
add_file(os.path.join(sessions_dir, name))
|
|
1019
|
+
except OSError:
|
|
1020
|
+
pass
|
|
1021
|
+
|
|
1022
|
+
entries.sort()
|
|
1023
|
+
return tuple(entries)
|
|
1024
|
+
|
|
1025
|
+
last_progress_signature = progress_signature()
|
|
1026
|
+
|
|
798
1027
|
with open(output_path, "ab", buffering=0) as log_handle:
|
|
799
1028
|
proc = subprocess.Popen(
|
|
800
1029
|
cmd,
|
|
@@ -817,8 +1046,42 @@ with open(output_path, "ab", buffering=0) as log_handle:
|
|
|
817
1046
|
terminate_process_group(proc)
|
|
818
1047
|
break
|
|
819
1048
|
|
|
1049
|
+
current_progress_signature = progress_signature()
|
|
1050
|
+
if current_progress_signature != last_progress_signature:
|
|
1051
|
+
last_progress_signature = current_progress_signature
|
|
1052
|
+
seen_agent_progress = True
|
|
1053
|
+
last_agent_progress_at = time.monotonic()
|
|
1054
|
+
last_progress_source = "session-state"
|
|
1055
|
+
next_heartbeat = time.monotonic() + heartbeat_seconds
|
|
1056
|
+
|
|
820
1057
|
events = sel.select(timeout=0.2)
|
|
821
1058
|
if not events:
|
|
1059
|
+
if proc.poll() is None and not seen_agent_progress and stall_seconds > 0 and (time.monotonic() - started_at) >= stall_seconds:
|
|
1060
|
+
elapsed = int(time.monotonic() - started_at)
|
|
1061
|
+
write_running_heartbeat()
|
|
1062
|
+
log_handle.write(f"[openclaw] stale-run no-agent-output-before-stall-threshold elapsed={elapsed}s\n".encode("utf-8"))
|
|
1063
|
+
terminate_process_group(proc)
|
|
1064
|
+
break
|
|
1065
|
+
if proc.poll() is None and seen_agent_progress and stall_seconds > 0 and (time.monotonic() - last_agent_progress_at) >= stall_seconds:
|
|
1066
|
+
elapsed = int(time.monotonic() - started_at)
|
|
1067
|
+
idle_for = int(time.monotonic() - last_agent_progress_at)
|
|
1068
|
+
write_running_heartbeat()
|
|
1069
|
+
log_handle.write(f"[openclaw] stale-run no-agent-progress-before-stall-threshold elapsed={elapsed}s idle={idle_for}s\n".encode("utf-8"))
|
|
1070
|
+
terminate_process_group(proc)
|
|
1071
|
+
break
|
|
1072
|
+
if proc.poll() is None and not seen_agent_progress and time.monotonic() >= next_heartbeat:
|
|
1073
|
+
elapsed = int(time.monotonic() - started_at)
|
|
1074
|
+
write_running_heartbeat()
|
|
1075
|
+
log_handle.write(f"[openclaw] heartbeat waiting-for-agent-output elapsed={elapsed}s\n".encode("utf-8"))
|
|
1076
|
+
next_heartbeat = time.monotonic() + heartbeat_seconds
|
|
1077
|
+
if proc.poll() is None and seen_agent_progress and time.monotonic() >= next_heartbeat:
|
|
1078
|
+
elapsed = int(time.monotonic() - started_at)
|
|
1079
|
+
idle_for = int(time.monotonic() - last_agent_progress_at)
|
|
1080
|
+
write_running_heartbeat()
|
|
1081
|
+
log_handle.write(
|
|
1082
|
+
f"[openclaw] heartbeat progress source={last_progress_source} elapsed={elapsed}s idle={idle_for}s\n".encode("utf-8")
|
|
1083
|
+
)
|
|
1084
|
+
next_heartbeat = time.monotonic() + heartbeat_seconds
|
|
822
1085
|
if proc.poll() is not None:
|
|
823
1086
|
break
|
|
824
1087
|
continue
|
|
@@ -831,6 +1094,10 @@ with open(output_path, "ab", buffering=0) as log_handle:
|
|
|
831
1094
|
log_handle.write(chunk)
|
|
832
1095
|
text = chunk.decode("utf-8", errors="replace")
|
|
833
1096
|
tail = (tail + text)[-8192:]
|
|
1097
|
+
next_heartbeat = time.monotonic() + heartbeat_seconds
|
|
1098
|
+
seen_agent_progress = True
|
|
1099
|
+
last_agent_progress_at = time.monotonic()
|
|
1100
|
+
last_progress_source = "stdout"
|
|
834
1101
|
|
|
835
1102
|
if not matched_terminal_error and any(pattern.search(tail) for pattern in terminal_patterns):
|
|
836
1103
|
matched_terminal_error = True
|
|
@@ -950,9 +1217,9 @@ while true; do
|
|
|
950
1217
|
break
|
|
951
1218
|
done
|
|
952
1219
|
recover_literal_runtime_artifacts
|
|
1220
|
+
infer_result_from_output
|
|
1221
|
+
synthesize_comment_artifact_from_output
|
|
953
1222
|
if [[ "\${status}" -eq 0 ]]; then
|
|
954
|
-
# Infer result.env from output if agent didn't create one
|
|
955
|
-
infer_result_from_output
|
|
956
1223
|
write_state succeeded "0" ""
|
|
957
1224
|
else
|
|
958
1225
|
if [[ -z "\${failure_reason}" ]]; then
|
|
@@ -16,6 +16,8 @@ canonical_root=""
|
|
|
16
16
|
anchor_root=""
|
|
17
17
|
remote_name="origin"
|
|
18
18
|
default_branch="main"
|
|
19
|
+
dirty_state_stashed="no"
|
|
20
|
+
dirty_stash_message=""
|
|
19
21
|
|
|
20
22
|
while [[ $# -gt 0 ]]; do
|
|
21
23
|
case "$1" in
|
|
@@ -103,8 +105,13 @@ git -C "$anchor_root" fetch "$remote_name" --prune >/dev/null
|
|
|
103
105
|
git -C "$anchor_root" worktree prune >/dev/null 2>&1 || true
|
|
104
106
|
|
|
105
107
|
if [[ -n "$(git -C "$anchor_root" status --porcelain --untracked-files=no)" ]]; then
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
dirty_stash_message="acp-anchor-sync-$(date -u +%Y%m%dT%H%M%SZ)"
|
|
109
|
+
git -C "$anchor_root" stash push --message "$dirty_stash_message" >/dev/null
|
|
110
|
+
if [[ -n "$(git -C "$anchor_root" status --porcelain --untracked-files=no)" ]]; then
|
|
111
|
+
echo "[agent-sync-anchor] anchor repo is dirty; refuse to reset: $anchor_root" >&2
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
dirty_state_stashed="yes"
|
|
108
115
|
fi
|
|
109
116
|
|
|
110
117
|
default_ref="${remote_name}/${default_branch}"
|
|
@@ -126,3 +133,7 @@ printf 'ANCHOR_ROOT=%s\n' "$anchor_root"
|
|
|
126
133
|
printf 'REMOTE=%s\n' "$remote_name"
|
|
127
134
|
printf 'DEFAULT_BRANCH=%s\n' "$default_branch"
|
|
128
135
|
printf 'ORIGIN_URL=%s\n' "$origin_url"
|
|
136
|
+
printf 'DIRTY_STATE_STASHED=%s\n' "$dirty_state_stashed"
|
|
137
|
+
if [[ "$dirty_state_stashed" == "yes" ]]; then
|
|
138
|
+
printf 'DIRTY_STASH_MESSAGE=%s\n' "$dirty_stash_message"
|
|
139
|
+
fi
|
|
@@ -87,23 +87,14 @@ if [[ "$status" == "UNKNOWN" && -f "$output_file" ]]; then
|
|
|
87
87
|
fi
|
|
88
88
|
fi
|
|
89
89
|
|
|
90
|
-
if [[ "$status" == "UNKNOWN" && -f "$result_file" ]]; then
|
|
91
|
-
# A worker that managed to persist result.env already completed its contract,
|
|
92
|
-
# even if the tmux session disappeared before the exit marker was flushed.
|
|
93
|
-
status="SUCCEEDED"
|
|
94
|
-
result_only_completion="yes"
|
|
95
|
-
fi
|
|
96
|
-
|
|
97
|
-
if [[ "$status" == "UNKNOWN" && -f "$output_file" ]]; then
|
|
98
|
-
if rg -qi "You've hit your usage limit|You have reached your Codex usage limits|visit https://chatgpt.com/codex/settings/usage|Upgrade to Pro|rate limit exceeded|quota exceeded|usage cap (reached|exceeded)|usage quota (reached|exceeded)" "$output_file"; then
|
|
99
|
-
status="FAILED"
|
|
100
|
-
failure_reason="usage-limit"
|
|
101
|
-
fi
|
|
102
|
-
fi
|
|
103
|
-
|
|
104
90
|
if [[ "$status" == "UNKNOWN" && -n "$runner_state" ]]; then
|
|
105
91
|
case "$runner_state" in
|
|
106
92
|
running|waiting-auth-refresh|switching-account)
|
|
93
|
+
# Tmux session is gone and runner never reached a terminal state.
|
|
94
|
+
# This detects crashes where the worker process died before updating
|
|
95
|
+
# runner.env or writing an exit marker.
|
|
96
|
+
# Check BEFORE stale result.env to avoid false SUCCEEDED when a prior
|
|
97
|
+
# cycle's result.env happens to exist.
|
|
107
98
|
status="FAILED"
|
|
108
99
|
if [[ -z "$failure_reason" ]]; then
|
|
109
100
|
failure_reason="runner-aborted-before-completion"
|
|
@@ -115,6 +106,20 @@ if [[ "$status" == "UNKNOWN" && -n "$runner_state" ]]; then
|
|
|
115
106
|
esac
|
|
116
107
|
fi
|
|
117
108
|
|
|
109
|
+
if [[ "$status" == "UNKNOWN" && -f "$result_file" ]]; then
|
|
110
|
+
# A worker that managed to persist result.env already completed its contract,
|
|
111
|
+
# even if the tmux session disappeared before the exit marker was flushed.
|
|
112
|
+
status="SUCCEEDED"
|
|
113
|
+
result_only_completion="yes"
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
if [[ "$status" == "UNKNOWN" && -f "$output_file" ]]; then
|
|
117
|
+
if rg -qi "You've hit your usage limit|You have reached your Codex usage limits|visit https://chatgpt.com/codex/settings/usage|Upgrade to Pro|rate limit exceeded|quota exceeded|usage cap (reached|exceeded)|usage quota (reached|exceeded)" "$output_file"; then
|
|
118
|
+
status="FAILED"
|
|
119
|
+
failure_reason="usage-limit"
|
|
120
|
+
fi
|
|
121
|
+
fi
|
|
122
|
+
|
|
118
123
|
printf 'SESSION=%s\n' "$session"
|
|
119
124
|
printf 'STATUS=%s\n' "$status"
|
|
120
125
|
if [[ -f "$meta_file" ]]; then
|
|
@@ -12,6 +12,7 @@ AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
|
|
|
12
12
|
AGENT_ROOT="$(flow_resolve_agent_root "${CONFIG_YAML}")"
|
|
13
13
|
RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
|
|
14
14
|
HISTORY_ROOT="$(flow_resolve_history_root "${CONFIG_YAML}")"
|
|
15
|
+
WORKTREE_ROOT="$(flow_resolve_worktree_root "${CONFIG_YAML}")"
|
|
15
16
|
RETAINED_REPO_ROOT="$(flow_resolve_retained_repo_root "${CONFIG_YAML}")"
|
|
16
17
|
VSCODE_WORKSPACE_FILE="$(flow_resolve_vscode_workspace_file "${CONFIG_YAML}")"
|
|
17
18
|
ISSUE_SESSION_PREFIX="$(flow_resolve_issue_session_prefix "${CONFIG_YAML}")"
|
|
@@ -36,7 +37,9 @@ if [[ -n "$SESSION" ]]; then
|
|
|
36
37
|
ARGS+=(--session "$SESSION")
|
|
37
38
|
fi
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
AGENT_PROJECT_WORKTREE_ROOT="$WORKTREE_ROOT" \
|
|
41
|
+
F_LOSNING_WORKTREE_ROOT="$WORKTREE_ROOT" \
|
|
42
|
+
bash "${FLOW_TOOLS_DIR}/agent-project-cleanup-session" "${ARGS[@]}" >/dev/null
|
|
40
43
|
|
|
41
44
|
F_LOSNING_AGENT_REPO_ROOT="$AGENT_REPO_ROOT" \
|
|
42
45
|
F_LOSNING_RETAINED_REPO_ROOT="$RETAINED_REPO_ROOT" \
|
|
@@ -4,13 +4,14 @@ set -euo pipefail
|
|
|
4
4
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
5
|
FLOW_SKILL_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
6
6
|
HOME_DIR="${ACP_DASHBOARD_HOME_DIR:-${HOME:-}}"
|
|
7
|
-
SOURCE_HOME="${ACP_DASHBOARD_SOURCE_HOME
|
|
7
|
+
SOURCE_HOME="${ACP_DASHBOARD_SOURCE_HOME:-}"
|
|
8
8
|
RUNTIME_HOME="${ACP_DASHBOARD_RUNTIME_HOME:-${HOME_DIR}/.agent-runtime/runtime-home}"
|
|
9
9
|
PROFILE_REGISTRY_ROOT="${ACP_DASHBOARD_PROFILE_REGISTRY_ROOT:-${ACP_PROFILE_REGISTRY_ROOT:-${HOME_DIR}/.agent-runtime/control-plane/profiles}}"
|
|
10
10
|
HOST="${ACP_DASHBOARD_HOST:-127.0.0.1}"
|
|
11
11
|
PORT="${ACP_DASHBOARD_PORT:-8765}"
|
|
12
12
|
BASE_PATH="${ACP_DASHBOARD_PATH:-/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin}"
|
|
13
13
|
SYNC_SCRIPT="${ACP_DASHBOARD_SYNC_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/sync-shared-agent-home.sh}"
|
|
14
|
+
ENSURE_SYNC_SCRIPT="${ACP_DASHBOARD_ENSURE_SYNC_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/ensure-runtime-sync.sh}"
|
|
14
15
|
RUNTIME_SERVE_SCRIPT="${ACP_DASHBOARD_RUNTIME_SERVE_SCRIPT:-${RUNTIME_HOME}/skills/openclaw/agent-control-plane/tools/bin/serve-dashboard.sh}"
|
|
15
16
|
|
|
16
17
|
if [[ -z "${HOME_DIR}" ]]; then
|
|
@@ -23,12 +24,23 @@ export PATH="${BASE_PATH}"
|
|
|
23
24
|
export ACP_PROFILE_REGISTRY_ROOT="${PROFILE_REGISTRY_ROOT}"
|
|
24
25
|
export PYTHONDONTWRITEBYTECODE=1
|
|
25
26
|
|
|
26
|
-
if [[ ! -x "${SYNC_SCRIPT}" ]]; then
|
|
27
|
-
echo "dashboard launchd bootstrap missing sync
|
|
27
|
+
if [[ ! -x "${ENSURE_SYNC_SCRIPT}" && ! -x "${SYNC_SCRIPT}" ]]; then
|
|
28
|
+
echo "dashboard launchd bootstrap missing sync helper: ${ENSURE_SYNC_SCRIPT}" >&2
|
|
28
29
|
exit 65
|
|
29
30
|
fi
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
if [[ -x "${ENSURE_SYNC_SCRIPT}" ]]; then
|
|
33
|
+
ensure_args=(--runtime-home "${RUNTIME_HOME}" --quiet)
|
|
34
|
+
if [[ -n "${SOURCE_HOME}" ]]; then
|
|
35
|
+
ensure_args=(--source-home "${SOURCE_HOME}" "${ensure_args[@]}")
|
|
36
|
+
fi
|
|
37
|
+
bash "${ENSURE_SYNC_SCRIPT}" "${ensure_args[@]}"
|
|
38
|
+
else
|
|
39
|
+
if [[ -z "${SOURCE_HOME}" ]]; then
|
|
40
|
+
SOURCE_HOME="${FLOW_SKILL_DIR}"
|
|
41
|
+
fi
|
|
42
|
+
bash "${SYNC_SCRIPT}" "${SOURCE_HOME}" "${RUNTIME_HOME}" >/dev/null
|
|
43
|
+
fi
|
|
32
44
|
|
|
33
45
|
if [[ ! -x "${RUNTIME_SERVE_SCRIPT}" ]]; then
|
|
34
46
|
echo "dashboard launchd bootstrap missing runtime serve script: ${RUNTIME_SERVE_SCRIPT}" >&2
|