aiur-cli 0.0.4 → 0.0.5-nightly.cd08c27

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.
@@ -35,19 +35,180 @@ elif [ -n "${__aiur_hard_nofile}" ]; then
35
35
  fi
36
36
  unset __aiur_hard_nofile
37
37
 
38
+ # Export the effective soft limit after the best-effort raise. The BEAM uses
39
+ # this inherited value for FD-headroom admission on hosts without procfs,
40
+ # avoiding a runtime `ulimit` subprocess precisely when descriptors are scarce.
41
+ __aiur_soft_nofile="$(ulimit -Sn 2>/dev/null || echo)"
42
+ if [[ "$__aiur_soft_nofile" =~ ^[0-9]+$ ]]; then
43
+ export AIUR_NOFILE_SOFT_LIMIT="$__aiur_soft_nofile"
44
+ else
45
+ unset AIUR_NOFILE_SOFT_LIMIT
46
+ fi
47
+ unset __aiur_soft_nofile
48
+
49
+ # Preserve the shell that initiated the run as a best-effort Executor root.
50
+ # An explicit positive override wins (service managers may know a better root);
51
+ # otherwise the engine's parent is the nearest identity available before tmux
52
+ # hands daemon ownership to its pane launcher.
53
+ if ! [[ "${AIUR_OPERATOR_PID:-}" =~ ^[1-9][0-9]*$ ]]; then
54
+ if [[ "${PPID:-}" =~ ^[1-9][0-9]*$ ]]; then
55
+ export AIUR_OPERATOR_PID="$PPID"
56
+ else
57
+ unset AIUR_OPERATOR_PID
58
+ fi
59
+ fi
60
+
38
61
  die() {
39
62
  echo "❌ $*" >&2
40
63
  exit 1
41
64
  }
42
65
 
66
+ legacy_config_path() {
67
+ while [ "$#" -gt 0 ]; do
68
+ case "$1" in
69
+ --logs-root | --host | --port | --max-agents)
70
+ if [ "$#" -ge 2 ]; then
71
+ shift 2
72
+ else
73
+ shift
74
+ fi
75
+ ;;
76
+ --)
77
+ shift
78
+ [ "$#" -gt 0 ] || return
79
+ case "$1" in
80
+ *.aiurconfig) printf '%s' "$1" ;;
81
+ esac
82
+ return
83
+ ;;
84
+ --logs-root=* | --host=* | --port=* | --max-agents=* | --*)
85
+ shift
86
+ ;;
87
+ *.aiurconfig)
88
+ printf '%s' "$1"
89
+ return
90
+ ;;
91
+ *)
92
+ # A positional non-legacy config is authoritative. Let the Elixir CLI
93
+ # validate it instead of rejecting an unrelated ambient legacy file.
94
+ return
95
+ ;;
96
+ esac
97
+ done
98
+
99
+ local target_root="${AIUR_REPO_ROOT:-}" home_real
100
+ home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
101
+
102
+ if [ -n "$target_root" ]; then
103
+ if [ -f "$target_root/.aiur/config" ]; then
104
+ return
105
+ fi
106
+ if [ -f "$target_root/.aiurconfig" ]; then
107
+ printf '%s' "$target_root/.aiurconfig"
108
+ return
109
+ fi
110
+ else
111
+ local pwd_real d
112
+ pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
113
+ d="$pwd_real"
114
+
115
+ while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
116
+ if [ -f "$d/.aiur/config" ]; then
117
+ return
118
+ fi
119
+ if [ -f "$d/.aiurconfig" ]; then
120
+ printf '%s' "$d/.aiurconfig"
121
+ return
122
+ fi
123
+ d="$(dirname "$d")"
124
+ done
125
+ fi
126
+
127
+ if [ -n "$home_real" ] && [ ! -f "$home_real/.aiur/config" ] && [ -f "$home_real/.aiurconfig" ]; then
128
+ printf '%s' "$home_real/.aiurconfig"
129
+ fi
130
+ }
131
+
132
+ reject_legacy_config() {
133
+ local legacy legacy_basename canonical
134
+ legacy="$(legacy_config_path "$@")"
135
+ [ -z "$legacy" ] && return
136
+ legacy_basename="${legacy##*/}"
137
+
138
+ if [ "$legacy_basename" = ".aiurconfig" ]; then
139
+ canonical="$(dirname "$legacy")/.aiur/config"
140
+ else
141
+ canonical="${legacy%.aiurconfig}.yaml"
142
+ fi
143
+
144
+ die "$legacy is no longer supported. Move it to $canonical. Keep relative prompt_file and hooks_file paths valid from the new config directory."
145
+ }
146
+
43
147
  engine_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44
148
 
149
+ package_version() {
150
+ local package_file="$1"
151
+ [ -r "$package_file" ] || return 1
152
+ sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$package_file" | head -n 1
153
+ }
154
+
155
+ cli_package_version() {
156
+ package_version "$engine_dir/../package.json"
157
+ }
158
+
159
+ version_is_older() {
160
+ local installed="$1" available="$2"
161
+ local installed_major installed_minor installed_patch installed_pre
162
+ local available_major available_minor available_patch available_pre
163
+
164
+ if [[ ! "$installed" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z.-]+))?(\+[0-9A-Za-z.-]+)?$ ]]; then return 1; fi
165
+ installed_major="${BASH_REMATCH[1]}"
166
+ installed_minor="${BASH_REMATCH[2]}"
167
+ installed_patch="${BASH_REMATCH[3]}"
168
+ installed_pre="${BASH_REMATCH[5]}"
169
+
170
+ if [[ ! "$available" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z.-]+))?(\+[0-9A-Za-z.-]+)?$ ]]; then return 1; fi
171
+ available_major="${BASH_REMATCH[1]}"
172
+ available_minor="${BASH_REMATCH[2]}"
173
+ available_patch="${BASH_REMATCH[3]}"
174
+ available_pre="${BASH_REMATCH[5]}"
175
+
176
+ [ "$installed_major" -lt "$available_major" ] ||
177
+ { [ "$installed_major" -eq "$available_major" ] && [ "$installed_minor" -lt "$available_minor" ]; } ||
178
+ { [ "$installed_major" -eq "$available_major" ] && [ "$installed_minor" -eq "$available_minor" ] && [ "$installed_patch" -lt "$available_patch" ]; } ||
179
+ {
180
+ [ "$installed_major" -eq "$available_major" ] &&
181
+ [ "$installed_minor" -eq "$available_minor" ] &&
182
+ [ "$installed_patch" -eq "$available_patch" ] &&
183
+ {
184
+ { [ -n "$installed_pre" ] && [ -z "$available_pre" ]; } ||
185
+ { [ -n "$installed_pre" ] && [ -n "$available_pre" ] && [[ "$installed_pre" < "$available_pre" ]]; }
186
+ }
187
+ }
188
+ }
189
+
190
+ warn_if_cli_behind_release_checkout() {
191
+ local stamp="${AIUR_RELEASE_DIR:-}/AIUR_BUILD_STAMP"
192
+ local repo_root installed_version checkout_version
193
+
194
+ [ -r "$stamp" ] || return 0
195
+ repo_root="$(sed -n 's/^repo_root=//p' "$stamp" | head -n 1)"
196
+ [ -n "$repo_root" ] || return 0
197
+
198
+ installed_version="$(cli_package_version || true)"
199
+ checkout_version="$(package_version "$repo_root/packaging/npm/aiur-cli/package.json" || true)"
200
+
201
+ if version_is_older "$installed_version" "$checkout_version"; then
202
+ echo "aiur: installed CLI $installed_version is older than checkout CLI $checkout_version; update aiur-cli before retrying" >&2
203
+ fi
204
+ }
205
+
45
206
  # --- distribution identity (per-instance: keyed by the aiur project root) -----
46
207
 
47
208
  # The aiur project root used to key this instance. AIUR_REPO_ROOT (set by the dev
48
209
  # shim) wins. Otherwise walk up from $PWD to the first dir holding a REPO-LOCAL
49
- # config — but the walk STOPS at $HOME: the global config at ~/.aiur/config (legacy
50
- # ~/.aiurconfig) is not a repo root, and treating it as one would collapse every
210
+ # config — but the walk STOPS at $HOME: the global config at ~/.aiur/config is
211
+ # not a repo root, and treating it as one would collapse every
51
212
  # project under $HOME onto one key (#443). When no repo-local config is found, the
52
213
  # BEAM serves this run via the global config (mirroring its discovery order in
53
214
  # src/lib/aiur/workflow.ex) — or via none — and in both cases the project being
@@ -71,7 +232,7 @@ aiur_project_root() {
71
232
 
72
233
  local d="$pwd_real"
73
234
  while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
74
- if [ -f "$d/.aiur/config" ] || [ -f "$d/.aiurconfig" ]; then
235
+ if [ -f "$d/.aiur/config" ]; then
75
236
  printf '%s' "$d"
76
237
  return
77
238
  fi
@@ -90,7 +251,7 @@ aiur_project_root_source() {
90
251
 
91
252
  local d="$pwd_real"
92
253
  while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
93
- if [ -f "$d/.aiur/config" ] || [ -f "$d/.aiurconfig" ]; then
254
+ if [ -f "$d/.aiur/config" ]; then
94
255
  printf 'repo'
95
256
  return
96
257
  fi
@@ -213,16 +374,35 @@ release_dir=""
213
374
  vsn_dir=""
214
375
  release_bin=""
215
376
 
377
+ control_release_retry() {
378
+ [ -n "${AIUR_CONTROL_RELEASE_RETRY_SIGNAL:-}" ] && : >"$AIUR_CONTROL_RELEASE_RETRY_SIGNAL"
379
+ return 75
380
+ }
381
+
216
382
  resolve_release() {
217
383
  release_dir="${AIUR_RELEASE_DIR:-}"
218
384
  [ -n "$release_dir" ] || die "AIUR_RELEASE_DIR is not set; the engine must be invoked via the aiur or aiurdev wrapper"
219
- [ -d "$release_dir" ] || die "AIUR_RELEASE_DIR does not exist: $release_dir"
385
+ if [ ! -d "$release_dir" ]; then
386
+ # aiurdev uses EX_TEMPFAIL to wait out an in-place dev rebuild and retry
387
+ # exactly once. Product launches keep the existing fatal diagnostics.
388
+ if [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ]; then
389
+ control_release_retry
390
+ return $?
391
+ fi
392
+ die "AIUR_RELEASE_DIR does not exist: $release_dir"
393
+ fi
220
394
 
221
395
  local release_vsn
222
- release_vsn="$(cut -d' ' -f2 "$release_dir/releases/start_erl.data")"
396
+ release_vsn="$(cut -d' ' -f2 "$release_dir/releases/start_erl.data" 2>/dev/null || true)"
223
397
  vsn_dir="$release_dir/releases/$release_vsn"
224
398
  release_bin="$release_dir/bin/aiur"
225
- [ -x "$vsn_dir/elixir" ] || die "release elixir launcher not found at $vsn_dir/elixir"
399
+ if [ -z "$release_vsn" ] || [ ! -x "$release_bin" ] || [ ! -x "$vsn_dir/elixir" ]; then
400
+ if [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ]; then
401
+ control_release_retry
402
+ return $?
403
+ fi
404
+ die "release elixir launcher not found at $vsn_dir/elixir"
405
+ fi
226
406
  }
227
407
 
228
408
  # --- argv round-trip (System.argv is empty under `elixir --eval`) -------------
@@ -267,17 +447,39 @@ build_init_cmd() {
267
447
 
268
448
  usage() {
269
449
  cat <<'EOF'
270
- Usage: aiur [--interactive] [--max-agents <n>] [--logs-root <path>] [--port <port>] [--host <host>] [path-to-.aiurconfig]
271
- aiur init [--force] scaffold .aiurconfig (interactive setup wizard)
272
- aiur --bg start in a lean, headless detached tmux session
450
+ Usage: aiur [--interactive] [--no-dashboard] [--executor] [--pause] [--max-agents <n>] [--logs-root <path>] [--port <port>] [--host <host>] [config-path]
451
+ aiur run [--bg] [--no-dashboard] [--executor] [--debug] explicit launch form (foreground unless --bg)
452
+ aiur init [--force] scaffold .aiur/config (interactive setup wizard)
453
+ aiur --bg [--no-dashboard] [--executor] [--debug] start detached; dashboard on unless suppressed
273
454
  aiur stop stop the running session
455
+ aiur restart [--no-build] [run flags] stop, refresh the build, start again (detached)
274
456
  aiur status show agent status
275
457
  aiur agents show each agent's state + current activity
458
+ aiur commands [<decision-id>] [--filter all|open|blocking|resolved] [--blocking] [--ticket <id>] [--search <text>] [--cursor <cursor>] [--limit <n>] [--json]
459
+ aiur executor-answer <decision-id> --expected-version <n> (--option <id>|--custom-response <text>) --rationale <text> --idempotency-key <key> [--executor-id <id>]
460
+ aiur executor-escalate <decision-id> --expected-version <n> --reason <text> [--executor-id <id>]
461
+ aiur units [--scope live|unfinished|all|none] [--condition active|alert|paused|queued|finished]... [--format auto|table|records] [--json]
462
+ aiur build-orders [<root>] [--json] show the Build Order catalog or one root
463
+ aiur analytics [--range run|full] [--since <ISO-8601>] [--until <ISO-8601>] [--build-order <id>] [--json]
276
464
  aiur alerts [--needs-attention] show structured alert feed
277
465
  aiur watch [--full|--changes] [--interval <secs>] server-side status board
466
+ aiur executor-listen [--topic <pattern>] stream Executor events as JSON lines
467
+ aiur executor-wait [--timeout <seconds>] [--json] block until Executor work arrives
468
+ aiur executor-emit <topic> --payload <json> publish an Executor event
469
+ aiur executor-subscribe|executor-unsubscribe <pattern>
470
+ aiur executor-subscriptions list persistent Executor bindings
278
471
  aiur set max-agents <n> change the concurrent-agent cap at runtime
279
- aiur pause <ids|--all> | resume <ids|--all>
280
- aiur message <id> <text> send operator text to a running agent
472
+ aiur pause | resume flip the global pause switch (whole daemon)
473
+ aiur pause <ids|--all> | resume <ids|--all> per-agent pause/resume
474
+ aiur message <id> <text> send Executor text to a running agent
475
+ aiur --todo <ids...> [--only] queue tickets; optionally dequeue all other pending tickets
476
+ aiur findings [--unfiled] [--slugs] [--scope aiur|repo] inspect host-local findings
477
+ aiur findings --record <json> --repo <owner/repo> append one validated finding
478
+ aiur findings --digest [--scope aiur|repo] generate the promoted Markdown digest
479
+ aiur guard-pr-deletions [base-branch] refuse PRs with excessive untouched deletions
480
+ aiur ask <title> [--body <text>|--body-file <path>] [--urgency low|normal|high] [--blocking]
481
+ aiur ask --done <id> [--note <text>] create or resolve an operator request
482
+ aiur asks [--open|--all] [--json] inspect current-repository operator requests
281
483
  aiur cleanup-stale [--dry-run] list/reap stale manual-smoke leftovers
282
484
  aiur --version
283
485
  EOF
@@ -287,6 +489,8 @@ EOF
287
489
 
288
490
  run_version() {
289
491
  resolve_release
492
+ AIUR_CLI_VERSION="$(cli_package_version || true)"
493
+ export AIUR_CLI_VERSION
290
494
  # Distribution-free (like init): printing the version is a compile-time
291
495
  # constant, so never claim the node name — otherwise `aiur --version` fails
292
496
  # whenever an aiur session is already running.
@@ -308,16 +512,81 @@ run_init() {
308
512
  exec "${release_cmd[@]}"
309
513
  }
310
514
 
515
+ # --- one-shot: --todo (control RPC; requires a running daemon) ----------------
516
+
517
+ parsed_todo_only=0
518
+
519
+ parse_todo_args() {
520
+ parsed_targets=()
521
+ parsed_todo_only=0
522
+
523
+ local saw_todo=0 raw part parts
524
+ for raw in "$@"; do
525
+ case "$raw" in
526
+ --todo)
527
+ [ "$saw_todo" -eq 0 ] || return 1
528
+ saw_todo=1
529
+ ;;
530
+ --only)
531
+ parsed_todo_only=1
532
+ ;;
533
+ -*)
534
+ return 1
535
+ ;;
536
+ *)
537
+ IFS=',' read -ra parts <<<"$raw"
538
+ for part in "${parts[@]}"; do
539
+ part="$(trim "$part")"
540
+ if [ -z "$part" ] || [[ ! "$part" =~ ^[0-9]+$ ]]; then return 1; fi
541
+ while [ "${#part}" -gt 1 ] && [ "${part#0}" != "$part" ]; do
542
+ part="${part#0}"
543
+ done
544
+ parsed_targets+=("$part")
545
+ done
546
+ ;;
547
+ esac
548
+ done
549
+
550
+ [ "$saw_todo" -eq 1 ] && [ "${#parsed_targets[@]}" -gt 0 ]
551
+ }
552
+
553
+ run_todo() {
554
+ if ! parse_todo_args "$@"; then
555
+ echo "aiur: --todo expects one or more numeric issue IDs, optionally followed by --only" >&2
556
+ exit 64
557
+ fi
558
+
559
+ local only_arg="false"
560
+ [ "$parsed_todo_only" -eq 1 ] && only_arg="true"
561
+ run_control_rpc "Aiur.AgentControlCLI.todo($(elixir_list_literal "${parsed_targets[@]}"), only: $only_arg, emit_exit_marker: true)"
562
+ }
563
+
564
+ # --- one-shot: findings (distribution-free, no daemon/tmux) -------------------
565
+
566
+ run_findings() {
567
+ run_init "$@"
568
+ }
569
+
570
+ # --- one-shot: ask / asks (distribution-free, no daemon/tmux) ----------------
571
+
572
+ run_asks() {
573
+ run_init "$@"
574
+ }
575
+
311
576
  # --- interactive / background run -------------------------------------------
312
577
  #
313
578
  # mode=foreground attaches the UI and tears down on exit; mode=background leaves
314
579
  # the detached tmux session running and returns.
315
580
 
316
- # Load KEY=VALUE pairs from ./.env into the environment so the running release
317
- # (e.g. GITHUB_TOKEN, dashboard creds) sees what `aiur init` scaffolded there.
318
- # An already-exported variable always wins, so a shell export overrides the file.
581
+ # Load operator/machine credentials before repo-local settings. Since each file
582
+ # only fills unset names, shell exports win first, then ~/.aiur/.env, then ./.env.
319
583
  load_dotenv() {
320
- local file=".env" line key val
584
+ load_dotenv_file "$HOME/.aiur/.env"
585
+ load_dotenv_file ".env"
586
+ }
587
+
588
+ load_dotenv_file() {
589
+ local file="$1" line key val
321
590
  [ -f "$file" ] || return 0
322
591
  while IFS= read -r line || [ -n "$line" ]; do
323
592
  line="${line#"${line%%[![:space:]]*}"}"
@@ -337,6 +606,56 @@ load_dotenv() {
337
606
  done <"$file"
338
607
  }
339
608
 
609
+ # The readiness token grants the one-shot `aiur init` assessment access that a
610
+ # normal daemon and its child agents must never inherit. Keep dotenv loading
611
+ # generic, then remove this run-only secret before any session process starts.
612
+ scrub_run_only_env() {
613
+ unset AIUR_CI_READINESS_TOKEN
614
+ }
615
+
616
+ run_argv=()
617
+ # Default dashboard bind host. Prefer this machine's Tailscale IPv4 so the
618
+ # dashboard is reachable across the tailnet by default (when config omits it);
619
+ # fall back to loopback when Tailscale is absent, or when dashboard credentials
620
+ # are unset (a non-loopback bind requires them, so we stay on loopback rather
621
+ # than refuse to start). The BEAM applies this below an explicit `server.host`,
622
+ # while an explicit `--host` remains the highest-precedence value.
623
+ default_dashboard_host() {
624
+ local ip=""
625
+ if command -v tailscale >/dev/null 2>&1; then
626
+ ip="$(tailscale ip -4 2>/dev/null | grep -m1 -E '^100\.[0-9]+\.[0-9]+\.[0-9]+$' || true)"
627
+ fi
628
+ if [ -n "$ip" ] && [ -n "${AIUR_DASHBOARD_USERNAME:-}" ] && [ -n "${AIUR_DASHBOARD_PASSWORD:-}" ]; then
629
+ printf '%s' "$ip"
630
+ else
631
+ printf '127.0.0.1'
632
+ fi
633
+ }
634
+
635
+ build_run_argv() {
636
+ local mode="$1"
637
+ shift
638
+
639
+ local has_interactive=0 has_headless=0 has_ack=0 arg
640
+ for arg in "$@"; do
641
+ case "$arg" in
642
+ --interactive) has_interactive=1 ;;
643
+ --headless) has_headless=1 ;;
644
+ --i-understand-that-this-will-be-running-without-the-usual-guardrails) has_ack=1 ;;
645
+ esac
646
+ done
647
+
648
+ local injected=()
649
+ if [ "$mode" = "background" ] && [ "$has_interactive" -eq 0 ]; then
650
+ [ "$has_headless" -eq 1 ] || injected+=(--headless)
651
+ else
652
+ [ "$has_interactive" -eq 1 ] || injected+=(--interactive)
653
+ fi
654
+ [ "$has_ack" -eq 1 ] || injected+=(--i-understand-that-this-will-be-running-without-the-usual-guardrails)
655
+
656
+ run_argv=("${injected[@]}" "$@")
657
+ }
658
+
340
659
  run_session() {
341
660
  local mode="$1"
342
661
  shift
@@ -363,34 +682,25 @@ run_session() {
363
682
  # Pick up GITHUB_TOKEN / dashboard creds the wizard wrote to ./.env so the
364
683
  # running tracker can authenticate. Shell exports still take precedence.
365
684
  load_dotenv
685
+ scrub_run_only_env
686
+ export AIUR_DEFAULT_DASHBOARD_HOST="$(default_dashboard_host)"
366
687
 
367
688
  init_argv_file
368
689
 
369
- # Inject the flags a bare `aiur` needs: loopback bind, UI mode, and the
370
- # no-guardrails ack. Skip any the user already passed. Foreground runs are
371
- # interactive (tmux panes + dashboard); `--bg` runs lean/headless — no panes,
372
- # no dashboard bind, no chat backfill — and is driven over the control RPC
373
- # (status/agents/message/pause/set). `aiur --bg --interactive` opts back into
374
- # the full interactive stack for an attachable background session.
375
- local has_host=0 has_interactive=0 has_headless=0 has_ack=0 arg
376
- for arg in "$@"; do
377
- case "$arg" in
378
- --host | --host=*) has_host=1 ;;
379
- --interactive) has_interactive=1 ;;
380
- --headless) has_headless=1 ;;
381
- --i-understand-that-this-will-be-running-without-the-usual-guardrails) has_ack=1 ;;
382
- esac
690
+ # Supply the flags a bare `aiur` needs: UI mode and the no-guardrails ack.
691
+ # The exported dashboard host fills only a missing config value; an explicit
692
+ # server.host or --host remains authoritative. Foreground runs are
693
+ # interactive; `--bg` runs headless (no panes/chat backfill) and is driven
694
+ # over the control RPC (status/agents/message/pause/set). Dashboard binding is
695
+ # independent: it remains enabled in either mode unless `--no-dashboard` is
696
+ # supplied. `aiur --bg --interactive` opts back into the full terminal stack
697
+ # for an attachable background session.
698
+ build_run_argv "$mode" "$@"
699
+ local no_dashboard=0
700
+ for run_arg in "${run_argv[@]}"; do
701
+ [ "$run_arg" = "--no-dashboard" ] && no_dashboard=1
383
702
  done
384
- local injected=()
385
- [ "$has_host" -eq 1 ] || injected+=(--host 127.0.0.1)
386
- if [ "$mode" = "background" ] && [ "$has_interactive" -eq 0 ]; then
387
- [ "$has_headless" -eq 1 ] || injected+=(--headless)
388
- else
389
- [ "$has_interactive" -eq 1 ] || injected+=(--interactive)
390
- fi
391
- [ "$has_ack" -eq 1 ] || injected+=(--i-understand-that-this-will-be-running-without-the-usual-guardrails)
392
-
393
- write_argv "${injected[@]}" "$@"
703
+ write_argv "${run_argv[@]}"
394
704
  export AIUR_ARGV_FILE="$argv_file"
395
705
 
396
706
  prepare_distribution
@@ -441,6 +751,17 @@ run_session() {
441
751
  export AIUR_LOGS_ROOT
442
752
  fi
443
753
 
754
+ # Capture an erl_crash.dump on daemon BEAM death (#852) so a crash under load
755
+ # is diagnosable instead of vanishing. Written next to the run's aiur.log so it
756
+ # survives the launcher's tempfile cleanup; ERL_CRASH_DUMP_SECONDS bounds the
757
+ # write so a wedged BEAM can't hang the dump indefinitely. Nothing in the
758
+ # release boot disables dumps, and an Executor override of either var is kept.
759
+ # Requires a durable logs root (background run or agent IR sandbox).
760
+ if [ -n "${AIUR_LOGS_ROOT:-}" ]; then
761
+ export ERL_CRASH_DUMP="${ERL_CRASH_DUMP:-$AIUR_LOGS_ROOT/erl_crash.dump}"
762
+ export ERL_CRASH_DUMP_SECONDS="${ERL_CRASH_DUMP_SECONDS:-30}"
763
+ fi
764
+
444
765
  # Capture sink for BEAM startup (and, in background mode, the whole run's
445
766
  # boot stdout/stderr). Foreground uses a throwaway tempfile; background points
446
767
  # at a durable file in the run log dir. The dir/file is created lazily just
@@ -460,13 +781,15 @@ run_session() {
460
781
  {
461
782
  printf '#!/usr/bin/env bash\n'
462
783
  printf 'set -o pipefail\n'
784
+ printf 'unset AIUR_CI_READINESS_TOKEN\n'
463
785
  printf 'cd %q || exit 1\n' "$PWD"
464
786
  local v
465
787
  for v in AIUR_RELEASE_DIR AIUR_ARGV_FILE RELEASE_DISTRIBUTION RELEASE_NODE \
466
788
  RELEASE_COOKIE ERL_AFLAGS ERL_EPMD_ADDRESS AIUR_NODE AIUR_ERLANG_COOKIE \
467
789
  AIUR_TMUX_SESSION AIUR_TMUX_SOCKET AIUR_TMUX_CONF AIUR_BIN \
468
790
  AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE AIUR_WORKSPACE_ROOT_FILE \
469
- ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEBUG; do
791
+ ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEFAULT_DASHBOARD_HOST AIUR_DEBUG \
792
+ AIUR_OPERATOR_PID AIUR_NOFILE_SOFT_LIMIT ERL_CRASH_DUMP ERL_CRASH_DUMP_SECONDS; do
470
793
  if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
471
794
  done
472
795
  printf 'capture=%q\n' "$startup_capture"
@@ -493,7 +816,10 @@ run_session() {
493
816
  # session whose BEAM/control plane is gone should be reclaimed before retry.
494
817
  if [ "$mode" = "background" ] && "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
495
818
  if [ "$(probe_control_liveness)" = "up" ]; then
496
- write_aiur_instance_record "$session" "$socket"
819
+ # Write a record only when none exists. A live session's own start already
820
+ # wrote one — and it owns the BEAM-written AIUR_RECORD_WORKSPACE_ROOT_FILE
821
+ # path, which must not be overwritten by this invocation's temp file.
822
+ [ -f "$(aiur_instance_record_path)" ] || (AIUR_WORKSPACE_ROOT_FILE="" write_aiur_instance_record "$session" "$socket")
497
823
  echo "aiur is already running in the background (tmux session ${session})." >&2
498
824
  echo "Use: aiur status # inspect agents" >&2
499
825
  echo "Use: aiur stop # stop it before starting a fresh session" >&2
@@ -561,6 +887,7 @@ run_session() {
561
887
  fi
562
888
 
563
889
  write_aiur_instance_record "$session" "$socket"
890
+ print_dashboard_status "$no_dashboard" "$startup_capture"
564
891
 
565
892
  if [ "$mode" = "foreground" ]; then
566
893
  echo "aiur foreground tmux socket ${socket}, session ${session}" >&2
@@ -902,7 +1229,7 @@ agent_pid_matches() {
902
1229
  #
903
1230
  # tmux runs on aiur's PRIVATE socket (-L aiur-$USER), so kill-server tears down
904
1231
  # every REPL/chat pane agent in one shot AND leaves no live aiur tmux server —
905
- # never touching the operator's own default tmux. Headless agents (claude/codex
1232
+ # never touching the Executor’s own default tmux. Headless agents (claude/codex
906
1233
  # app-servers spawned via Port) are bare OS processes that reparent to init on a
907
1234
  # BEAM crash; kill-server can't see them, so they're reaped from the pidfile by
908
1235
  # process tree, comm-guarded against pid reuse. Idempotent.
@@ -969,6 +1296,7 @@ write_aiur_instance_record() {
969
1296
  printf 'AIUR_RECORD_INSTANCE_KEY=%q\n' "$AIUR_INSTANCE_KEY"
970
1297
  printf 'AIUR_RECORD_SESSION=%q\n' "$session"
971
1298
  printf 'AIUR_RECORD_SOCKET=%q\n' "$socket"
1299
+ printf 'AIUR_RECORD_WORKSPACE_ROOT_FILE=%q\n' "${AIUR_WORKSPACE_ROOT_FILE:-}"
972
1300
  printf 'AIUR_RECORD_PROJECT_ROOT=%q\n' "$root"
973
1301
  printf 'AIUR_RECORD_PROJECT_ROOT_SOURCE=%q\n' "${AIUR_PROJECT_ROOT_SOURCE:-}"
974
1302
  printf 'AIUR_RECORD_WRITTEN_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
@@ -1121,24 +1449,10 @@ reap_workspace_cwd_from_file() {
1121
1449
  reap_workspace_cwd_agents "$root"
1122
1450
  }
1123
1451
 
1124
- current_workspace_root() {
1125
- resolve_release
1126
- prepare_distribution >/dev/null 2>&1 || return 1
1127
-
1128
- local output status line
1129
- set +e
1130
- output="$("$release_bin" rpc "IO.puts(Aiur.Config.workspace_root())" 2>/dev/null)"
1131
- status=$?
1132
- set -e
1133
- [ "$status" -eq 0 ] || return 1
1134
-
1135
- while IFS= read -r line; do
1136
- case "$line" in
1137
- "" | :ok) continue ;;
1138
- *) printf '%s\n' "$line"; return 0 ;;
1139
- esac
1140
- done <<<"$output"
1141
- return 1
1452
+ workspace_root_file_from_instance_record() {
1453
+ load_aiur_instance_record "$(aiur_instance_record_path)" || return 1
1454
+ [ -n "${AIUR_RECORD_WORKSPACE_ROOT_FILE:-}" ] || return 1
1455
+ printf '%s\n' "$AIUR_RECORD_WORKSPACE_ROOT_FILE"
1142
1456
  }
1143
1457
 
1144
1458
  # Background watchdog that survives the BEAM. Polls for the release BEAM by
@@ -1189,7 +1503,10 @@ start_beam_death_watchdog() {
1189
1503
 
1190
1504
  probe_control_liveness() {
1191
1505
  local expression output status
1192
- expression='case Process.whereis(Aiur.Orchestrator) do pid when is_pid(pid) -> case Aiur.Orchestrator.status(Aiur.Orchestrator, 100) do statuses when is_list(statuses) -> IO.puts("__AIUR_CONTROL_READY__"); _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end; _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end'
1506
+ # The Orchestrator starts before the dashboard child, so an answering status
1507
+ # call alone does not prove startup (or the bind attempt) finished. Wait until
1508
+ # OTP marks :aiur started before printing the effective dashboard status.
1509
+ expression='aiur_started = Enum.any?(Application.started_applications(), fn {app, _description, _version} -> app == :aiur end); case {Process.whereis(Aiur.Orchestrator), aiur_started} do {pid, true} when is_pid(pid) -> case Aiur.Orchestrator.status(Aiur.Orchestrator, 100) do statuses when is_list(statuses) -> IO.puts("__AIUR_CONTROL_READY__"); _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end; _ -> IO.puts("__AIUR_CONTROL_NOT_READY__") end'
1193
1510
 
1194
1511
  set +e
1195
1512
  output="$("$release_bin" rpc "$expression" 2>&1)"
@@ -1203,11 +1520,51 @@ probe_control_liveness() {
1203
1520
  fi
1204
1521
  }
1205
1522
 
1523
+ # Return the useful URL and effective bind host/port only when the running node
1524
+ # confirms that Bandit actually bound a listener. An empty result means the
1525
+ # listener was suppressed or refused; configured server.port alone is never
1526
+ # proof of service.
1527
+ probe_dashboard_status() {
1528
+ local expression output status marker="__AIUR_DASHBOARD_STATUS__:"
1529
+ expression='case {Aiur.HttpServer.base_url(), Aiur.Config.server_host(), Aiur.HttpServer.bound_port()} do {url, host, port} when is_binary(url) and is_binary(host) and is_integer(port) -> IO.puts("__AIUR_DASHBOARD_STATUS__:Dashboard: " <> url <> " (bind host=" <> host <> ", port=" <> Integer.to_string(port) <> ")"); _ -> :ok end'
1530
+
1531
+ set +e
1532
+ run_release_rpc_with_timeout "$expression"
1533
+ status=$?
1534
+ set -e
1535
+ output="$AIUR_CONTROL_RPC_OUTPUT"
1536
+
1537
+ if [ "$status" -eq 0 ] && [[ "$output" == *"$marker"* ]]; then
1538
+ output="${output#*"$marker"}"
1539
+ printf '%s' "${output%%$'\n'*}"
1540
+ fi
1541
+ }
1542
+
1543
+ print_dashboard_status() {
1544
+ local no_dashboard="$1" startup_capture="$2" dashboard_status
1545
+
1546
+ if [ "$no_dashboard" -eq 1 ]; then
1547
+ echo "Dashboard disabled by --no-dashboard." >&2
1548
+ return
1549
+ fi
1550
+
1551
+ dashboard_status="$(probe_dashboard_status)"
1552
+ if [ -n "$dashboard_status" ]; then
1553
+ echo "${dashboard_status}" >&2
1554
+ else
1555
+ echo "⚠️ dashboard listener unavailable; inspect ${startup_capture} for bind or authentication refusal." >&2
1556
+ fi
1557
+ }
1558
+
1206
1559
  wait_for_session_startup() {
1207
1560
  local tmux_bin="$1" socket="$2" conf="$3" session="$4" startup_capture="$5" require_control="$6"
1208
1561
  local max_ticks="${AIUR_TMUX_GRACE_TICKS:-30}" tick control_state
1209
1562
  if [ "$require_control" = "1" ]; then
1210
- max_ticks="${AIUR_NODE_GRACE_TICKS:-100}"
1563
+ # 0.1s per tick. The old 100-tick (10s) budget was under a cold control
1564
+ # plane's real boot time, so a healthy daemon was killed mid-startup and
1565
+ # reported as a failure. Boot time grows with the module count; keep the
1566
+ # headroom generous, since the loop exits as soon as the probe says "up".
1567
+ max_ticks="${AIUR_NODE_GRACE_TICKS:-1200}"
1211
1568
  fi
1212
1569
 
1213
1570
  for ((tick = 0; tick < max_ticks; tick++)); do
@@ -1231,7 +1588,16 @@ wait_for_session_startup() {
1231
1588
  done
1232
1589
 
1233
1590
  if [ "$require_control" = "1" ]; then
1234
- echo "❌ aiur control plane did not become ready at ${RELEASE_NODE:-unknown} during startup; captured output:" >&2
1591
+ # A live session at this point means the BEAM never died -- we ran out of
1592
+ # patience, not the daemon out of health. Saying so is the difference
1593
+ # between "your release is broken" and "give it longer", and the empty
1594
+ # capture below is evidence of the latter, not of a silent crash.
1595
+ if "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
1596
+ echo "❌ aiur control plane at ${RELEASE_NODE:-unknown} was still booting after $(awk "BEGIN{printf \"%.0f\", ${max_ticks} / 10}")s; the BEAM is alive but not yet answering." >&2
1597
+ echo " Raise the budget with AIUR_NODE_GRACE_TICKS (ticks of 0.1s) if this box is just slow." >&2
1598
+ else
1599
+ echo "❌ aiur control plane did not become ready at ${RELEASE_NODE:-unknown} during startup; captured output:" >&2
1600
+ fi
1235
1601
  tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
1236
1602
  return 1
1237
1603
  fi
@@ -1364,6 +1730,7 @@ load_aiur_instance_record() {
1364
1730
  AIUR_RECORD_INSTANCE_KEY=""
1365
1731
  AIUR_RECORD_SESSION=""
1366
1732
  AIUR_RECORD_SOCKET=""
1733
+ AIUR_RECORD_WORKSPACE_ROOT_FILE=""
1367
1734
  AIUR_RECORD_PROJECT_ROOT=""
1368
1735
  AIUR_RECORD_PROJECT_ROOT_SOURCE=""
1369
1736
  [ -r "$file" ] || return 1
@@ -1445,15 +1812,14 @@ $root
1445
1812
  }
1446
1813
 
1447
1814
  print_global_config_control_hint() {
1815
+ [ -n "${AIUR_CONTROL_HINT_ROOTS:-}" ] || return 0
1448
1816
  [ "${AIUR_CONTROL_CALLER_ROOT_SOURCE:-}" = "cwd" ] || return 0
1449
1817
  [ "${AIUR_CONTROL_CURRENT_NODE_STATE:-}" = "down" ] || return 0
1450
1818
 
1451
1819
  echo "aiur: global-config control identity is keyed by cwd ${AIUR_CONTROL_CALLER_ROOT:-${AIUR_PROJECT_ROOT:-unknown}}" >&2
1452
1820
  echo "aiur: run control commands from the launch directory, or from a subdirectory of that launch directory" >&2
1453
- if [ -n "${AIUR_CONTROL_HINT_ROOTS:-}" ]; then
1454
- echo "aiur: live launch directory candidate(s):" >&2
1455
- printf '%s' "$AIUR_CONTROL_HINT_ROOTS" | sed '/^$/d; s/^/ /' >&2
1456
- fi
1821
+ echo "aiur: live launch directory candidate(s):" >&2
1822
+ printf '%s' "$AIUR_CONTROL_HINT_ROOTS" | sed '/^$/d; s/^/ /' >&2
1457
1823
  }
1458
1824
 
1459
1825
  control_rpc_timeout_seconds() {
@@ -1464,20 +1830,41 @@ control_rpc_timeout_seconds() {
1464
1830
  printf '%s' "$seconds"
1465
1831
  }
1466
1832
 
1833
+ print_not_running_message() {
1834
+ echo "error: aiur is not running. Start it with \`aiurdev run\` (or \`aiurdev --bg\`), then retry." >&2
1835
+ }
1836
+
1837
+ print_control_down_message() {
1838
+ local crash_marker
1839
+ crash_marker="$(aiur_crash_marker_path)"
1840
+ if [ -f "$crash_marker" ]; then
1841
+ echo "aiur: background daemon at ${RELEASE_NODE} is DOWN after an unexpected exit; agents may be orphaned" >&2
1842
+ sed 's/^/ /' "$crash_marker" >&2 2>/dev/null || true
1843
+ echo "aiur: run 'aiur stop' to reap any orphaned agents, then start aiur again" >&2
1844
+ else
1845
+ print_not_running_message
1846
+ print_global_config_control_hint
1847
+ fi
1848
+ }
1849
+
1467
1850
  kill_control_rpc_process() {
1468
1851
  local pid="$1" grouped="$2" p tree=()
1469
1852
  [ -n "$pid" ] || return 0
1470
1853
 
1854
+ # Snapshot descendants before signalling the wrapper. A release launcher may
1855
+ # fork its rpc BEAM into another process group and then exit; group signalling
1856
+ # alone would orphan that child and lose the only relationship we can reap.
1857
+ while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
1858
+
1471
1859
  if [ "$grouped" = "1" ]; then
1472
1860
  kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
1473
- sleep 0.2
1474
- kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
1475
- return 0
1476
1861
  fi
1477
1862
 
1478
- while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
1479
1863
  for p in "${tree[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
1480
1864
  sleep 0.2
1865
+ if [ "$grouped" = "1" ]; then
1866
+ kill -KILL "-$pid" 2>/dev/null || true
1867
+ fi
1481
1868
  for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
1482
1869
  }
1483
1870
 
@@ -1510,7 +1897,14 @@ run_release_rpc_with_timeout() {
1510
1897
  else
1511
1898
  status=$?
1512
1899
  fi
1513
- kill "$watchdog_pid" 2>/dev/null || true
1900
+ if [ -f "$timeout_file" ]; then
1901
+ # The timeout watchdog owns descendant cleanup. Once it has marked the
1902
+ # timeout, let it finish its TERM/KILL sequence; cancelling it here can
1903
+ # strand descendants that ignored TERM after the root process exits.
1904
+ :
1905
+ else
1906
+ kill "$watchdog_pid" 2>/dev/null || true
1907
+ fi
1514
1908
  wait "$watchdog_pid" 2>/dev/null || true
1515
1909
 
1516
1910
  AIUR_CONTROL_RPC_OUTPUT="$(cat "$output_file" 2>/dev/null || true)"
@@ -1525,19 +1919,66 @@ run_release_rpc_with_timeout() {
1525
1919
  return "$status"
1526
1920
  }
1527
1921
 
1922
+ control_command_label() {
1923
+ printf '%s' "${AIUR_CONTROL_COMMAND:-control rpc}"
1924
+ }
1925
+
1926
+ control_attempt_sentence() {
1927
+ [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ] || return 0
1928
+ printf ' Attempted %s against daemon endpoint %s.' "$AIUR_CONTROL_ATTEMPT_CONTEXT" "${RELEASE_NODE:-unknown}"
1929
+ }
1930
+
1931
+ # Every line a control RPC surfaces to the operator routes through one of these
1932
+ # two helpers, so `run_control_rpc` can prove it never returns non-zero while
1933
+ # saying nothing (#1684): a silent failure is indistinguishable from a healthy
1934
+ # idle fleet, and nobody reads exit codes interactively.
1935
+ control_rpc_say() {
1936
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1937
+ printf '%s\n' "$1" >&2
1938
+ }
1939
+
1940
+ control_rpc_echo_output() {
1941
+ [ -n "$1" ] || return 0
1942
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1943
+ printf '%s\n' "$1" >&2
1944
+ }
1945
+
1528
1946
  # RPC an expression into the running node. The control CLI prints a trailing
1529
1947
  # `__AIUR_CONTROL_EXIT__:<code>` marker we translate into the process exit code.
1948
+ #
1949
+ # Wrapper contract: a non-zero return ALWAYS carries at least one stderr line
1950
+ # naming the command that failed. EX_TEMPFAIL (75) is exempt — it is the dev
1951
+ # shim's internal rebuild-and-retry signal, never an operator-visible outcome.
1530
1952
  run_control_rpc() {
1953
+ local status=0
1954
+
1955
+ AIUR_CONTROL_RPC_DIAGNOSED=0
1956
+ run_control_rpc_dispatch "$@" || status=$?
1957
+
1958
+ if [ "$status" -ne 0 ] && [ "$status" -ne 75 ] && [ "${AIUR_CONTROL_RPC_DIAGNOSED:-0}" -ne 1 ]; then
1959
+ if [ "$status" -eq 124 ]; then
1960
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE:-the daemon} timed out after $(control_rpc_timeout_seconds)s; outcome is unknown.$(control_attempt_sentence) The daemon did not reply within the budget - commonly one blocked process inside it, not host load. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
1961
+ elif [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
1962
+ control_rpc_say "aiur: $(control_command_label) failed (exit ${status}) and produced no diagnostic output.$(control_attempt_sentence) Outcome is unknown. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
1963
+ else
1964
+ control_rpc_say "aiur: $(control_command_label) failed (exit ${status}) and produced no diagnostic output; outcome is unknown. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
1965
+ fi
1966
+ fi
1967
+
1968
+ return "$status"
1969
+ }
1970
+
1971
+ run_control_rpc_dispatch() {
1531
1972
  local expression="$1"
1532
- resolve_release
1973
+ resolve_release || return $?
1533
1974
  prepare_distribution || die "distribution setup failed; cannot contact aiur"
1534
1975
  resolve_control_identity_from_records
1535
1976
  if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
1536
1977
  prepare_distribution || die "distribution setup failed; cannot contact aiur"
1537
1978
  fi
1538
1979
 
1539
- local marker="__AIUR_CONTROL_EXIT__:"
1540
- local output status exit_code=0 saw_marker=0 line
1980
+ local marker="__AIUR_CONTROL_EXIT__:" error_marker="__AIUR_CONTROL_ERROR__:"
1981
+ local output status exit_code=0 saw_marker=0 saw_error=0 saw_output=0 line partial_suffix=""
1541
1982
 
1542
1983
  set +e
1543
1984
  run_release_rpc_with_timeout "$expression"
@@ -1545,9 +1986,15 @@ run_control_rpc() {
1545
1986
  output="$AIUR_CONTROL_RPC_OUTPUT"
1546
1987
  set -e
1547
1988
 
1989
+ if [ "$status" -ne 0 ] && [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ] && \
1990
+ { [ ! -x "$release_bin" ] || [ ! -x "$vsn_dir/elixir" ] || [ ! -r "$release_dir/releases/start_erl.data" ]; }; then
1991
+ control_release_retry
1992
+ return $?
1993
+ fi
1994
+
1548
1995
  if [ "${AIUR_CONTROL_RPC_TIMED_OUT:-0}" -eq 1 ]; then
1549
- [ -n "$output" ] && printf '%s\n' "$output" >&2
1550
- echo "aiur: control rpc to ${RELEASE_NODE} timed out after $(control_rpc_timeout_seconds)s; helper process was terminated" >&2
1996
+ [ -n "$output" ] && partial_suffix="; partial output was discarded"
1997
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} timed out after $(control_rpc_timeout_seconds)s; outcome is unknown${partial_suffix}.$(control_attempt_sentence) Commonly one blocked process inside the daemon, not host load."
1551
1998
  return 124
1552
1999
  fi
1553
2000
 
@@ -1560,26 +2007,35 @@ run_control_rpc() {
1560
2007
  # node epmd confirms is down earns the friendly "start aiur" hint; an `up`
1561
2008
  # node failed for a real reason, and an `unknown` probe must not be assumed
1562
2009
  # down — in both of those cases surface the actual rpc output, never mask it.
2010
+ #
2011
+ # An empty buffer is its own case: `--rpc-eval` kills itself without a word
2012
+ # when the evaluated expression exits (a GenServer call timing out against a
2013
+ # saturated daemon does exactly that), so "see the error above" would point
2014
+ # at nothing (#1684). Name the failure instead.
1563
2015
  case "$(probe_node_liveness)" in
1564
2016
  down)
1565
- local crash_marker
1566
- crash_marker="$(aiur_crash_marker_path)"
1567
- if [ -f "$crash_marker" ]; then
1568
- echo "aiur: background daemon at ${RELEASE_NODE} is DOWN after an unexpected exit; agents may be orphaned" >&2
1569
- sed 's/^/ /' "$crash_marker" >&2 2>/dev/null || true
1570
- echo "aiur: run 'aiur stop' to reap any orphaned agents, then start aiur again" >&2
1571
- else
1572
- echo "aiur: no running aiur node at ${RELEASE_NODE}; start aiur and try again" >&2
1573
- print_global_config_control_hint
1574
- fi
2017
+ print_control_down_message
2018
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1575
2019
  ;;
1576
2020
  up)
1577
- [ -n "$output" ] && printf '%s\n' "$output" >&2
1578
- echo "aiur: rpc to ${RELEASE_NODE} failed (node is running); see the error above" >&2
2021
+ control_rpc_echo_output "$output"
2022
+ if [ -n "$output" ]; then
2023
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (node is running); see the error above"
2024
+ elif [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2025
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no diagnostic output (node is running).$(control_attempt_sentence) Outcome is unknown. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
2026
+ else
2027
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no diagnostic output (node is running); outcome is unknown. 'aiur alerts' is answered by a different process and can confirm the daemon is alive."
2028
+ fi
1579
2029
  ;;
1580
2030
  *)
1581
- [ -n "$output" ] && printf '%s\n' "$output" >&2
1582
- echo "aiur: rpc to ${RELEASE_NODE} failed (could not query epmd to confirm node state); see the error above" >&2
2031
+ control_rpc_echo_output "$output"
2032
+ if [ -n "$output" ]; then
2033
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (could not query epmd to confirm node state); see the error above"
2034
+ elif [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2035
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no output (could not query epmd to confirm node state).$(control_attempt_sentence) Outcome is unknown."
2036
+ else
2037
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no output (could not query epmd to confirm node state)"
2038
+ fi
1583
2039
  ;;
1584
2040
  esac
1585
2041
  return 1
@@ -1591,19 +2047,63 @@ run_control_rpc() {
1591
2047
  saw_marker=1
1592
2048
  exit_code="${line#"$marker"}"
1593
2049
  ;;
2050
+ "$error_marker"*)
2051
+ saw_error=1
2052
+ AIUR_CONTROL_RPC_DIAGNOSED=1
2053
+ printf '%s\n' "${line#"$error_marker"}" >&2
2054
+ ;;
1594
2055
  :ok | "") ;;
1595
- *) printf '%s\n' "$line" ;;
2056
+ *)
2057
+ saw_output=1
2058
+ AIUR_CONTROL_RPC_DIAGNOSED=1
2059
+ printf '%s\n' "$line"
2060
+ ;;
1596
2061
  esac
1597
2062
  done <<<"$output"
1598
2063
 
1599
2064
  if [ "$saw_marker" -ne 1 ]; then
1600
- echo "aiur: control rpc to ${RELEASE_NODE} returned no exit marker; command output may be incomplete" >&2
2065
+ if [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2066
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} returned no exit marker; command output may be incomplete.$(control_attempt_sentence)"
2067
+ else
2068
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} returned no exit marker; command output may be incomplete"
2069
+ fi
1601
2070
  return 1
1602
2071
  fi
1603
2072
 
2073
+ if [ "$exit_code" -ne 0 ] && [ "$saw_error" -ne 1 ] && [ "$saw_output" -ne 1 ]; then
2074
+ if [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2075
+ control_rpc_say "aiur: $(control_command_label) failed with exit ${exit_code} and produced no diagnostic output.$(control_attempt_sentence) Outcome is unknown."
2076
+ else
2077
+ control_rpc_say "aiur: $(control_command_label) failed with exit ${exit_code} and returned no diagnostic output"
2078
+ fi
2079
+ fi
2080
+
1604
2081
  return "$exit_code"
1605
2082
  }
1606
2083
 
2084
+ # A streaming Executor listener must retain the RPC process and its stdout. It
2085
+ # intentionally bypasses the one-shot control timeout and exit-marker wrapper.
2086
+ run_control_stream() {
2087
+ local expression="$1"
2088
+ resolve_release || return $?
2089
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
2090
+ resolve_control_identity_from_records
2091
+ if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
2092
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
2093
+ fi
2094
+ if [ "$(probe_node_liveness)" = "down" ]; then
2095
+ print_control_down_message
2096
+ return 1
2097
+ fi
2098
+ if "$release_bin" rpc "$expression"; then
2099
+ return 0
2100
+ else
2101
+ local status=$?
2102
+ echo "aiur: streaming control RPC failed with exit ${status}" >&2
2103
+ return "$status"
2104
+ fi
2105
+ }
2106
+
1607
2107
  elixir_list_literal() {
1608
2108
  local first=1 item
1609
2109
  printf '['
@@ -1647,11 +2147,26 @@ cmd_status() {
1647
2147
  run_control_rpc "Aiur.AgentControlCLI.status()"
1648
2148
  }
1649
2149
 
2150
+ # `aiur usage` — Codex/Claude limit headroom from the daemon's meter
2151
+ # projection, each value carrying the age of its observation.
2152
+ cmd_usage() {
2153
+ [ "$#" -eq 0 ] || die "usage does not accept arguments"
2154
+ run_control_rpc "Aiur.AgentControlCLI.usage()"
2155
+ }
2156
+
1650
2157
  cmd_pause_resume() {
1651
2158
  local command="$1"
1652
2159
  shift
2160
+
2161
+ # Bare `aiur pause` / `aiur resume` (no IDs, no --all) flips the single
2162
+ # global pause switch: a daemon-wide halt distinct from per-agent pause.
2163
+ if [ "$#" -eq 0 ]; then
2164
+ run_control_rpc "Aiur.AgentControlCLI.${command}_global()"
2165
+ return
2166
+ fi
2167
+
1653
2168
  if ! parse_issue_targets "$@"; then
1654
- echo "aiur: $command expects issue IDs or --all (e.g. aiur $command 44 45,46; aiur $command --all)" >&2
2169
+ echo "aiur: $command expects issue IDs or --all (e.g. aiur $command 44 45,46; aiur $command --all; or bare aiur $command for the global switch)" >&2
1655
2170
  exit 64
1656
2171
  fi
1657
2172
 
@@ -1665,7 +2180,28 @@ cmd_pause_resume() {
1665
2180
  run_control_rpc "$expression"
1666
2181
  }
1667
2182
 
1668
- # `aiur message <issue> <text>` — deliver operator text to one running agent.
2183
+ # `aiur reset-budget <id>...` — clear the lifetime dispatch latch for one or
2184
+ # more tickets (the supported exit from the #1453 latch; no JSON hand-editing).
2185
+ cmd_reset_budget() {
2186
+ if ! parse_issue_targets "$@"; then
2187
+ echo "aiur: reset-budget expects issue IDs (e.g. aiur reset-budget 44 45,46)" >&2
2188
+ exit 64
2189
+ fi
2190
+
2191
+ # --all is rejected (exit 64 with guidance) rather than silently no-opping:
2192
+ # clearing every ticket's latch at once is not a documented operation and
2193
+ # would mask which tickets are structurally stuck (#1453 review P2d).
2194
+ if [ "$parsed_all" -eq 1 ]; then
2195
+ echo "aiur: reset-budget does not accept --all; name ticket IDs explicitly (e.g. aiur reset-budget 44 45,46)" >&2
2196
+ exit 64
2197
+ fi
2198
+
2199
+ local expression
2200
+ expression="Aiur.AgentControlCLI.reset_budget($(elixir_list_literal "${parsed_targets[@]}"))"
2201
+ run_control_rpc "$expression"
2202
+ }
2203
+
2204
+ # `aiur message <issue> <text>` — deliver Executor text to one running agent.
1669
2205
  # The text is base64-encoded for the RPC hop so arbitrary content (quotes,
1670
2206
  # backslashes, `#{}`, newlines) survives without Elixir-string escaping.
1671
2207
  cmd_message() {
@@ -1696,8 +2232,270 @@ cmd_agents() {
1696
2232
  run_control_rpc "Aiur.AgentControlCLI.agents()"
1697
2233
  }
1698
2234
 
2235
+ # `aiur commands` — read the dashboard's retained Decision projection without
2236
+ # exposing any dispatch or answer mutation path.
2237
+ cmd_commands() {
2238
+ local filter="all" blocking=0 json=0 decision_id="" ticket="" search="" cursor="" limit="" arg
2239
+
2240
+ while [ "$#" -gt 0 ]; do
2241
+ arg="$1"
2242
+ case "$arg" in
2243
+ --filter) [ "$#" -gt 1 ] || { echo "aiur: commands --filter requires a value" >&2; exit 64; }; shift; filter="$1" ;;
2244
+ --filter=*) filter="${arg#--filter=}" ;;
2245
+ --blocking) blocking=1 ;;
2246
+ --ticket) [ "$#" -gt 1 ] || { echo "aiur: commands --ticket requires a value" >&2; exit 64; }; shift; ticket="$1" ;;
2247
+ --ticket=*) ticket="${arg#--ticket=}" ;;
2248
+ --search) [ "$#" -gt 1 ] || { echo "aiur: commands --search requires a value" >&2; exit 64; }; shift; search="$1" ;;
2249
+ --search=*) search="${arg#--search=}" ;;
2250
+ --cursor) [ "$#" -gt 1 ] || { echo "aiur: commands --cursor requires a value" >&2; exit 64; }; shift; cursor="$1" ;;
2251
+ --cursor=*) cursor="${arg#--cursor=}" ;;
2252
+ --limit) [ "$#" -gt 1 ] || { echo "aiur: commands --limit requires a value" >&2; exit 64; }; shift; limit="$1" ;;
2253
+ --limit=*) limit="${arg#--limit=}" ;;
2254
+ --json) json=1 ;;
2255
+ -*) echo "aiur: commands received an unknown option: $arg" >&2; exit 64 ;;
2256
+ *)
2257
+ if [ -n "$decision_id" ]; then
2258
+ echo "aiur: commands accepts at most one decision ID" >&2
2259
+ exit 64
2260
+ fi
2261
+ decision_id="$arg"
2262
+ ;;
2263
+ esac
2264
+ shift
2265
+ done
2266
+
2267
+ case "$filter" in all|open|blocking|resolved) ;; *) echo "aiur: commands --filter accepts all, open, blocking, or resolved" >&2; exit 64 ;; esac
2268
+ [ -z "$limit" ] || [[ "$limit" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: commands --limit expects a positive integer" >&2; exit 64; }
2269
+ [ -z "$ticket" ] || [ "$filter" = "all" ] || { echo "aiur: commands --ticket requires --filter all" >&2; exit 64; }
2270
+ [ -z "$search" ] || [ "$filter" = "all" ] || { echo "aiur: commands --search requires --filter all" >&2; exit 64; }
2271
+
2272
+ local opts="filter: :$filter"
2273
+ [ "$blocking" -eq 1 ] && opts="$opts, blocking: true"
2274
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2275
+ [ -n "$limit" ] && opts="$opts, limit: $limit"
2276
+ local key raw encoded
2277
+ for key in decision_id ticket search cursor; do
2278
+ raw="${!key}"
2279
+ [ -n "$raw" ] || continue
2280
+ encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2281
+ opts="$opts, $key: Base.decode64!(\"$encoded\")"
2282
+ done
2283
+
2284
+ run_control_rpc "Aiur.AgentControlCLI.commands([$opts])"
2285
+ }
2286
+
2287
+ encode_control_value() {
2288
+ printf '%s' "$1" | base64 | tr -d '\n'
2289
+ }
2290
+
2291
+ # These are explicit trusted-Executor mutations, deliberately separate from
2292
+ # the read-only `commands` catalog. Revisions remain dashboard-owned.
2293
+ cmd_executor_answer() {
2294
+ local decision_id="${1:-}" expected_version="" option_id="" custom_response="" rationale="" idempotency_key="" executor_id="aiur-cli" arg
2295
+ if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2296
+ echo "aiur: executor-answer expects exactly one decision ID" >&2
2297
+ exit 64
2298
+ fi
2299
+ shift
2300
+
2301
+ while [ "$#" -gt 0 ]; do
2302
+ arg="$1"
2303
+ case "$arg" in
2304
+ --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2305
+ --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2306
+ --option) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --option requires a value" >&2; exit 64; }; shift; option_id="$1" ;;
2307
+ --option=*) option_id="${arg#--option=}" ;;
2308
+ --custom-response) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --custom-response requires a value" >&2; exit 64; }; shift; custom_response="$1" ;;
2309
+ --custom-response=*) custom_response="${arg#--custom-response=}" ;;
2310
+ --rationale) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --rationale requires a value" >&2; exit 64; }; shift; rationale="$1" ;;
2311
+ --rationale=*) rationale="${arg#--rationale=}" ;;
2312
+ --idempotency-key) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --idempotency-key requires a value" >&2; exit 64; }; shift; idempotency_key="$1" ;;
2313
+ --idempotency-key=*) idempotency_key="${arg#--idempotency-key=}" ;;
2314
+ --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2315
+ --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2316
+ -*) echo "aiur: executor-answer received an unknown option: $arg" >&2; exit 64 ;;
2317
+ *) echo "aiur: executor-answer expects exactly one decision ID" >&2; exit 64 ;;
2318
+ esac
2319
+ shift
2320
+ done
2321
+
2322
+ [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-answer --expected-version expects a positive integer" >&2; exit 64; }
2323
+ if { [ -n "$option_id" ] && [ -n "$custom_response" ]; } || { [ -z "$option_id" ] && [ -z "$custom_response" ]; }; then
2324
+ echo "aiur: executor-answer requires exactly one of --option or --custom-response" >&2
2325
+ exit 64
2326
+ fi
2327
+ [ -n "$rationale" ] || { echo "aiur: executor-answer --rationale is required" >&2; exit 64; }
2328
+ [ -n "$idempotency_key" ] || { echo "aiur: executor-answer --idempotency-key is required" >&2; exit 64; }
2329
+ [ -n "$executor_id" ] || { echo "aiur: executor-answer --executor-id must not be empty" >&2; exit 64; }
2330
+
2331
+ local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2332
+ if [ -n "$option_id" ]; then
2333
+ opts="$opts, option_id: Base.decode64!(\"$(encode_control_value "$option_id")\")"
2334
+ else
2335
+ opts="$opts, custom_response: Base.decode64!(\"$(encode_control_value "$custom_response")\")"
2336
+ fi
2337
+ opts="$opts, rationale: Base.decode64!(\"$(encode_control_value "$rationale")\")"
2338
+ opts="$opts, idempotency_key: Base.decode64!(\"$(encode_control_value "$idempotency_key")\")"
2339
+ opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2340
+ local AIUR_CONTROL_ATTEMPT_CONTEXT="decision ID ${decision_id} with expected version ${expected_version}"
2341
+ run_control_rpc "Aiur.AgentControlCLI.executor_answer([$opts])"
2342
+ }
2343
+
2344
+ cmd_executor_escalate() {
2345
+ local decision_id="${1:-}" expected_version="" reason="" executor_id="aiur-cli" arg
2346
+ if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2347
+ echo "aiur: executor-escalate expects exactly one decision ID" >&2
2348
+ exit 64
2349
+ fi
2350
+ shift
2351
+
2352
+ while [ "$#" -gt 0 ]; do
2353
+ arg="$1"
2354
+ case "$arg" in
2355
+ --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2356
+ --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2357
+ --reason) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --reason requires a value" >&2; exit 64; }; shift; reason="$1" ;;
2358
+ --reason=*) reason="${arg#--reason=}" ;;
2359
+ --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2360
+ --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2361
+ -*) echo "aiur: executor-escalate received an unknown option: $arg" >&2; exit 64 ;;
2362
+ *) echo "aiur: executor-escalate expects exactly one decision ID" >&2; exit 64 ;;
2363
+ esac
2364
+ shift
2365
+ done
2366
+
2367
+ [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-escalate --expected-version expects a positive integer" >&2; exit 64; }
2368
+ [ -n "$reason" ] || { echo "aiur: executor-escalate --reason is required" >&2; exit 64; }
2369
+ [ -n "$executor_id" ] || { echo "aiur: executor-escalate --executor-id must not be empty" >&2; exit 64; }
2370
+
2371
+ local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2372
+ opts="$opts, reason: Base.decode64!(\"$(encode_control_value "$reason")\")"
2373
+ opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2374
+ local AIUR_CONTROL_ATTEMPT_CONTEXT="decision ID ${decision_id} with expected version ${expected_version}"
2375
+ run_control_rpc "Aiur.AgentControlCLI.executor_escalate([$opts])"
2376
+ }
2377
+
2378
+ # `aiur units` — read the dashboard Units catalog through its own projection,
2379
+ # including non-running tickets in current-run membership.
2380
+ cmd_units() {
2381
+ local scope="live" format="" json=0 arg condition condition_value condition_encoded conditions_literal=""
2382
+ local -a conditions=() condition_values=()
2383
+ while [ "$#" -gt 0 ]; do
2384
+ arg="$1"
2385
+ case "$arg" in
2386
+ --scope) [ "$#" -gt 1 ] || { echo "aiur: units --scope requires a value" >&2; exit 64; }; shift; scope="$1" ;;
2387
+ --scope=*) scope="${arg#--scope=}" ;;
2388
+ --condition) [ "$#" -gt 1 ] || { echo "aiur: units --condition requires a value" >&2; exit 64; }; shift; conditions+=("$1") ;;
2389
+ --condition=*) conditions+=("${arg#--condition=}") ;;
2390
+ --format) [ "$#" -gt 1 ] || { echo "aiur: units --format requires a value" >&2; exit 64; }; shift; format="$1" ;;
2391
+ --format=*) format="${arg#--format=}" ;;
2392
+ --json) json=1 ;;
2393
+ -*) echo "aiur: units received an unknown option: $arg" >&2; exit 64 ;;
2394
+ *) echo "aiur: units does not accept positional arguments" >&2; exit 64 ;;
2395
+ esac
2396
+ shift
2397
+ done
2398
+
2399
+ for condition in "${conditions[@]+"${conditions[@]}"}"; do
2400
+ IFS=',' read -r -a condition_values <<< "$condition"
2401
+ for condition_value in "${condition_values[@]+"${condition_values[@]}"}"; do
2402
+ if [ -n "$conditions_literal" ]; then conditions_literal="$conditions_literal, "; fi
2403
+ condition_encoded="$(printf '%s' "$condition_value" | base64 | tr -d '\n')"
2404
+ conditions_literal="${conditions_literal}Base.decode64!(\"${condition_encoded}\")"
2405
+ done
2406
+ done
2407
+
2408
+ local scope_encoded
2409
+ scope_encoded="$(printf '%s' "$scope" | base64 | tr -d '\n')"
2410
+ local opts="scope: Base.decode64!(\"$scope_encoded\")"
2411
+ [ -z "$conditions_literal" ] || opts="$opts, conditions: [$conditions_literal]"
2412
+
2413
+ if [ -n "$format" ]; then
2414
+ local format_encoded
2415
+ format_encoded="$(printf '%s' "$format" | base64 | tr -d '\n')"
2416
+ opts="$opts, format: Base.decode64!(\"$format_encoded\")"
2417
+ fi
2418
+
2419
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2420
+ run_control_rpc "Aiur.AgentControlCLI.units([$opts])"
2421
+ }
2422
+
2423
+ # `aiur build-orders` — read the dashboard Build Order projection without a
2424
+ # second GitHub or API derivation. A root selector switches from catalog to the
2425
+ # selected-root graph view.
2426
+ cmd_build_orders() {
2427
+ local json=0 root="" arg
2428
+
2429
+ while [ "$#" -gt 0 ]; do
2430
+ arg="$1"
2431
+ case "$arg" in
2432
+ --json) json=1 ;;
2433
+ -*) echo "aiur: build-orders received an unknown option: $arg" >&2; exit 64 ;;
2434
+ *)
2435
+ if [ -n "$root" ]; then
2436
+ echo "aiur: build-orders accepts at most one root" >&2
2437
+ exit 64
2438
+ fi
2439
+ root="$arg"
2440
+ ;;
2441
+ esac
2442
+ shift
2443
+ done
2444
+
2445
+ local opts=""
2446
+ [ "$json" -eq 1 ] && opts="json: true"
2447
+
2448
+ if [ -n "$root" ]; then
2449
+ local encoded
2450
+ encoded="$(printf '%s' "$root" | base64 | tr -d '\n')"
2451
+ [ -n "$opts" ] && opts="$opts, "
2452
+ opts="${opts}root: Base.decode64!(\"$encoded\")"
2453
+ fi
2454
+
2455
+ run_control_rpc "Aiur.AgentControlCLI.build_orders([$opts])"
2456
+ }
2457
+
2458
+ # `aiur analytics` — render the dashboard analytics projection for an explicit
2459
+ # time window. This is read-only and obtains the same durable telemetry snapshot
2460
+ # the page uses through the running node.
2461
+ cmd_analytics() {
2462
+ local range="run" json=0 since="" until="" build_order="" has_build_order=0 arg
2463
+
2464
+ while [ "$#" -gt 0 ]; do
2465
+ arg="$1"
2466
+ case "$arg" in
2467
+ --range) [ "$#" -gt 1 ] || { echo "aiur: analytics --range requires a value" >&2; exit 64; }; shift; range="$1" ;;
2468
+ --range=*) range="${arg#--range=}" ;;
2469
+ --since) [ "$#" -gt 1 ] || { echo "aiur: analytics --since requires a value" >&2; exit 64; }; shift; since="$1" ;;
2470
+ --since=*) since="${arg#--since=}" ;;
2471
+ --until) [ "$#" -gt 1 ] || { echo "aiur: analytics --until requires a value" >&2; exit 64; }; shift; until="$1" ;;
2472
+ --until=*) until="${arg#--until=}" ;;
2473
+ --build-order) [ "$#" -gt 1 ] || { echo "aiur: analytics --build-order requires a value" >&2; exit 64; }; shift; build_order="$1"; has_build_order=1 ;;
2474
+ --build-order=*) build_order="${arg#--build-order=}"; has_build_order=1 ;;
2475
+ --json) json=1 ;;
2476
+ -*) echo "aiur: analytics received an unknown option: $arg" >&2; exit 64 ;;
2477
+ *) echo "aiur: analytics does not accept positional arguments" >&2; exit 64 ;;
2478
+ esac
2479
+ shift
2480
+ done
2481
+
2482
+ case "$range" in run|full) ;; *) echo "aiur: analytics --range accepts run or full" >&2; exit 64 ;; esac
2483
+ [ "$has_build_order" -eq 0 ] || [[ "$build_order" =~ ^[0-9]+$ ]] || { echo "aiur: analytics --build-order expects a numeric ticket ID" >&2; exit 64; }
2484
+
2485
+ local opts="range: :$range" key raw encoded
2486
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2487
+ for key in since until build_order; do
2488
+ raw="${!key}"
2489
+ [ -n "$raw" ] || continue
2490
+ encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2491
+ opts="$opts, $key: Base.decode64!(\"$encoded\")"
2492
+ done
2493
+
2494
+ run_control_rpc "Aiur.AgentControlCLI.analytics([$opts])"
2495
+ }
2496
+
1699
2497
  # `aiur alerts` — newline-delimited structured alert feed from persisted
1700
- # per-agent logs. `--needs-attention` filters to operator-actionable alerts.
2498
+ # per-agent logs. `--needs-attention` filters to Executor-actionable alerts.
1701
2499
  cmd_alerts() {
1702
2500
  local needs_attention=0 arg
1703
2501
  for arg in "$@"; do
@@ -1761,6 +2559,78 @@ cmd_watch() {
1761
2559
  fi
1762
2560
  }
1763
2561
 
2562
+ cmd_executor_listen() {
2563
+ local topic="executor.#" arg
2564
+ while [ "$#" -gt 0 ]; do
2565
+ arg="$1"
2566
+ case "$arg" in
2567
+ --topic) shift; topic="${1:-}" ;;
2568
+ --topic=*) topic="${arg#--topic=}" ;;
2569
+ *) echo "aiur: executor-listen accepts --topic <pattern>" >&2; exit 64 ;;
2570
+ esac
2571
+ [ -n "$topic" ] || { echo "aiur: executor-listen requires a topic" >&2; exit 64; }
2572
+ shift
2573
+ done
2574
+ local encoded
2575
+ encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2576
+ run_control_stream "Aiur.AgentControlCLI.executor_listen(topic: Base.decode64!(\"$encoded\"))"
2577
+ }
2578
+
2579
+ cmd_executor_wait() {
2580
+ local timeout=300 json=0 arg
2581
+ while [ "$#" -gt 0 ]; do
2582
+ arg="$1"
2583
+ case "$arg" in
2584
+ --timeout) shift; timeout="${1:-}" ;;
2585
+ --timeout=*) timeout="${arg#--timeout=}" ;;
2586
+ --json) json=1 ;;
2587
+ *) echo "aiur: executor-wait accepts --timeout <seconds> and --json" >&2; exit 64 ;;
2588
+ esac
2589
+ shift
2590
+ done
2591
+ [[ "$timeout" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-wait --timeout expects a positive integer" >&2; exit 64; }
2592
+ local json_arg=false
2593
+ if [ "$json" -eq 1 ]; then json_arg=true; fi
2594
+ AIUR_CONTROL_COMMAND="executor-wait"
2595
+ AIUR_CONTROL_RPC_TIMEOUT_SECONDS=$((timeout + 10)) run_control_rpc "Aiur.AgentControlCLI.executor_wait(timeout_ms: $((timeout * 1000)), json: $json_arg)"
2596
+ }
2597
+
2598
+ cmd_executor_emit() {
2599
+ local topic="${1:-}" payload="" arg
2600
+ shift 2>/dev/null || true
2601
+ [ -n "$topic" ] || { echo "aiur: executor-emit requires a topic" >&2; exit 64; }
2602
+ while [ "$#" -gt 0 ]; do
2603
+ arg="$1"
2604
+ case "$arg" in
2605
+ --payload) shift; payload="${1:-}" ;;
2606
+ --payload=*) payload="${arg#--payload=}" ;;
2607
+ *) echo "aiur: executor-emit accepts only --payload <json>" >&2; exit 64 ;;
2608
+ esac
2609
+ shift
2610
+ done
2611
+ [ -n "$payload" ] || { echo "aiur: executor-emit requires --payload <json>" >&2; exit 64; }
2612
+ local topic_encoded payload_encoded
2613
+ topic_encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2614
+ payload_encoded="$(printf '%s' "$payload" | base64 | tr -d '\n')"
2615
+ run_control_rpc "Aiur.AgentControlCLI.executor_emit(Base.decode64!(\"$topic_encoded\"), Base.decode64!(\"$payload_encoded\"))"
2616
+ }
2617
+
2618
+ cmd_executor_subscription() {
2619
+ local action="$1" topic="${2:-}"
2620
+ [ -n "$topic" ] && [ "$#" -eq 2 ] || { echo "aiur: $action requires one topic pattern" >&2; exit 64; }
2621
+ local encoded
2622
+ encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2623
+ case "$action" in
2624
+ executor-subscribe) run_control_rpc "Aiur.AgentControlCLI.executor_subscribe(Base.decode64!(\"$encoded\"))" ;;
2625
+ executor-unsubscribe) run_control_rpc "Aiur.AgentControlCLI.executor_unsubscribe(Base.decode64!(\"$encoded\"))" ;;
2626
+ esac
2627
+ }
2628
+
2629
+ cmd_executor_subscriptions() {
2630
+ [ "$#" -eq 0 ] || { echo "aiur: executor-subscriptions does not accept arguments" >&2; exit 64; }
2631
+ run_control_rpc "Aiur.AgentControlCLI.executor_subscriptions()"
2632
+ }
2633
+
1764
2634
  watch_validate_interval() {
1765
2635
  if ! [[ "$1" =~ ^[0-9]+$ ]] || [ "$1" -le 0 ]; then
1766
2636
  echo "aiur: watch --interval expects a positive integer (seconds)" >&2
@@ -1841,7 +2711,7 @@ sweep_dead_tmux_sockets() {
1841
2711
  # foreground teardown (session_cleanup) and on `aiur stop`. Bounded and safe:
1842
2712
  #
1843
2713
  # * Only exact top-level Aiur artifact families under the temp roots the engine
1844
- # and BEAM write to. Arbitrary operator worktrees/checkouts like
2714
+ # and BEAM write to. Arbitrary Executor worktrees/checkouts like
1845
2715
  # `/tmp/aiur-pr490` are not candidates.
1846
2716
  # * Age-gated by AIUR_TMP_REAP_MINUTES (default 1440 = 24h). An entry is removed
1847
2717
  # only when NOTHING in its subtree was modified within the window, so a live
@@ -1935,8 +2805,41 @@ sweep_stale_tmp_artifacts() {
1935
2805
  return 0
1936
2806
  }
1937
2807
 
2808
+ # A stop only reaps the daemon whose node name matches this project root's
2809
+ # instance key (keys are sha256(project_root), so instances can't reap each
2810
+ # other). A daemon launched from a different directory therefore survives a stop
2811
+ # invoked elsewhere — the silent orphan that keeps holding the dashboard port and
2812
+ # serving stale code/credentials. Surface it loudly (we warn, not reap, to
2813
+ # respect the deliberate isolation) so the operator can stop it explicitly.
2814
+ warn_other_aiur_daemons() {
2815
+ local self_node="$1" me pids pid cmd node port found=0
2816
+ me="${USER:-$(id -un 2>/dev/null)}"
2817
+ pids="$(pgrep -u "$me" -f -- "-name aiur-${me}" 2>/dev/null || true)"
2818
+ for pid in $pids; do
2819
+ cmd="$(pid_command "$pid")"
2820
+ case "$cmd" in *beam.smp*) : ;; *) continue ;; esac
2821
+ node="$(printf '%s' "$cmd" | grep -oE -- '-name [^ ]+' | awk '{print $2}' | head -1)"
2822
+ [ -z "$node" ] && continue
2823
+ [ "$node" = "$self_node" ] && continue
2824
+ port="$(ss -tlnpH 2>/dev/null | awk -v p="pid=$pid," '$0 ~ p {print $4}' \
2825
+ | grep -oE '[0-9]+$' | head -1 || true)"
2826
+ if [ "$found" -eq 0 ]; then
2827
+ echo "aiur: heads up — another aiur daemon is still running that this stop did not touch" >&2
2828
+ echo " (launched from a different directory, so it has a different instance key):" >&2
2829
+ found=1
2830
+ fi
2831
+ echo " pid=$pid node=$node${port:+ dashboard-port=$port} — stop it with: kill $pid" >&2
2832
+ done
2833
+ }
2834
+
2835
+ # Set when cmd_stop returns nonzero only because there was nothing to stop. It
2836
+ # lets a caller (cmd_restart) tell that benign outcome apart from a stop that
2837
+ # failed for a real reason, without changing `aiur stop`'s own exit code.
2838
+ AIUR_STOP_FOUND_NOTHING=0
2839
+
1938
2840
  # Stop the running session: kill this instance's tmux + BEAM, then sweep.
1939
2841
  cmd_stop() {
2842
+ AIUR_STOP_FOUND_NOTHING=0
1940
2843
  resolve_release
1941
2844
  aiur_resolve_identity
1942
2845
  RELEASE_NODE="$AIUR_RELEASE_NODE"
@@ -1944,8 +2847,8 @@ cmd_stop() {
1944
2847
  export RELEASE_NODE ERL_EPMD_ADDRESS
1945
2848
  resolve_control_identity_from_records
1946
2849
 
1947
- local workspace_root
1948
- workspace_root="$(current_workspace_root 2>/dev/null || true)"
2850
+ local workspace_root_file
2851
+ workspace_root_file="$(workspace_root_file_from_instance_record 2>/dev/null || true)"
1949
2852
 
1950
2853
  local tmux_bin
1951
2854
  tmux_bin="$(command -v tmux || true)"
@@ -1963,7 +2866,9 @@ cmd_stop() {
1963
2866
  [ "$has_session" -eq 0 ] && \
1964
2867
  [ ! -f "$(aiur_crash_marker_path)" ]; then
1965
2868
  echo "aiur: no running aiur node at ${AIUR_RELEASE_NODE}; nothing stopped" >&2
2869
+ warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
1966
2870
  print_global_config_control_hint
2871
+ AIUR_STOP_FOUND_NOTHING=1
1967
2872
  return 1
1968
2873
  fi
1969
2874
 
@@ -1984,9 +2889,10 @@ cmd_stop() {
1984
2889
  # it. Do not sweep by release dir: sibling instances share dev releases.
1985
2890
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
1986
2891
 
1987
- # Final cwd-sweep backstop after the BEAM is dead. `cmd_stop` is a fresh shell,
1988
- # so it captures the root from the live control node before the kill above.
1989
- reap_workspace_cwd_agents "$workspace_root"
2892
+ # Final cwd-sweep backstop after the BEAM is dead. The launcher's instance
2893
+ # record points to the BEAM-written root handoff, so degraded stop never has
2894
+ # to wait for an overloaded daemon before it starts tearing workers down.
2895
+ reap_workspace_cwd_from_file "$workspace_root_file"
1990
2896
 
1991
2897
  # The BEAM (alive until the TERM above) reaped its own headless agents through
1992
2898
  # ProcessReaper on Application.stop. kill-server is the guarantee the earlier
@@ -2011,12 +2917,244 @@ cmd_stop() {
2011
2917
  sweep_dead_tmux_sockets
2012
2918
  sweep_stale_tmp_artifacts
2013
2919
  rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
2920
+ warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
2921
+ }
2922
+
2923
+ # Stop, refresh the release, start again — `aiur restart`.
2924
+ #
2925
+ # The refresh deliberately runs BETWEEN the stop and the start. A dev rebuild
2926
+ # rewrites the release directory in place, and a BEAM booted from that directory
2927
+ # is exactly what the control-retry path (EX_TEMPFAIL) exists to survive; doing
2928
+ # it while the daemon is down means the new daemon boots from a release nothing
2929
+ # else is holding half-written.
2930
+ #
2931
+ # The engine itself never knows how to build: the installed CLI runs a pinned
2932
+ # platform release with no source tree beside it, so its restart is a plain
2933
+ # bounce. The dev shim supplies its build-if-stale step through
2934
+ # AIUR_RESTART_BUILD_CMD, a shell command line run via `bash -c` so the wrapper
2935
+ # (not this parser) owns quoting.
2936
+ aiur_restart_daemon_state="untouched"
2937
+
2938
+ # Everything between the stop and a confirmed start runs with this armed, so the
2939
+ # operator hears about a fleet that is down no matter which step failed — a
2940
+ # rebuild, a missing tmux, a BEAM that would not boot, or a Ctrl-C during the
2941
+ # minutes-long rebuild. Naming only the step that failed would let "aiur failed
2942
+ # to start" read as "nothing changed", which is the silently stopped fleet.
2943
+ aiur_restart_report_state() {
2944
+ [ "$aiur_restart_daemon_state" = "stopped" ] || return 0
2945
+
2946
+ # release_dir is resolved by the stop; fall back to the caller's declared dir
2947
+ # for the paths that fail before that.
2948
+ local dir="${release_dir:-${AIUR_RELEASE_DIR:-}}"
2949
+
2950
+ echo "aiur: the daemon is STOPPED and was NOT restarted" >&2
2951
+ if [ -n "$dir" ] && [ -x "$dir/bin/aiur" ]; then
2952
+ echo "aiur: fix the cause and run 'aiur restart' again, or 'aiur restart --no-build' to start" >&2
2953
+ echo " on the release already on disk" >&2
2954
+ else
2955
+ # The dev builder removes an incomplete release, so after a failed rebuild
2956
+ # there is nothing for --no-build to start. Advertising it anyway would cost
2957
+ # the operator a second failed cycle while the fleet stays down.
2958
+ echo "aiur: no complete release is left on disk, so --no-build has nothing to start —" >&2
2959
+ echo " fix the build and run 'aiur restart' again" >&2
2960
+ fi
2961
+ }
2962
+
2963
+ # The rebuild runs in a process this command cannot see into, so "the builder
2964
+ # exited 0" is not the same claim as "the release I am about to boot is the one
2965
+ # it just built" — a wrapper pointed at another checkout satisfies the first and
2966
+ # not the second, which is exactly how a bounce ships a stale release while every
2967
+ # step reports success. The builder leaves a receipt naming the release dir it
2968
+ # vouches for and the commit it built from; this checks both against the release
2969
+ # on disk. Anything unverifiable is reported as unverifiable, never as success.
2970
+ verify_restart_build() {
2971
+ local receipt="$1"
2972
+ local built_dir built_sha stamped_sha stamp target_dir
2973
+
2974
+ if [ ! -s "$receipt" ]; then
2975
+ echo "aiur: the rebuild left no build receipt, so restart cannot confirm which" >&2
2976
+ echo " release it produced" >&2
2977
+ return 1
2978
+ fi
2979
+
2980
+ built_dir="$(sed -n 's/^release_dir=//p' "$receipt" | head -n 1)"
2981
+ built_sha="$(sed -n 's/^source_sha=//p' "$receipt" | head -n 1)"
2982
+
2983
+ # Fail on the missing field rather than on whatever an empty one happens to
2984
+ # compare equal to further down.
2985
+ if [ -z "$built_dir" ] || [ -z "$built_sha" ]; then
2986
+ echo "aiur: the build receipt is malformed; restart cannot confirm what was built" >&2
2987
+ return 1
2988
+ fi
2989
+
2990
+ # Compare canonical paths: the builder and this engine can reach the same
2991
+ # release through different symlinked parents.
2992
+ built_dir="$(cd -P "$built_dir" 2>/dev/null && pwd || printf '%s' "$built_dir")"
2993
+ target_dir="$(cd -P "${AIUR_RELEASE_DIR:-}" 2>/dev/null && pwd || printf '%s' "${AIUR_RELEASE_DIR:-}")"
2994
+
2995
+ if [ "$built_dir" != "$target_dir" ]; then
2996
+ echo "aiur: the rebuild targeted a different release than the one about to boot" >&2
2997
+ echo " rebuilt : $built_dir" >&2
2998
+ echo " booting : $target_dir" >&2
2999
+ return 1
3000
+ fi
3001
+
3002
+ stamp="$target_dir/AIUR_BUILD_STAMP"
3003
+ if [ ! -r "$stamp" ]; then
3004
+ echo "aiur: the release carries no build stamp, so restart cannot confirm it is" >&2
3005
+ echo " the one just built ($stamp)" >&2
3006
+ return 1
3007
+ fi
3008
+
3009
+ stamped_sha="$(sed -n 's/^source_sha=//p' "$stamp" | head -n 1)"
3010
+ if [ "$stamped_sha" != "$built_sha" ]; then
3011
+ echo "aiur: the release on disk was not built from the commit the rebuild reported" >&2
3012
+ echo " rebuild reported : $built_sha" >&2
3013
+ echo " release stamped : $stamped_sha" >&2
3014
+ return 1
3015
+ fi
3016
+
3017
+ # Say what was actually established. "verified ... built from unknown" would be
3018
+ # a silent-pass guard reporting success over a release it cannot identify, and
3019
+ # a dirty tree means the SHA is a floor rather than an identity.
3020
+ if [ "$stamped_sha" = "unknown" ]; then
3021
+ echo "aiur: release $target_dir matches the rebuild, but its source commit is" >&2
3022
+ echo " unknown (built outside a git work tree) — provenance unverified" >&2
3023
+ return 0
3024
+ fi
3025
+
3026
+ if [ "$(sed -n 's/^dirty=//p' "$stamp" | head -n 1)" = "yes" ]; then
3027
+ echo "aiur: release $target_dir built from $stamped_sha with uncommitted changes" >&2
3028
+ return 0
3029
+ fi
3030
+
3031
+ echo "aiur: verified release $target_dir built from $stamped_sha" >&2
3032
+ }
3033
+
3034
+ cmd_restart() {
3035
+ local skip_build=0 arg
3036
+ local run_args=()
3037
+
3038
+ # Refuse a stale config before stopping a healthy daemon. dispatch_run also
3039
+ # checks this for ordinary launches, but restart mutates state before it gets
3040
+ # that far.
3041
+ reject_legacy_config "$@"
3042
+
3043
+ for arg in "$@"; do
3044
+ case "$arg" in
3045
+ --no-build) skip_build=1 ;;
3046
+ *) run_args+=("$arg") ;;
3047
+ esac
3048
+ done
3049
+
3050
+ # Restarting something that is already down is a start, not an error: the
3051
+ # operator asked for a running daemon on current code, and that is what they
3052
+ # get. Any OTHER stop failure is fatal — building and starting on top of a
3053
+ # half-stopped instance is the in-place rewrite this ordering exists to avoid.
3054
+ local stop_status=0
3055
+ cmd_stop || stop_status=$?
3056
+ if [ "$stop_status" -ne 0 ]; then
3057
+ if [ "${AIUR_STOP_FOUND_NOTHING:-0}" -eq 1 ]; then
3058
+ echo "aiur: nothing was running; restart will start a fresh session" >&2
3059
+ else
3060
+ die "restart aborted: stop failed (exit $stop_status); the daemon may still be running"
3061
+ fi
3062
+ fi
3063
+
3064
+ # cmd_stop's tmux teardown is best-effort (`|| true`), so a stop can report
3065
+ # success while the session survives. Starting from there would rebuild the
3066
+ # release under a live BEAM and then hit run_session's idempotent "already
3067
+ # running" return — exit 0, stale release, no bounce. Refuse instead.
3068
+ # release_bin is unset only when the stop was stubbed out, i.e. in tests.
3069
+ if [ -n "${release_bin:-}" ] && [ "$(probe_control_liveness)" = "up" ]; then
3070
+ die "restart aborted: the previous session still answers after the stop; nothing was rebuilt or restarted"
3071
+ fi
3072
+
3073
+ aiur_restart_daemon_state="stopped"
3074
+ trap aiur_restart_report_state EXIT INT TERM
3075
+
3076
+ if [ -n "${AIUR_RESTART_BUILD_CMD:-}" ] && [ "$skip_build" -eq 0 ]; then
3077
+ local build_status=0 receipt
3078
+ receipt="$(mktemp "${TMPDIR:-/tmp}/aiur-restart-receipt.XXXXXX")"
3079
+ AIUR_RESTART_BUILD_RECEIPT="$receipt" bash -c "$AIUR_RESTART_BUILD_CMD" || build_status=$?
3080
+ if [ "$build_status" -ne 0 ]; then
3081
+ # Exit with the builder's own status rather than a flat 1, so a caller can
3082
+ # still tell a failed rebuild from any other abort.
3083
+ rm -f "$receipt"
3084
+ echo "❌ restart aborted before start: the rebuild failed" >&2
3085
+ aiur_restart_report_state
3086
+ trap - EXIT INT TERM
3087
+ exit "$build_status"
3088
+ fi
3089
+
3090
+ # A builder that declares AIUR_RESTART_BUILD_VERIFIES has promised a receipt,
3091
+ # so a missing or contradicted one is a real failure and an unverifiable
3092
+ # rebuild is not a lesser success — starting anyway would hand back the
3093
+ # stale-release outcome this ordering exists to prevent, with a "restarted"
3094
+ # line on top of it. A builder that makes no such promise cannot be held to
3095
+ # it; say plainly that the guarantee did not apply rather than convert an
3096
+ # unknown wrapper into a stopped fleet.
3097
+ if [ "${AIUR_RESTART_BUILD_VERIFIES:-0}" = "1" ]; then
3098
+ local verify_status=0
3099
+ verify_restart_build "$receipt" || verify_status=$?
3100
+ rm -f "$receipt"
3101
+ if [ "$verify_status" -ne 0 ]; then
3102
+ echo "❌ restart aborted before start: the rebuild could not be verified" >&2
3103
+ # Naming the builder makes an inherited AIUR_RESTART_BUILD_CMD — a dev
3104
+ # shim leaking into another shell — diagnosable from this output alone,
3105
+ # instead of an identical refusal on every retry.
3106
+ echo " rebuild command: ${AIUR_RESTART_BUILD_CMD}" >&2
3107
+ echo " Rebuild from the checkout you mean and retry, or 'aiur restart --no-build'" >&2
3108
+ echo " to start the release on disk without the guarantee." >&2
3109
+ aiur_restart_report_state
3110
+ trap - EXIT INT TERM
3111
+ exit 70
3112
+ fi
3113
+ else
3114
+ rm -f "$receipt"
3115
+ echo "aiur: UNVERIFIED rebuild — this builder reports no receipt, so restart" >&2
3116
+ echo " cannot confirm the release it is about to boot is the one just built" >&2
3117
+ fi
3118
+ elif [ "$skip_build" -eq 1 ]; then
3119
+ echo "aiur: --no-build — restarting on the release already on disk" >&2
3120
+ fi
3121
+
3122
+ # Always detached: the daemon this stopped was itself detached, and a restart
3123
+ # that captured the terminal would be a surprise. `--interactive` still opts
3124
+ # into the attachable session (`aiur --bg --interactive`), and a purely
3125
+ # foreground restart remains `aiur stop && aiur run`.
3126
+ dispatch_run --bg "${run_args[@]+"${run_args[@]}"}"
3127
+
3128
+ aiur_restart_daemon_state="running"
3129
+ trap - EXIT INT TERM
2014
3130
  }
2015
3131
 
2016
3132
  # --- dispatch ----------------------------------------------------------------
2017
3133
 
3134
+ dispatch_run() {
3135
+ local mode="foreground" arg
3136
+ local args=()
3137
+
3138
+ reject_legacy_config "$@"
3139
+
3140
+ for arg in "$@"; do
3141
+ if [ "$arg" = "--bg" ]; then
3142
+ mode="background"
3143
+ else
3144
+ args+=("$arg")
3145
+ fi
3146
+ done
3147
+
3148
+ # bash 3.2 (macOS default) errors on "${args[@]}" when args is empty under
3149
+ # `set -u` — happens for a bare `--bg` run. Guard the expansion.
3150
+ run_session "$mode" "${args[@]+"${args[@]}"}"
3151
+ }
3152
+
2018
3153
  aiur_engine_main() {
2019
3154
  local cmd="${1:-}"
3155
+ # Names the running subcommand in control-RPC diagnostics so a failure says
3156
+ # which command failed instead of a generic "control rpc" (#1684).
3157
+ AIUR_CONTROL_COMMAND="${cmd:-run}"
2020
3158
  case "$cmd" in
2021
3159
  __identity)
2022
3160
  aiur_print_identity
@@ -2027,25 +3165,69 @@ aiur_engine_main() {
2027
3165
  --version)
2028
3166
  run_version "$@"
2029
3167
  ;;
3168
+ --todo)
3169
+ run_todo "$@"
3170
+ ;;
3171
+ --only)
3172
+ echo "aiur: --only is valid only with --todo" >&2
3173
+ exit 64
3174
+ ;;
2030
3175
  init)
2031
3176
  run_init "$@"
2032
3177
  ;;
2033
- --bg)
3178
+ findings)
3179
+ run_findings "$@"
3180
+ ;;
3181
+ guard-pr-deletions)
2034
3182
  shift
2035
- run_session background "$@"
3183
+ "$engine_dir/guard-pr-deletions.sh" "$@"
3184
+ ;;
3185
+ ask | asks)
3186
+ run_asks "$@"
3187
+ ;;
3188
+ --bg)
3189
+ dispatch_run "$@"
2036
3190
  ;;
2037
3191
  run)
2038
3192
  shift
2039
- run_session foreground "$@"
3193
+ dispatch_run "$@"
2040
3194
  ;;
2041
3195
  status)
2042
3196
  shift
2043
3197
  cmd_status "$@"
2044
3198
  ;;
3199
+ usage)
3200
+ shift
3201
+ cmd_usage "$@"
3202
+ ;;
2045
3203
  agents)
2046
3204
  shift
2047
3205
  cmd_agents "$@"
2048
3206
  ;;
3207
+ commands)
3208
+ shift
3209
+ cmd_commands "$@"
3210
+ ;;
3211
+ executor-answer)
3212
+ shift
3213
+ cmd_executor_answer "$@"
3214
+ ;;
3215
+ executor-escalate)
3216
+ shift
3217
+ cmd_executor_escalate "$@"
3218
+ ;;
3219
+ units)
3220
+ shift
3221
+ cmd_units "$@"
3222
+ ;;
3223
+ build-orders)
3224
+ shift
3225
+ cmd_build_orders "$@"
3226
+ ;;
3227
+ analytics)
3228
+ shift
3229
+ cmd_analytics "$@"
3230
+ ;;
2049
3231
  alerts)
2050
3232
  shift
2051
3233
  cmd_alerts "$@"
@@ -2054,6 +3236,26 @@ aiur_engine_main() {
2054
3236
  shift
2055
3237
  cmd_watch "$@"
2056
3238
  ;;
3239
+ executor-listen)
3240
+ shift
3241
+ cmd_executor_listen "$@"
3242
+ ;;
3243
+ executor-wait)
3244
+ shift
3245
+ cmd_executor_wait "$@"
3246
+ ;;
3247
+ executor-emit)
3248
+ shift
3249
+ cmd_executor_emit "$@"
3250
+ ;;
3251
+ executor-subscribe | executor-unsubscribe)
3252
+ shift
3253
+ cmd_executor_subscription "$cmd" "$@"
3254
+ ;;
3255
+ executor-subscriptions)
3256
+ shift
3257
+ cmd_executor_subscriptions "$@"
3258
+ ;;
2057
3259
  set)
2058
3260
  shift
2059
3261
  cmd_set "$@"
@@ -2062,6 +3264,10 @@ aiur_engine_main() {
2062
3264
  shift
2063
3265
  cmd_pause_resume "$cmd" "$@"
2064
3266
  ;;
3267
+ reset-budget)
3268
+ shift
3269
+ cmd_reset_budget "$@"
3270
+ ;;
2065
3271
  message)
2066
3272
  shift
2067
3273
  cmd_message "$@"
@@ -2073,19 +3279,24 @@ aiur_engine_main() {
2073
3279
  stop)
2074
3280
  cmd_stop
2075
3281
  ;;
3282
+ restart)
3283
+ shift
3284
+ cmd_restart "$@"
3285
+ ;;
2076
3286
  "")
2077
- run_session foreground
3287
+ dispatch_run
2078
3288
  ;;
2079
3289
  -*)
2080
3290
  # leading-flag forms (e.g. `aiur --interactive <config>`) are a run
2081
- run_session foreground "$@"
3291
+ dispatch_run "$@"
2082
3292
  ;;
2083
3293
  *)
2084
3294
  # a path/config argument is a run; anything else is a usage error
2085
3295
  if [ -e "$cmd" ]; then
2086
- run_session foreground "$@"
3296
+ dispatch_run "$@"
2087
3297
  else
2088
3298
  echo "aiur: unknown command: $cmd" >&2
3299
+ warn_if_cli_behind_release_checkout
2089
3300
  usage >&2
2090
3301
  exit 64
2091
3302
  fi