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.
Files changed (48) hide show
  1. package/bin/pr-risk.sh +54 -10
  2. package/hooks/heartbeat-hooks.sh +166 -13
  3. package/package.json +8 -2
  4. package/references/commands.md +1 -0
  5. package/tools/bin/agent-project-cleanup-session +143 -2
  6. package/tools/bin/agent-project-heartbeat-loop +29 -2
  7. package/tools/bin/agent-project-publish-issue-pr +178 -62
  8. package/tools/bin/agent-project-reconcile-issue-session +230 -5
  9. package/tools/bin/agent-project-reconcile-pr-session +104 -13
  10. package/tools/bin/agent-project-run-claude-session +19 -1
  11. package/tools/bin/agent-project-run-codex-resilient +121 -16
  12. package/tools/bin/agent-project-run-codex-session +61 -11
  13. package/tools/bin/agent-project-run-openclaw-session +274 -7
  14. package/tools/bin/agent-project-sync-anchor-repo +13 -2
  15. package/tools/bin/agent-project-worker-status +19 -14
  16. package/tools/bin/cleanup-worktree.sh +4 -1
  17. package/tools/bin/dashboard-launchd-bootstrap.sh +16 -4
  18. package/tools/bin/ensure-runtime-sync.sh +182 -0
  19. package/tools/bin/flow-config-lib.sh +76 -30
  20. package/tools/bin/flow-resident-worker-lib.sh +28 -2
  21. package/tools/bin/flow-shell-lib.sh +28 -8
  22. package/tools/bin/heartbeat-safe-auto.sh +32 -0
  23. package/tools/bin/issue-publish-localization-guard.sh +142 -0
  24. package/tools/bin/prepare-worktree.sh +3 -1
  25. package/tools/bin/project-launchd-bootstrap.sh +17 -4
  26. package/tools/bin/project-runtime-supervisor.sh +7 -1
  27. package/tools/bin/project-runtimectl.sh +78 -15
  28. package/tools/bin/provider-cooldown-state.sh +1 -1
  29. package/tools/bin/render-flow-config.sh +16 -1
  30. package/tools/bin/reuse-issue-worktree.sh +46 -0
  31. package/tools/bin/run-codex-task.sh +2 -2
  32. package/tools/bin/scaffold-profile.sh +2 -2
  33. package/tools/bin/start-issue-worker.sh +118 -16
  34. package/tools/bin/start-resident-issue-loop.sh +1 -0
  35. package/tools/bin/sync-shared-agent-home.sh +26 -0
  36. package/tools/bin/test-smoke.sh +6 -1
  37. package/tools/dashboard/app.js +91 -3
  38. package/tools/dashboard/dashboard_snapshot.py +119 -0
  39. package/tools/dashboard/styles.css +43 -0
  40. package/tools/templates/issue-prompt-template.md +18 -66
  41. package/tools/templates/legacy/issue-prompt-template-pre-slim.md +109 -0
  42. package/bin/audit-issue-routing.sh +0 -74
  43. package/tools/bin/audit-agent-worktrees.sh +0 -310
  44. package/tools/bin/audit-issue-routing.sh +0 -11
  45. package/tools/bin/audit-retained-layout.sh +0 -58
  46. package/tools/bin/audit-retained-overlap.sh +0 -135
  47. package/tools/bin/audit-retained-worktrees.sh +0 -228
  48. 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/stepfun/step-3.5-flash:free}}"
38
- openclaw_thinking="${ACP_OPENCLAW_THINKING:-${F_LOSNING_OPENCLAW_THINKING:-adaptive}}"
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:8]
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
- echo "[agent-sync-anchor] anchor repo is dirty; refuse to reset: $anchor_root" >&2
107
- exit 1
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
- bash "${FLOW_TOOLS_DIR}/agent-project-cleanup-session" "${ARGS[@]}" >/dev/null
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:-$(cd "${FLOW_SKILL_DIR}/../../.." && pwd)}"
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 script: ${SYNC_SCRIPT}" >&2
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
- bash "${SYNC_SCRIPT}" "${SOURCE_HOME}" "${RUNTIME_HOME}" >/dev/null
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