agent-control-plane 0.1.7 → 0.1.9

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.
@@ -168,6 +168,9 @@ fi
168
168
  result_outcome=""
169
169
  result_action=""
170
170
  result_issue_id="${ISSUE_ID:-}"
171
+ result_detail=""
172
+ run_started_at="${STARTED_AT:-}"
173
+ expected_run_started_at="${ACP_EXPECTED_RUN_STARTED_AT:-${F_LOSNING_EXPECTED_RUN_STARTED_AT:-}}"
171
174
  host_blocker_file="${run_dir}/host-blocker.md"
172
175
  prompt_file="${run_dir}/prompt.md"
173
176
  pr_comment_file="${run_dir}/pr-comment.md"
@@ -184,9 +187,18 @@ if [[ -f "$result_file_candidate" ]]; then
184
187
  set +a
185
188
  result_outcome="${OUTCOME:-}"
186
189
  result_action="${ACTION:-}"
190
+ result_detail="${DETAIL:-}"
187
191
  result_issue_id="${ISSUE_ID:-${result_issue_id}}"
188
192
  fi
189
193
 
194
+ if [[ -n "${expected_run_started_at}" && "${expected_run_started_at}" != "${run_started_at}" ]]; then
195
+ printf 'STATUS=STALE-RUN-SKIPPED\n'
196
+ printf 'SESSION=%s\n' "$session"
197
+ printf 'EXPECTED_STARTED_AT=%s\n' "${expected_run_started_at}"
198
+ printf 'ACTUAL_STARTED_AT=%s\n' "${run_started_at}"
199
+ exit 0
200
+ fi
201
+
190
202
  pr_schedule_retry() { :; }
191
203
  pr_clear_retry() { :; }
192
204
  pr_cleanup_linked_issue_session() { :; }
@@ -225,6 +237,8 @@ clear_provider_quota_cooldown() {
225
237
  "${provider_cooldown_script}" clear >/dev/null || true
226
238
  }
227
239
 
240
+ blocked_runtime_reason=""
241
+
228
242
  owner="${repo_slug%%/*}"
229
243
  repo="${repo_slug#*/}"
230
244
  pr_view_json="$(flow_github_pr_view_json "$repo_slug" "$pr_number")"
@@ -339,6 +353,11 @@ normalize_pr_result_contract() {
339
353
  host-comment-pr-blocker)
340
354
  return 0
341
355
  ;;
356
+ host-comment-blocker)
357
+ result_action="host-comment-pr-blocker"
358
+ pr_result_contract_note="normalized-legacy-blocked-action"
359
+ return 0
360
+ ;;
342
361
  requested-changes-or-blocked)
343
362
  result_action="host-comment-pr-blocker"
344
363
  pr_result_contract_note="normalized-legacy-blocked-action"
@@ -358,8 +377,15 @@ normalize_pr_result_contract() {
358
377
  }
359
378
 
360
379
  mark_reconciled() {
380
+ local reconciled_at tmp_file
361
381
  if [[ -d "$run_dir" ]]; then
362
- touch "${run_dir}/reconciled.ok"
382
+ reconciled_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
383
+ tmp_file="${run_dir}/reconciled.ok.tmp.$$"
384
+ {
385
+ printf 'STARTED_AT=%s\n' "${run_started_at}"
386
+ printf 'RECONCILED_AT=%s\n' "${reconciled_at}"
387
+ } >"${tmp_file}"
388
+ mv "${tmp_file}" "${run_dir}/reconciled.ok"
363
389
  fi
364
390
  }
365
391
 
@@ -393,6 +419,20 @@ blocked_result_indicates_local_bind_failure() {
393
419
  return 1
394
420
  }
395
421
 
422
+ classify_pr_blocked_runtime_reason() {
423
+ if [[ "${result_detail:-}" == "worker-tool-exec-empty-command" ]]; then
424
+ printf 'worker-tool-exec-empty-command\n'
425
+ return 0
426
+ fi
427
+
428
+ if [[ -f "$session_log_file" ]] && grep -Fq '[tools] exec failed: Provide a command to start.' "$session_log_file"; then
429
+ printf 'worker-tool-exec-empty-command\n'
430
+ return 0
431
+ fi
432
+
433
+ return 1
434
+ }
435
+
396
436
  extract_preapproved_host_recovery_commands() {
397
437
  [[ -f "$prompt_file" ]] || return 0
398
438
  sed -n 's/^.*loopback retry command: `\(.*\)`$/\1/p' "$prompt_file"
@@ -700,18 +740,53 @@ merge_state_prepared() {
700
740
  git -C "$pr_worktree" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1
701
741
  }
702
742
 
743
+ current_github_login() {
744
+ flow_export_github_cli_auth_env "${repo_slug}"
745
+ gh api user --jq '.login // ""' 2>/dev/null || true
746
+ }
747
+
748
+ pr_author_login() {
749
+ flow_export_github_cli_auth_env "${repo_slug}"
750
+ gh pr view "${pr_number}" -R "${repo_slug}" --json author --jq '.author.login // ""' 2>/dev/null || true
751
+ }
752
+
753
+ pr_is_self_authored_for_current_actor() {
754
+ local actor_login=""
755
+ local author_login=""
756
+
757
+ actor_login="$(current_github_login)"
758
+ author_login="$(pr_author_login)"
759
+ [[ -n "${actor_login}" && -n "${author_login}" && "${actor_login}" == "${author_login}" ]]
760
+ }
761
+
762
+ pr_remote_head_oid() {
763
+ flow_export_github_cli_auth_env "${repo_slug}"
764
+ gh pr view "${pr_number}" -R "${repo_slug}" --json headRefOid --jq '.headRefOid // ""' 2>/dev/null || true
765
+ }
766
+
767
+ pr_remote_already_has_final_head() {
768
+ local final_head="${FINAL_HEAD:-}"
769
+ local remote_head=""
770
+
771
+ [[ -n "${final_head}" ]] || return 1
772
+ remote_head="$(pr_remote_head_oid)"
773
+ [[ -n "${remote_head}" && "${remote_head}" == "${final_head}" ]]
774
+ }
775
+
703
776
  approve_and_merge() {
704
777
  local approve_output
705
- if ! approve_output="$(
706
- flow_github_api_repo "${repo_slug}" "pulls/${pr_number}/reviews" \
707
- --method POST \
708
- -f event=APPROVE \
709
- -f body="Automated final review passed. Safe low-risk scope, green checks, and host-side merge approved." \
710
- 2>&1
711
- )"; then
712
- if ! grep -q "Can not approve your own pull request" <<<"$approve_output"; then
713
- printf '%s\n' "$approve_output" >&2
714
- return 1
778
+ if ! pr_is_self_authored_for_current_actor; then
779
+ if ! approve_output="$(
780
+ flow_github_api_repo "${repo_slug}" "pulls/${pr_number}/reviews" \
781
+ --method POST \
782
+ -f event=APPROVE \
783
+ -f body="Automated final review passed. Safe low-risk scope, green checks, and host-side merge approved." \
784
+ 2>&1
785
+ )"; then
786
+ if ! grep -q "Can not approve your own pull request" <<<"$approve_output"; then
787
+ printf '%s\n' "$approve_output" >&2
788
+ return 1
789
+ fi
715
790
  fi
716
791
  fi
717
792
 
@@ -753,7 +828,14 @@ handle_linked_issue_merge_cleanup() {
753
828
 
754
829
  handle_updated_branch_result() {
755
830
  if [[ -z "$pr_worktree" || ! -d "$pr_worktree" ]]; then
756
- if pr_comment_already_posted; then
831
+ if pr_remote_already_has_final_head; then
832
+ post_pr_comment_if_present
833
+ require_transition "pr_clear_retry" pr_clear_retry
834
+ require_transition "pr_after_updated_branch" pr_after_updated_branch "$pr_number"
835
+ cleanup_pr_session
836
+ result_action="${result_action:-host-push-pr-branch}"
837
+ notify_pr_reconciled
838
+ elif pr_comment_already_posted; then
757
839
  require_transition "pr_clear_retry" pr_clear_retry
758
840
  require_transition "pr_after_updated_branch" pr_after_updated_branch "$pr_number"
759
841
  cleanup_pr_session
@@ -968,7 +1050,16 @@ elif [[ "$status" == "SUCCEEDED" && "$result_outcome" == "no-change-needed" ]];
968
1050
  fi
969
1051
  fi
970
1052
  elif [[ "$status" == "SUCCEEDED" && "$result_outcome" == "blocked" ]]; then
971
- if attempt_blocked_pr_host_verification_recovery; then
1053
+ blocked_runtime_reason="$(classify_pr_blocked_runtime_reason || true)"
1054
+ if [[ -n "${blocked_runtime_reason:-}" ]]; then
1055
+ status="FAILED"
1056
+ failure_reason="${blocked_runtime_reason}"
1057
+ require_transition "pr_schedule_retry" pr_schedule_retry "$failure_reason"
1058
+ require_transition "pr_after_failed" pr_after_failed "$pr_number"
1059
+ cleanup_pr_session
1060
+ result_action="queued-pr-retry"
1061
+ notify_pr_reconciled
1062
+ elif attempt_blocked_pr_host_verification_recovery; then
972
1063
  handle_updated_branch_result
973
1064
  else
974
1065
  post_pr_comment_if_present
@@ -11,12 +11,13 @@ persist the standard run artifacts.
11
11
 
12
12
  Options:
13
13
  --claude-model <name> Claude model alias or full name
14
- --claude-permission-mode <mode> Claude permission mode (e.g. dontAsk, bypassPermissions)
14
+ --claude-permission-mode <mode> Claude permission mode (e.g. acceptEdits, bypassPermissions)
15
15
  --claude-effort <level> Claude effort level (low, medium, high, max)
16
16
  --claude-timeout-seconds <secs> Claude command timeout (default: 900)
17
17
  --claude-max-attempts <count> Retry transient failures this many times (default: 3)
18
18
  --claude-retry-backoff-seconds <s>
19
19
  Sleep between transient retries (default: 30)
20
+ --claude-allowed-tools <spec> Allowed Claude tools for headless runs
20
21
  --env-prefix <prefix> Export prefixed runtime/context env vars inside the worker
21
22
  --context <KEY=VALUE> Extra metadata written to run.env and exported to the worker
22
23
  --collect-file <name> Copy sandbox artifact file into the host run dir after execution
@@ -35,11 +36,12 @@ adapter_id=""
35
36
  task_kind=""
36
37
  task_id=""
37
38
  claude_model="${ACP_CLAUDE_MODEL:-${F_LOSNING_CLAUDE_MODEL:-sonnet}}"
38
- claude_permission_mode="${ACP_CLAUDE_PERMISSION_MODE:-${F_LOSNING_CLAUDE_PERMISSION_MODE:-dontAsk}}"
39
+ claude_permission_mode="${ACP_CLAUDE_PERMISSION_MODE:-${F_LOSNING_CLAUDE_PERMISSION_MODE:-acceptEdits}}"
39
40
  claude_effort="${ACP_CLAUDE_EFFORT:-${F_LOSNING_CLAUDE_EFFORT:-medium}}"
40
41
  claude_timeout_seconds="${ACP_CLAUDE_TIMEOUT_SECONDS:-${F_LOSNING_CLAUDE_TIMEOUT_SECONDS:-900}}"
41
42
  claude_max_attempts="${ACP_CLAUDE_MAX_ATTEMPTS:-${F_LOSNING_CLAUDE_MAX_ATTEMPTS:-3}}"
42
43
  claude_retry_backoff_seconds="${ACP_CLAUDE_RETRY_BACKOFF_SECONDS:-${F_LOSNING_CLAUDE_RETRY_BACKOFF_SECONDS:-30}}"
44
+ claude_allowed_tools="${ACP_CLAUDE_ALLOWED_TOOLS:-${F_LOSNING_CLAUDE_ALLOWED_TOOLS:-Bash(*),Read,Grep,Glob,LS,Edit,Write,MultiEdit}}"
43
45
  env_prefix=""
44
46
  sandbox_subdir=".openclaw-artifacts"
45
47
  reconcile_command=""
@@ -59,6 +61,24 @@ resolve_claude_bin() {
59
61
  return 0
60
62
  fi
61
63
 
64
+ # Well-known install locations for Claude Code CLI.
65
+ # Detached supervisors and LaunchAgents run with a minimal PATH that
66
+ # does not include user-local directories, so command -v alone is not
67
+ # enough. Try the common locations explicitly.
68
+ local -a fallback_paths=(
69
+ "${HOME}/.local/bin/claude"
70
+ "${HOME}/.claude/local/bin/claude"
71
+ "/usr/local/bin/claude"
72
+ "/opt/homebrew/bin/claude"
73
+ )
74
+ local p
75
+ for p in "${fallback_paths[@]}"; do
76
+ if [[ -x "${p}" ]]; then
77
+ printf '%s\n' "${p}"
78
+ return 0
79
+ fi
80
+ done
81
+
62
82
  return 1
63
83
  }
64
84
 
@@ -94,6 +114,7 @@ while [[ $# -gt 0 ]]; do
94
114
  --claude-timeout-seconds) claude_timeout_seconds="${2:-}"; shift 2 ;;
95
115
  --claude-max-attempts) claude_max_attempts="${2:-}"; shift 2 ;;
96
116
  --claude-retry-backoff-seconds) claude_retry_backoff_seconds="${2:-}"; shift 2 ;;
117
+ --claude-allowed-tools) claude_allowed_tools="${2:-}"; shift 2 ;;
97
118
  --env-prefix) env_prefix="${2:-}"; shift 2 ;;
98
119
  --context) context_items+=("${2:-}"); shift 2 ;;
99
120
  --collect-file) collect_files+=("${2:-}"); shift 2 ;;
@@ -156,11 +177,31 @@ meta_file="${artifact_dir}/run.env"
156
177
  result_file="${artifact_dir}/result.env"
157
178
  runner_state_file="${artifact_dir}/runner.env"
158
179
  sandbox_run_dir="${worktree%/}/${sandbox_subdir}/${session}"
180
+ claude_settings_file="${artifact_dir}/claude-headless-settings.json"
181
+ claude_mcp_config_file="${artifact_dir}/claude-headless-mcp.json"
182
+ claude_debug_file="${artifact_dir}/claude-debug.log"
159
183
  started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
160
184
 
161
185
  mkdir -p "$artifact_dir"
162
186
  mkdir -p "$sandbox_run_dir"
163
187
 
188
+ effective_claude_permission_mode="${claude_permission_mode}"
189
+ if [[ "${effective_claude_permission_mode}" == "dontAsk" ]]; then
190
+ effective_claude_permission_mode="acceptEdits"
191
+ fi
192
+
193
+ cat >"$claude_settings_file" <<'EOF'
194
+ {
195
+ "disableAllHooks": true
196
+ }
197
+ EOF
198
+
199
+ cat >"$claude_mcp_config_file" <<'EOF'
200
+ {
201
+ "mcpServers": {}
202
+ }
203
+ EOF
204
+
164
205
  if tmux has-session -t "$session" 2>/dev/null; then
165
206
  echo "tmux session already exists: $session" >&2
166
207
  exit 1
@@ -187,10 +228,15 @@ printf -v started_at_q '%q' "$started_at"
187
228
  printf -v claude_bin_q '%q' "$claude_bin"
188
229
  printf -v claude_model_q '%q' "$claude_model"
189
230
  printf -v claude_permission_mode_q '%q' "$claude_permission_mode"
231
+ printf -v claude_effective_permission_mode_q '%q' "$effective_claude_permission_mode"
190
232
  printf -v claude_effort_q '%q' "$claude_effort"
191
233
  printf -v claude_timeout_q '%q' "$claude_timeout_seconds"
192
234
  printf -v claude_max_attempts_q '%q' "$claude_max_attempts"
193
235
  printf -v claude_retry_backoff_q '%q' "$claude_retry_backoff_seconds"
236
+ printf -v claude_allowed_tools_q '%q' "$claude_allowed_tools"
237
+ printf -v claude_settings_file_q '%q' "$claude_settings_file"
238
+ printf -v claude_mcp_config_file_q '%q' "$claude_mcp_config_file"
239
+ printf -v claude_debug_file_q '%q' "$claude_debug_file"
194
240
  printf -v python_bin_q '%q' "$python_bin"
195
241
  printf -v sandbox_subdir_q '%q' "$sandbox_subdir"
196
242
  printf -v claude_thread_id_q '%q' "claude-print-${session}"
@@ -213,10 +259,15 @@ printf -v claude_thread_id_q '%q' "claude-print-${session}"
213
259
  printf 'CLAUDE_BIN=%s\n' "$claude_bin_q"
214
260
  printf 'CLAUDE_MODEL=%s\n' "$claude_model_q"
215
261
  printf 'CLAUDE_PERMISSION_MODE=%s\n' "$claude_permission_mode_q"
262
+ printf 'CLAUDE_EFFECTIVE_PERMISSION_MODE=%s\n' "$claude_effective_permission_mode_q"
216
263
  printf 'CLAUDE_EFFORT=%s\n' "$claude_effort_q"
217
264
  printf 'CLAUDE_TIMEOUT_SECONDS=%s\n' "$claude_timeout_q"
218
265
  printf 'CLAUDE_MAX_ATTEMPTS=%s\n' "$claude_max_attempts_q"
219
266
  printf 'CLAUDE_RETRY_BACKOFF_SECONDS=%s\n' "$claude_retry_backoff_q"
267
+ printf 'CLAUDE_ALLOWED_TOOLS=%s\n' "$claude_allowed_tools_q"
268
+ printf 'CLAUDE_SETTINGS_FILE=%s\n' "$claude_settings_file_q"
269
+ printf 'CLAUDE_MCP_CONFIG_FILE=%s\n' "$claude_mcp_config_file_q"
270
+ printf 'CLAUDE_DEBUG_FILE=%s\n' "$claude_debug_file_q"
220
271
  printf 'PYTHON_BIN=%s\n' "$python_bin_q"
221
272
  } >"$meta_file"
222
273
 
@@ -315,7 +366,7 @@ fi
315
366
 
316
367
  reconcile_snippet=""
317
368
  if [[ -n "$reconcile_command" ]]; then
318
- printf -v delayed_reconcile_q '%q' "sleep 2; $reconcile_command"
369
+ 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"
319
370
  reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
320
371
  fi
321
372
 
@@ -334,14 +385,20 @@ host_result_file=${result_q}
334
385
  claude_bin=${claude_bin_q}
335
386
  claude_model=${claude_model_q}
336
387
  claude_permission_mode=${claude_permission_mode_q}
388
+ claude_effective_permission_mode=${claude_effective_permission_mode_q}
337
389
  claude_effort=${claude_effort_q}
338
390
  claude_timeout_seconds=${claude_timeout_q}
339
391
  claude_max_attempts=${claude_max_attempts_q}
340
392
  claude_retry_backoff_seconds=${claude_retry_backoff_q}
393
+ claude_allowed_tools=${claude_allowed_tools_q}
394
+ claude_settings_file=${claude_settings_file_q}
395
+ claude_mcp_config_file=${claude_mcp_config_file_q}
396
+ claude_debug_file=${claude_debug_file_q}
341
397
  python_bin=${python_bin_q}
342
398
  worktree_root=${worktree_q}
343
399
  sandbox_subdir=${sandbox_subdir_q}
344
400
  prompt_file=${prompt_q}
401
+ export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
345
402
 
346
403
  write_state() {
347
404
  local runner_state="\${1:?runner state required}"
@@ -370,48 +427,119 @@ write_state() {
370
427
 
371
428
  run_with_timeout() {
372
429
  local timeout_seconds="\${1:?timeout seconds required}"
430
+ local stdin_file="\${2:?stdin file required}"
431
+ shift
373
432
  shift
374
433
 
375
- "\${python_bin}" - "\${timeout_seconds}" "\$@" <<'PY'
434
+ "\${python_bin}" - "\${timeout_seconds}" "\${stdin_file}" "\$@" <<'PY'
435
+ import errno
436
+ import fcntl
376
437
  import os
438
+ import selectors
377
439
  import signal
378
440
  import subprocess
379
441
  import sys
442
+ import time
380
443
 
381
444
  timeout_seconds = float(sys.argv[1])
382
- argv = sys.argv[2:]
445
+ stdin_path = sys.argv[2]
446
+ argv = sys.argv[3:]
383
447
 
384
448
  if not argv:
385
449
  sys.exit(64)
386
450
 
387
- proc = subprocess.Popen(argv, start_new_session=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
451
+ stdin_handle = open(stdin_path, "rb")
452
+ proc = subprocess.Popen(
453
+ argv,
454
+ start_new_session=True,
455
+ stdin=stdin_handle,
456
+ stdout=subprocess.PIPE,
457
+ stderr=subprocess.PIPE,
458
+ )
388
459
 
389
- try:
390
- stdout, stderr = proc.communicate(timeout=timeout_seconds)
391
- except subprocess.TimeoutExpired:
460
+ for stream in (proc.stdout, proc.stderr):
461
+ if stream is None:
462
+ continue
463
+ flags = fcntl.fcntl(stream.fileno(), fcntl.F_GETFL)
464
+ fcntl.fcntl(stream.fileno(), fcntl.F_SETFL, flags | os.O_NONBLOCK)
465
+
466
+ selector = selectors.DefaultSelector()
467
+ if proc.stdout is not None:
468
+ selector.register(proc.stdout, selectors.EVENT_READ, sys.stdout.buffer)
469
+ if proc.stderr is not None:
470
+ selector.register(proc.stderr, selectors.EVENT_READ, sys.stderr.buffer)
471
+
472
+ def terminate_process_group(sig):
392
473
  try:
393
- os.killpg(proc.pid, signal.SIGTERM)
474
+ os.killpg(proc.pid, sig)
394
475
  except ProcessLookupError:
395
- pass
396
- try:
397
- stdout, stderr = proc.communicate(timeout=2)
398
- except subprocess.TimeoutExpired:
476
+ return
477
+
478
+ def drain_streams(wait_seconds):
479
+ events = selector.select(wait_seconds)
480
+ for key, _ in events:
399
481
  try:
400
- os.killpg(proc.pid, signal.SIGKILL)
401
- except ProcessLookupError:
402
- pass
403
- stdout, stderr = proc.communicate()
404
- if stdout:
405
- sys.stdout.buffer.write(stdout)
406
- if stderr:
407
- sys.stderr.buffer.write(stderr)
408
- sys.exit(124)
482
+ chunk = key.fileobj.read()
483
+ except BlockingIOError:
484
+ continue
485
+ except OSError as exc:
486
+ if exc.errno == errno.EAGAIN:
487
+ continue
488
+ raise
489
+ if not chunk:
490
+ selector.unregister(key.fileobj)
491
+ continue
492
+ key.data.write(chunk)
493
+ key.data.flush()
494
+
495
+ def handle_parent_signal(signum, _frame):
496
+ terminate_process_group(signal.SIGTERM)
497
+ deadline = time.monotonic() + 2.0
498
+ while proc.poll() is None and time.monotonic() < deadline:
499
+ drain_streams(0.1)
500
+ if proc.poll() is None:
501
+ terminate_process_group(signal.SIGKILL)
502
+ while selector.get_map():
503
+ drain_streams(0)
504
+ sys.exit(128 + signum)
505
+
506
+ for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
507
+ signal.signal(signum, handle_parent_signal)
508
+
509
+ deadline = time.monotonic() + timeout_seconds
510
+ grace_deadline = None
511
+ timed_out = False
409
512
 
410
- if stdout:
411
- sys.stdout.buffer.write(stdout)
412
- if stderr:
413
- sys.stderr.buffer.write(stderr)
414
- sys.exit(proc.returncode)
513
+ try:
514
+ while True:
515
+ now = time.monotonic()
516
+ if not timed_out and now >= deadline:
517
+ timed_out = True
518
+ grace_deadline = now + 2.0
519
+ terminate_process_group(signal.SIGTERM)
520
+ elif timed_out and grace_deadline is not None and proc.poll() is None and now >= grace_deadline:
521
+ grace_deadline = None
522
+ terminate_process_group(signal.SIGKILL)
523
+
524
+ wait_seconds = 0.1
525
+ if not timed_out:
526
+ wait_seconds = max(0.0, min(0.1, deadline - now))
527
+ elif grace_deadline is not None:
528
+ wait_seconds = max(0.0, min(0.1, grace_deadline - now))
529
+
530
+ drain_streams(wait_seconds)
531
+
532
+ if proc.poll() is not None and not selector.get_map():
533
+ break
534
+ finally:
535
+ while selector.get_map():
536
+ drain_streams(0)
537
+
538
+ if timed_out and proc.returncode is None:
539
+ sys.exit(124)
540
+ if timed_out:
541
+ sys.exit(124)
542
+ sys.exit(proc.wait())
415
543
  PY
416
544
  }
417
545
 
@@ -528,28 +656,30 @@ HOOK_EOF
528
656
  }
529
657
 
530
658
  classify_failure_reason() {
531
- local log_file="\${1:-\${output_file}}"
532
-
533
- if grep -Eiq 'authentication|unauthorized|login required|invalid api key|api key' "\${log_file}" 2>/dev/null; then
534
- printf 'auth-failure\n'
535
- return 0
536
- fi
537
- if grep -Eiq 'rate limit|quota exceeded|insufficient credits|payment required|429' "\${log_file}" 2>/dev/null; then
538
- printf 'provider-quota-limit\n'
539
- return 0
540
- fi
541
- if grep -Eiq 'model .* not available|unsupported model|invalid model|model not found' "\${log_file}" 2>/dev/null; then
542
- printf 'model-unavailable\n'
543
- return 0
544
- fi
545
- if grep -Eiq 'connection reset|connection error|network error|temporarily unavailable|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN' "\${log_file}" 2>/dev/null; then
546
- printf 'network-connection\n'
547
- return 0
548
- fi
549
- if grep -Eiq 'timeout|timed out|ETIMEDOUT' "\${log_file}" 2>/dev/null; then
550
- printf 'timeout\n'
551
- return 0
552
- fi
659
+ local log_file=""
660
+ for log_file in "\$@"; do
661
+ [[ -n "\${log_file}" && -f "\${log_file}" ]] || continue
662
+ if grep -Eiq 'authentication|unauthorized|login required|invalid api key|api key' "\${log_file}" 2>/dev/null; then
663
+ printf 'auth-failure\n'
664
+ return 0
665
+ fi
666
+ if grep -Eiq 'rate limit|quota exceeded|insufficient credits|payment required|429' "\${log_file}" 2>/dev/null; then
667
+ printf 'provider-quota-limit\n'
668
+ return 0
669
+ fi
670
+ if grep -Eiq 'model .* not available|unsupported model|invalid model|model not found' "\${log_file}" 2>/dev/null; then
671
+ printf 'model-unavailable\n'
672
+ return 0
673
+ fi
674
+ if grep -Eiq 'connection reset|connection error|network error|temporarily unavailable|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN' "\${log_file}" 2>/dev/null; then
675
+ printf 'network-connection\n'
676
+ return 0
677
+ fi
678
+ if grep -Eiq 'timeout|timed out|ETIMEDOUT' "\${log_file}" 2>/dev/null; then
679
+ printf 'timeout\n'
680
+ return 0
681
+ fi
682
+ done
553
683
  printf 'claude-exit-failed\n'
554
684
  }
555
685
 
@@ -580,17 +710,23 @@ reset_sandbox_run_dir
580
710
  ensure_workspace_excludes
581
711
  install_pre_commit_scope_hook
582
712
 
583
- prompt_payload="\$(cat "\${prompt_file}")"
584
713
  claude_args=(
585
714
  -p
586
715
  --output-format text
716
+ --verbose
717
+ --debug-file "\${claude_debug_file}"
587
718
  --no-session-persistence
588
- --permission-mode "\${claude_permission_mode}"
719
+ --permission-mode "\${claude_effective_permission_mode}"
720
+ --allowed-tools "\${claude_allowed_tools}"
721
+ --disable-slash-commands
722
+ --strict-mcp-config
723
+ --mcp-config "\${claude_mcp_config_file}"
724
+ --settings "\${claude_settings_file}"
589
725
  --model "\${claude_model}"
590
726
  --effort "\${claude_effort}"
591
727
  --add-dir ${worktree_q}
592
728
  )
593
- if [[ "\${claude_permission_mode}" == "bypassPermissions" ]]; then
729
+ if [[ "\${claude_effective_permission_mode}" == "bypassPermissions" ]]; then
594
730
  claude_args+=(--allow-dangerously-skip-permissions)
595
731
  fi
596
732
 
@@ -602,7 +738,7 @@ while (( attempt <= claude_max_attempts )); do
602
738
  attempt_log_file="\${artifact_dir}/claude-attempt-\${attempt}.log"
603
739
  write_state running '' '' "\${attempt}" "\$((attempt - 1))"
604
740
  printf '\n[claude-attempt] %s/%s\n' "\${attempt}" "\${claude_max_attempts}" | tee -a "\${output_file}" >/dev/null
605
- run_with_timeout "\${claude_timeout_seconds}" "\${claude_bin}" "\${claude_args[@]}" "\${prompt_payload}" >"\${attempt_log_file}" 2>&1
741
+ run_with_timeout "\${claude_timeout_seconds}" "\${prompt_file}" "\${claude_bin}" "\${claude_args[@]}" >"\${attempt_log_file}" 2>&1
606
742
  status=\$?
607
743
  cat "\${attempt_log_file}" >>"\${output_file}"
608
744
  if [[ "\${status}" -eq 0 ]]; then
@@ -612,7 +748,7 @@ while (( attempt <= claude_max_attempts )); do
612
748
  if [[ "\${status}" -eq 124 ]]; then
613
749
  failure_reason="timeout"
614
750
  else
615
- failure_reason="\$(classify_failure_reason "\${attempt_log_file}")"
751
+ failure_reason="\$(classify_failure_reason "\${attempt_log_file}" "\${claude_debug_file}")"
616
752
  fi
617
753
  if (( attempt >= claude_max_attempts )) || ! is_retryable_failure_reason "\${failure_reason}"; then
618
754
  break
@@ -629,6 +765,8 @@ if [[ -f "\${result_file_path}" ]]; then
629
765
  else
630
766
  if [[ "\${status}" -eq 0 ]]; then
631
767
  write_result_fallback "missing-result-contract"
768
+ elif [[ "\${status}" -ne 124 && -n "\${failure_reason}" && "\${failure_reason}" != "claude-exit-failed" ]]; then
769
+ write_result_fallback "\${failure_reason}"
632
770
  else
633
771
  write_result_fallback "worker-exit-\${status}"
634
772
  fi
@@ -256,7 +256,7 @@ fi
256
256
 
257
257
  reconcile_snippet=""
258
258
  if [[ -n "$reconcile_command" ]]; then
259
- printf -v delayed_reconcile_q '%q' "sleep 2; $reconcile_command"
259
+ 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"
260
260
  reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
261
261
  fi
262
262