aiur-cli 0.0.4 → 0.0.5-nightly.24dcc4d

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,59 @@ 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
+ # Every line a control RPC surfaces to the operator routes through one of these
1927
+ # two helpers, so `run_control_rpc` can prove it never returns non-zero while
1928
+ # saying nothing (#1684): a silent failure is indistinguishable from a healthy
1929
+ # idle fleet, and nobody reads exit codes interactively.
1930
+ control_rpc_say() {
1931
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1932
+ printf '%s\n' "$1" >&2
1933
+ }
1934
+
1935
+ control_rpc_echo_output() {
1936
+ [ -n "$1" ] || return 0
1937
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1938
+ printf '%s\n' "$1" >&2
1939
+ }
1940
+
1528
1941
  # RPC an expression into the running node. The control CLI prints a trailing
1529
1942
  # `__AIUR_CONTROL_EXIT__:<code>` marker we translate into the process exit code.
1943
+ #
1944
+ # Wrapper contract: a non-zero return ALWAYS carries at least one stderr line
1945
+ # naming the command that failed. EX_TEMPFAIL (75) is exempt — it is the dev
1946
+ # shim's internal rebuild-and-retry signal, never an operator-visible outcome.
1530
1947
  run_control_rpc() {
1948
+ local status=0
1949
+
1950
+ AIUR_CONTROL_RPC_DIAGNOSED=0
1951
+ run_control_rpc_dispatch "$@" || status=$?
1952
+
1953
+ if [ "$status" -ne 0 ] && [ "$status" -ne 75 ] && [ "${AIUR_CONTROL_RPC_DIAGNOSED:-0}" -ne 1 ]; then
1954
+ if [ "$status" -eq 124 ]; then
1955
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE:-the daemon} timed out after $(control_rpc_timeout_seconds)s; outcome is unknown. 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."
1956
+ else
1957
+ 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."
1958
+ fi
1959
+ fi
1960
+
1961
+ return "$status"
1962
+ }
1963
+
1964
+ run_control_rpc_dispatch() {
1531
1965
  local expression="$1"
1532
- resolve_release
1966
+ resolve_release || return $?
1533
1967
  prepare_distribution || die "distribution setup failed; cannot contact aiur"
1534
1968
  resolve_control_identity_from_records
1535
1969
  if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
1536
1970
  prepare_distribution || die "distribution setup failed; cannot contact aiur"
1537
1971
  fi
1538
1972
 
1539
- local marker="__AIUR_CONTROL_EXIT__:"
1540
- local output status exit_code=0 saw_marker=0 line
1973
+ local marker="__AIUR_CONTROL_EXIT__:" error_marker="__AIUR_CONTROL_ERROR__:"
1974
+ local output status exit_code=0 saw_marker=0 saw_error=0 saw_output=0 line partial_suffix=""
1541
1975
 
1542
1976
  set +e
1543
1977
  run_release_rpc_with_timeout "$expression"
@@ -1545,9 +1979,15 @@ run_control_rpc() {
1545
1979
  output="$AIUR_CONTROL_RPC_OUTPUT"
1546
1980
  set -e
1547
1981
 
1982
+ if [ "$status" -ne 0 ] && [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ] && \
1983
+ { [ ! -x "$release_bin" ] || [ ! -x "$vsn_dir/elixir" ] || [ ! -r "$release_dir/releases/start_erl.data" ]; }; then
1984
+ control_release_retry
1985
+ return $?
1986
+ fi
1987
+
1548
1988
  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
1989
+ [ -n "$output" ] && partial_suffix="; partial output was discarded"
1990
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} timed out after $(control_rpc_timeout_seconds)s; outcome is unknown${partial_suffix}. Commonly one blocked process inside the daemon, not host load."
1551
1991
  return 124
1552
1992
  fi
1553
1993
 
@@ -1560,26 +2000,31 @@ run_control_rpc() {
1560
2000
  # node epmd confirms is down earns the friendly "start aiur" hint; an `up`
1561
2001
  # node failed for a real reason, and an `unknown` probe must not be assumed
1562
2002
  # down — in both of those cases surface the actual rpc output, never mask it.
2003
+ #
2004
+ # An empty buffer is its own case: `--rpc-eval` kills itself without a word
2005
+ # when the evaluated expression exits (a GenServer call timing out against a
2006
+ # saturated daemon does exactly that), so "see the error above" would point
2007
+ # at nothing (#1684). Name the failure instead.
1563
2008
  case "$(probe_node_liveness)" in
1564
2009
  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
2010
+ print_control_down_message
2011
+ AIUR_CONTROL_RPC_DIAGNOSED=1
1575
2012
  ;;
1576
2013
  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
2014
+ control_rpc_echo_output "$output"
2015
+ if [ -n "$output" ]; then
2016
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (node is running); see the error above"
2017
+ else
2018
+ 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."
2019
+ fi
1579
2020
  ;;
1580
2021
  *)
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
2022
+ control_rpc_echo_output "$output"
2023
+ if [ -n "$output" ]; then
2024
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (could not query epmd to confirm node state); see the error above"
2025
+ else
2026
+ control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no output (could not query epmd to confirm node state)"
2027
+ fi
1583
2028
  ;;
1584
2029
  esac
1585
2030
  return 1
@@ -1591,19 +2036,55 @@ run_control_rpc() {
1591
2036
  saw_marker=1
1592
2037
  exit_code="${line#"$marker"}"
1593
2038
  ;;
2039
+ "$error_marker"*)
2040
+ saw_error=1
2041
+ AIUR_CONTROL_RPC_DIAGNOSED=1
2042
+ printf '%s\n' "${line#"$error_marker"}" >&2
2043
+ ;;
1594
2044
  :ok | "") ;;
1595
- *) printf '%s\n' "$line" ;;
2045
+ *)
2046
+ saw_output=1
2047
+ AIUR_CONTROL_RPC_DIAGNOSED=1
2048
+ printf '%s\n' "$line"
2049
+ ;;
1596
2050
  esac
1597
2051
  done <<<"$output"
1598
2052
 
1599
2053
  if [ "$saw_marker" -ne 1 ]; then
1600
- echo "aiur: control rpc to ${RELEASE_NODE} returned no exit marker; command output may be incomplete" >&2
2054
+ control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} returned no exit marker; command output may be incomplete"
1601
2055
  return 1
1602
2056
  fi
1603
2057
 
2058
+ if [ "$exit_code" -ne 0 ] && [ "$saw_error" -ne 1 ] && [ "$saw_output" -ne 1 ]; then
2059
+ control_rpc_say "aiur: $(control_command_label) failed with exit ${exit_code} and returned no diagnostic output"
2060
+ fi
2061
+
1604
2062
  return "$exit_code"
1605
2063
  }
1606
2064
 
2065
+ # A streaming Executor listener must retain the RPC process and its stdout. It
2066
+ # intentionally bypasses the one-shot control timeout and exit-marker wrapper.
2067
+ run_control_stream() {
2068
+ local expression="$1"
2069
+ resolve_release || return $?
2070
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
2071
+ resolve_control_identity_from_records
2072
+ if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
2073
+ prepare_distribution || die "distribution setup failed; cannot contact aiur"
2074
+ fi
2075
+ if [ "$(probe_node_liveness)" = "down" ]; then
2076
+ print_control_down_message
2077
+ return 1
2078
+ fi
2079
+ if "$release_bin" rpc "$expression"; then
2080
+ return 0
2081
+ else
2082
+ local status=$?
2083
+ echo "aiur: streaming control RPC failed with exit ${status}" >&2
2084
+ return "$status"
2085
+ fi
2086
+ }
2087
+
1607
2088
  elixir_list_literal() {
1608
2089
  local first=1 item
1609
2090
  printf '['
@@ -1647,11 +2128,26 @@ cmd_status() {
1647
2128
  run_control_rpc "Aiur.AgentControlCLI.status()"
1648
2129
  }
1649
2130
 
2131
+ # `aiur usage` — Codex/Claude limit headroom from the daemon's meter
2132
+ # projection, each value carrying the age of its observation.
2133
+ cmd_usage() {
2134
+ [ "$#" -eq 0 ] || die "usage does not accept arguments"
2135
+ run_control_rpc "Aiur.AgentControlCLI.usage()"
2136
+ }
2137
+
1650
2138
  cmd_pause_resume() {
1651
2139
  local command="$1"
1652
2140
  shift
2141
+
2142
+ # Bare `aiur pause` / `aiur resume` (no IDs, no --all) flips the single
2143
+ # global pause switch: a daemon-wide halt distinct from per-agent pause.
2144
+ if [ "$#" -eq 0 ]; then
2145
+ run_control_rpc "Aiur.AgentControlCLI.${command}_global()"
2146
+ return
2147
+ fi
2148
+
1653
2149
  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
2150
+ 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
2151
  exit 64
1656
2152
  fi
1657
2153
 
@@ -1665,7 +2161,28 @@ cmd_pause_resume() {
1665
2161
  run_control_rpc "$expression"
1666
2162
  }
1667
2163
 
1668
- # `aiur message <issue> <text>` — deliver operator text to one running agent.
2164
+ # `aiur reset-budget <id>...` — clear the lifetime dispatch latch for one or
2165
+ # more tickets (the supported exit from the #1453 latch; no JSON hand-editing).
2166
+ cmd_reset_budget() {
2167
+ if ! parse_issue_targets "$@"; then
2168
+ echo "aiur: reset-budget expects issue IDs (e.g. aiur reset-budget 44 45,46)" >&2
2169
+ exit 64
2170
+ fi
2171
+
2172
+ # --all is rejected (exit 64 with guidance) rather than silently no-opping:
2173
+ # clearing every ticket's latch at once is not a documented operation and
2174
+ # would mask which tickets are structurally stuck (#1453 review P2d).
2175
+ if [ "$parsed_all" -eq 1 ]; then
2176
+ echo "aiur: reset-budget does not accept --all; name ticket IDs explicitly (e.g. aiur reset-budget 44 45,46)" >&2
2177
+ exit 64
2178
+ fi
2179
+
2180
+ local expression
2181
+ expression="Aiur.AgentControlCLI.reset_budget($(elixir_list_literal "${parsed_targets[@]}"))"
2182
+ run_control_rpc "$expression"
2183
+ }
2184
+
2185
+ # `aiur message <issue> <text>` — deliver Executor text to one running agent.
1669
2186
  # The text is base64-encoded for the RPC hop so arbitrary content (quotes,
1670
2187
  # backslashes, `#{}`, newlines) survives without Elixir-string escaping.
1671
2188
  cmd_message() {
@@ -1696,8 +2213,268 @@ cmd_agents() {
1696
2213
  run_control_rpc "Aiur.AgentControlCLI.agents()"
1697
2214
  }
1698
2215
 
2216
+ # `aiur commands` — read the dashboard's retained Decision projection without
2217
+ # exposing any dispatch or answer mutation path.
2218
+ cmd_commands() {
2219
+ local filter="all" blocking=0 json=0 decision_id="" ticket="" search="" cursor="" limit="" arg
2220
+
2221
+ while [ "$#" -gt 0 ]; do
2222
+ arg="$1"
2223
+ case "$arg" in
2224
+ --filter) [ "$#" -gt 1 ] || { echo "aiur: commands --filter requires a value" >&2; exit 64; }; shift; filter="$1" ;;
2225
+ --filter=*) filter="${arg#--filter=}" ;;
2226
+ --blocking) blocking=1 ;;
2227
+ --ticket) [ "$#" -gt 1 ] || { echo "aiur: commands --ticket requires a value" >&2; exit 64; }; shift; ticket="$1" ;;
2228
+ --ticket=*) ticket="${arg#--ticket=}" ;;
2229
+ --search) [ "$#" -gt 1 ] || { echo "aiur: commands --search requires a value" >&2; exit 64; }; shift; search="$1" ;;
2230
+ --search=*) search="${arg#--search=}" ;;
2231
+ --cursor) [ "$#" -gt 1 ] || { echo "aiur: commands --cursor requires a value" >&2; exit 64; }; shift; cursor="$1" ;;
2232
+ --cursor=*) cursor="${arg#--cursor=}" ;;
2233
+ --limit) [ "$#" -gt 1 ] || { echo "aiur: commands --limit requires a value" >&2; exit 64; }; shift; limit="$1" ;;
2234
+ --limit=*) limit="${arg#--limit=}" ;;
2235
+ --json) json=1 ;;
2236
+ -*) echo "aiur: commands received an unknown option: $arg" >&2; exit 64 ;;
2237
+ *)
2238
+ if [ -n "$decision_id" ]; then
2239
+ echo "aiur: commands accepts at most one decision ID" >&2
2240
+ exit 64
2241
+ fi
2242
+ decision_id="$arg"
2243
+ ;;
2244
+ esac
2245
+ shift
2246
+ done
2247
+
2248
+ case "$filter" in all|open|blocking|resolved) ;; *) echo "aiur: commands --filter accepts all, open, blocking, or resolved" >&2; exit 64 ;; esac
2249
+ [ -z "$limit" ] || [[ "$limit" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: commands --limit expects a positive integer" >&2; exit 64; }
2250
+ [ -z "$ticket" ] || [ "$filter" = "all" ] || { echo "aiur: commands --ticket requires --filter all" >&2; exit 64; }
2251
+ [ -z "$search" ] || [ "$filter" = "all" ] || { echo "aiur: commands --search requires --filter all" >&2; exit 64; }
2252
+
2253
+ local opts="filter: :$filter"
2254
+ [ "$blocking" -eq 1 ] && opts="$opts, blocking: true"
2255
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2256
+ [ -n "$limit" ] && opts="$opts, limit: $limit"
2257
+ local key raw encoded
2258
+ for key in decision_id ticket search cursor; do
2259
+ raw="${!key}"
2260
+ [ -n "$raw" ] || continue
2261
+ encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2262
+ opts="$opts, $key: Base.decode64!(\"$encoded\")"
2263
+ done
2264
+
2265
+ run_control_rpc "Aiur.AgentControlCLI.commands([$opts])"
2266
+ }
2267
+
2268
+ encode_control_value() {
2269
+ printf '%s' "$1" | base64 | tr -d '\n'
2270
+ }
2271
+
2272
+ # These are explicit trusted-Executor mutations, deliberately separate from
2273
+ # the read-only `commands` catalog. Revisions remain dashboard-owned.
2274
+ cmd_executor_answer() {
2275
+ local decision_id="${1:-}" expected_version="" option_id="" custom_response="" rationale="" idempotency_key="" executor_id="aiur-cli" arg
2276
+ if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2277
+ echo "aiur: executor-answer expects exactly one decision ID" >&2
2278
+ exit 64
2279
+ fi
2280
+ shift
2281
+
2282
+ while [ "$#" -gt 0 ]; do
2283
+ arg="$1"
2284
+ case "$arg" in
2285
+ --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2286
+ --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2287
+ --option) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --option requires a value" >&2; exit 64; }; shift; option_id="$1" ;;
2288
+ --option=*) option_id="${arg#--option=}" ;;
2289
+ --custom-response) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --custom-response requires a value" >&2; exit 64; }; shift; custom_response="$1" ;;
2290
+ --custom-response=*) custom_response="${arg#--custom-response=}" ;;
2291
+ --rationale) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --rationale requires a value" >&2; exit 64; }; shift; rationale="$1" ;;
2292
+ --rationale=*) rationale="${arg#--rationale=}" ;;
2293
+ --idempotency-key) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --idempotency-key requires a value" >&2; exit 64; }; shift; idempotency_key="$1" ;;
2294
+ --idempotency-key=*) idempotency_key="${arg#--idempotency-key=}" ;;
2295
+ --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2296
+ --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2297
+ -*) echo "aiur: executor-answer received an unknown option: $arg" >&2; exit 64 ;;
2298
+ *) echo "aiur: executor-answer expects exactly one decision ID" >&2; exit 64 ;;
2299
+ esac
2300
+ shift
2301
+ done
2302
+
2303
+ [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-answer --expected-version expects a positive integer" >&2; exit 64; }
2304
+ if { [ -n "$option_id" ] && [ -n "$custom_response" ]; } || { [ -z "$option_id" ] && [ -z "$custom_response" ]; }; then
2305
+ echo "aiur: executor-answer requires exactly one of --option or --custom-response" >&2
2306
+ exit 64
2307
+ fi
2308
+ [ -n "$rationale" ] || { echo "aiur: executor-answer --rationale is required" >&2; exit 64; }
2309
+ [ -n "$idempotency_key" ] || { echo "aiur: executor-answer --idempotency-key is required" >&2; exit 64; }
2310
+ [ -n "$executor_id" ] || { echo "aiur: executor-answer --executor-id must not be empty" >&2; exit 64; }
2311
+
2312
+ local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2313
+ if [ -n "$option_id" ]; then
2314
+ opts="$opts, option_id: Base.decode64!(\"$(encode_control_value "$option_id")\")"
2315
+ else
2316
+ opts="$opts, custom_response: Base.decode64!(\"$(encode_control_value "$custom_response")\")"
2317
+ fi
2318
+ opts="$opts, rationale: Base.decode64!(\"$(encode_control_value "$rationale")\")"
2319
+ opts="$opts, idempotency_key: Base.decode64!(\"$(encode_control_value "$idempotency_key")\")"
2320
+ opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2321
+ run_control_rpc "Aiur.AgentControlCLI.executor_answer([$opts])"
2322
+ }
2323
+
2324
+ cmd_executor_escalate() {
2325
+ local decision_id="${1:-}" expected_version="" reason="" executor_id="aiur-cli" arg
2326
+ if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2327
+ echo "aiur: executor-escalate expects exactly one decision ID" >&2
2328
+ exit 64
2329
+ fi
2330
+ shift
2331
+
2332
+ while [ "$#" -gt 0 ]; do
2333
+ arg="$1"
2334
+ case "$arg" in
2335
+ --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2336
+ --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2337
+ --reason) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --reason requires a value" >&2; exit 64; }; shift; reason="$1" ;;
2338
+ --reason=*) reason="${arg#--reason=}" ;;
2339
+ --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2340
+ --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2341
+ -*) echo "aiur: executor-escalate received an unknown option: $arg" >&2; exit 64 ;;
2342
+ *) echo "aiur: executor-escalate expects exactly one decision ID" >&2; exit 64 ;;
2343
+ esac
2344
+ shift
2345
+ done
2346
+
2347
+ [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-escalate --expected-version expects a positive integer" >&2; exit 64; }
2348
+ [ -n "$reason" ] || { echo "aiur: executor-escalate --reason is required" >&2; exit 64; }
2349
+ [ -n "$executor_id" ] || { echo "aiur: executor-escalate --executor-id must not be empty" >&2; exit 64; }
2350
+
2351
+ local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2352
+ opts="$opts, reason: Base.decode64!(\"$(encode_control_value "$reason")\")"
2353
+ opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2354
+ run_control_rpc "Aiur.AgentControlCLI.executor_escalate([$opts])"
2355
+ }
2356
+
2357
+ # `aiur units` — read the dashboard Units catalog through its own projection,
2358
+ # including non-running tickets in current-run membership.
2359
+ cmd_units() {
2360
+ local scope="live" format="" json=0 arg condition condition_value condition_encoded conditions_literal=""
2361
+ local -a conditions=() condition_values=()
2362
+ while [ "$#" -gt 0 ]; do
2363
+ arg="$1"
2364
+ case "$arg" in
2365
+ --scope) [ "$#" -gt 1 ] || { echo "aiur: units --scope requires a value" >&2; exit 64; }; shift; scope="$1" ;;
2366
+ --scope=*) scope="${arg#--scope=}" ;;
2367
+ --condition) [ "$#" -gt 1 ] || { echo "aiur: units --condition requires a value" >&2; exit 64; }; shift; conditions+=("$1") ;;
2368
+ --condition=*) conditions+=("${arg#--condition=}") ;;
2369
+ --format) [ "$#" -gt 1 ] || { echo "aiur: units --format requires a value" >&2; exit 64; }; shift; format="$1" ;;
2370
+ --format=*) format="${arg#--format=}" ;;
2371
+ --json) json=1 ;;
2372
+ -*) echo "aiur: units received an unknown option: $arg" >&2; exit 64 ;;
2373
+ *) echo "aiur: units does not accept positional arguments" >&2; exit 64 ;;
2374
+ esac
2375
+ shift
2376
+ done
2377
+
2378
+ for condition in "${conditions[@]+"${conditions[@]}"}"; do
2379
+ IFS=',' read -r -a condition_values <<< "$condition"
2380
+ for condition_value in "${condition_values[@]+"${condition_values[@]}"}"; do
2381
+ if [ -n "$conditions_literal" ]; then conditions_literal="$conditions_literal, "; fi
2382
+ condition_encoded="$(printf '%s' "$condition_value" | base64 | tr -d '\n')"
2383
+ conditions_literal="${conditions_literal}Base.decode64!(\"${condition_encoded}\")"
2384
+ done
2385
+ done
2386
+
2387
+ local scope_encoded
2388
+ scope_encoded="$(printf '%s' "$scope" | base64 | tr -d '\n')"
2389
+ local opts="scope: Base.decode64!(\"$scope_encoded\")"
2390
+ [ -z "$conditions_literal" ] || opts="$opts, conditions: [$conditions_literal]"
2391
+
2392
+ if [ -n "$format" ]; then
2393
+ local format_encoded
2394
+ format_encoded="$(printf '%s' "$format" | base64 | tr -d '\n')"
2395
+ opts="$opts, format: Base.decode64!(\"$format_encoded\")"
2396
+ fi
2397
+
2398
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2399
+ run_control_rpc "Aiur.AgentControlCLI.units([$opts])"
2400
+ }
2401
+
2402
+ # `aiur build-orders` — read the dashboard Build Order projection without a
2403
+ # second GitHub or API derivation. A root selector switches from catalog to the
2404
+ # selected-root graph view.
2405
+ cmd_build_orders() {
2406
+ local json=0 root="" arg
2407
+
2408
+ while [ "$#" -gt 0 ]; do
2409
+ arg="$1"
2410
+ case "$arg" in
2411
+ --json) json=1 ;;
2412
+ -*) echo "aiur: build-orders received an unknown option: $arg" >&2; exit 64 ;;
2413
+ *)
2414
+ if [ -n "$root" ]; then
2415
+ echo "aiur: build-orders accepts at most one root" >&2
2416
+ exit 64
2417
+ fi
2418
+ root="$arg"
2419
+ ;;
2420
+ esac
2421
+ shift
2422
+ done
2423
+
2424
+ local opts=""
2425
+ [ "$json" -eq 1 ] && opts="json: true"
2426
+
2427
+ if [ -n "$root" ]; then
2428
+ local encoded
2429
+ encoded="$(printf '%s' "$root" | base64 | tr -d '\n')"
2430
+ [ -n "$opts" ] && opts="$opts, "
2431
+ opts="${opts}root: Base.decode64!(\"$encoded\")"
2432
+ fi
2433
+
2434
+ run_control_rpc "Aiur.AgentControlCLI.build_orders([$opts])"
2435
+ }
2436
+
2437
+ # `aiur analytics` — render the dashboard analytics projection for an explicit
2438
+ # time window. This is read-only and obtains the same durable telemetry snapshot
2439
+ # the page uses through the running node.
2440
+ cmd_analytics() {
2441
+ local range="run" json=0 since="" until="" build_order="" has_build_order=0 arg
2442
+
2443
+ while [ "$#" -gt 0 ]; do
2444
+ arg="$1"
2445
+ case "$arg" in
2446
+ --range) [ "$#" -gt 1 ] || { echo "aiur: analytics --range requires a value" >&2; exit 64; }; shift; range="$1" ;;
2447
+ --range=*) range="${arg#--range=}" ;;
2448
+ --since) [ "$#" -gt 1 ] || { echo "aiur: analytics --since requires a value" >&2; exit 64; }; shift; since="$1" ;;
2449
+ --since=*) since="${arg#--since=}" ;;
2450
+ --until) [ "$#" -gt 1 ] || { echo "aiur: analytics --until requires a value" >&2; exit 64; }; shift; until="$1" ;;
2451
+ --until=*) until="${arg#--until=}" ;;
2452
+ --build-order) [ "$#" -gt 1 ] || { echo "aiur: analytics --build-order requires a value" >&2; exit 64; }; shift; build_order="$1"; has_build_order=1 ;;
2453
+ --build-order=*) build_order="${arg#--build-order=}"; has_build_order=1 ;;
2454
+ --json) json=1 ;;
2455
+ -*) echo "aiur: analytics received an unknown option: $arg" >&2; exit 64 ;;
2456
+ *) echo "aiur: analytics does not accept positional arguments" >&2; exit 64 ;;
2457
+ esac
2458
+ shift
2459
+ done
2460
+
2461
+ case "$range" in run|full) ;; *) echo "aiur: analytics --range accepts run or full" >&2; exit 64 ;; esac
2462
+ [ "$has_build_order" -eq 0 ] || [[ "$build_order" =~ ^[0-9]+$ ]] || { echo "aiur: analytics --build-order expects a numeric ticket ID" >&2; exit 64; }
2463
+
2464
+ local opts="range: :$range" key raw encoded
2465
+ [ "$json" -eq 1 ] && opts="$opts, json: true"
2466
+ for key in since until build_order; do
2467
+ raw="${!key}"
2468
+ [ -n "$raw" ] || continue
2469
+ encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2470
+ opts="$opts, $key: Base.decode64!(\"$encoded\")"
2471
+ done
2472
+
2473
+ run_control_rpc "Aiur.AgentControlCLI.analytics([$opts])"
2474
+ }
2475
+
1699
2476
  # `aiur alerts` — newline-delimited structured alert feed from persisted
1700
- # per-agent logs. `--needs-attention` filters to operator-actionable alerts.
2477
+ # per-agent logs. `--needs-attention` filters to Executor-actionable alerts.
1701
2478
  cmd_alerts() {
1702
2479
  local needs_attention=0 arg
1703
2480
  for arg in "$@"; do
@@ -1761,6 +2538,78 @@ cmd_watch() {
1761
2538
  fi
1762
2539
  }
1763
2540
 
2541
+ cmd_executor_listen() {
2542
+ local topic="executor.#" arg
2543
+ while [ "$#" -gt 0 ]; do
2544
+ arg="$1"
2545
+ case "$arg" in
2546
+ --topic) shift; topic="${1:-}" ;;
2547
+ --topic=*) topic="${arg#--topic=}" ;;
2548
+ *) echo "aiur: executor-listen accepts --topic <pattern>" >&2; exit 64 ;;
2549
+ esac
2550
+ [ -n "$topic" ] || { echo "aiur: executor-listen requires a topic" >&2; exit 64; }
2551
+ shift
2552
+ done
2553
+ local encoded
2554
+ encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2555
+ run_control_stream "Aiur.AgentControlCLI.executor_listen(topic: Base.decode64!(\"$encoded\"))"
2556
+ }
2557
+
2558
+ cmd_executor_wait() {
2559
+ local timeout=300 json=0 arg
2560
+ while [ "$#" -gt 0 ]; do
2561
+ arg="$1"
2562
+ case "$arg" in
2563
+ --timeout) shift; timeout="${1:-}" ;;
2564
+ --timeout=*) timeout="${arg#--timeout=}" ;;
2565
+ --json) json=1 ;;
2566
+ *) echo "aiur: executor-wait accepts --timeout <seconds> and --json" >&2; exit 64 ;;
2567
+ esac
2568
+ shift
2569
+ done
2570
+ [[ "$timeout" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-wait --timeout expects a positive integer" >&2; exit 64; }
2571
+ local json_arg=false
2572
+ if [ "$json" -eq 1 ]; then json_arg=true; fi
2573
+ AIUR_CONTROL_COMMAND="executor-wait"
2574
+ AIUR_CONTROL_RPC_TIMEOUT_SECONDS=$((timeout + 10)) run_control_rpc "Aiur.AgentControlCLI.executor_wait(timeout_ms: $((timeout * 1000)), json: $json_arg)"
2575
+ }
2576
+
2577
+ cmd_executor_emit() {
2578
+ local topic="${1:-}" payload="" arg
2579
+ shift 2>/dev/null || true
2580
+ [ -n "$topic" ] || { echo "aiur: executor-emit requires a topic" >&2; exit 64; }
2581
+ while [ "$#" -gt 0 ]; do
2582
+ arg="$1"
2583
+ case "$arg" in
2584
+ --payload) shift; payload="${1:-}" ;;
2585
+ --payload=*) payload="${arg#--payload=}" ;;
2586
+ *) echo "aiur: executor-emit accepts only --payload <json>" >&2; exit 64 ;;
2587
+ esac
2588
+ shift
2589
+ done
2590
+ [ -n "$payload" ] || { echo "aiur: executor-emit requires --payload <json>" >&2; exit 64; }
2591
+ local topic_encoded payload_encoded
2592
+ topic_encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2593
+ payload_encoded="$(printf '%s' "$payload" | base64 | tr -d '\n')"
2594
+ run_control_rpc "Aiur.AgentControlCLI.executor_emit(Base.decode64!(\"$topic_encoded\"), Base.decode64!(\"$payload_encoded\"))"
2595
+ }
2596
+
2597
+ cmd_executor_subscription() {
2598
+ local action="$1" topic="${2:-}"
2599
+ [ -n "$topic" ] && [ "$#" -eq 2 ] || { echo "aiur: $action requires one topic pattern" >&2; exit 64; }
2600
+ local encoded
2601
+ encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
2602
+ case "$action" in
2603
+ executor-subscribe) run_control_rpc "Aiur.AgentControlCLI.executor_subscribe(Base.decode64!(\"$encoded\"))" ;;
2604
+ executor-unsubscribe) run_control_rpc "Aiur.AgentControlCLI.executor_unsubscribe(Base.decode64!(\"$encoded\"))" ;;
2605
+ esac
2606
+ }
2607
+
2608
+ cmd_executor_subscriptions() {
2609
+ [ "$#" -eq 0 ] || { echo "aiur: executor-subscriptions does not accept arguments" >&2; exit 64; }
2610
+ run_control_rpc "Aiur.AgentControlCLI.executor_subscriptions()"
2611
+ }
2612
+
1764
2613
  watch_validate_interval() {
1765
2614
  if ! [[ "$1" =~ ^[0-9]+$ ]] || [ "$1" -le 0 ]; then
1766
2615
  echo "aiur: watch --interval expects a positive integer (seconds)" >&2
@@ -1841,7 +2690,7 @@ sweep_dead_tmux_sockets() {
1841
2690
  # foreground teardown (session_cleanup) and on `aiur stop`. Bounded and safe:
1842
2691
  #
1843
2692
  # * Only exact top-level Aiur artifact families under the temp roots the engine
1844
- # and BEAM write to. Arbitrary operator worktrees/checkouts like
2693
+ # and BEAM write to. Arbitrary Executor worktrees/checkouts like
1845
2694
  # `/tmp/aiur-pr490` are not candidates.
1846
2695
  # * Age-gated by AIUR_TMP_REAP_MINUTES (default 1440 = 24h). An entry is removed
1847
2696
  # only when NOTHING in its subtree was modified within the window, so a live
@@ -1935,8 +2784,41 @@ sweep_stale_tmp_artifacts() {
1935
2784
  return 0
1936
2785
  }
1937
2786
 
2787
+ # A stop only reaps the daemon whose node name matches this project root's
2788
+ # instance key (keys are sha256(project_root), so instances can't reap each
2789
+ # other). A daemon launched from a different directory therefore survives a stop
2790
+ # invoked elsewhere — the silent orphan that keeps holding the dashboard port and
2791
+ # serving stale code/credentials. Surface it loudly (we warn, not reap, to
2792
+ # respect the deliberate isolation) so the operator can stop it explicitly.
2793
+ warn_other_aiur_daemons() {
2794
+ local self_node="$1" me pids pid cmd node port found=0
2795
+ me="${USER:-$(id -un 2>/dev/null)}"
2796
+ pids="$(pgrep -u "$me" -f -- "-name aiur-${me}" 2>/dev/null || true)"
2797
+ for pid in $pids; do
2798
+ cmd="$(pid_command "$pid")"
2799
+ case "$cmd" in *beam.smp*) : ;; *) continue ;; esac
2800
+ node="$(printf '%s' "$cmd" | grep -oE -- '-name [^ ]+' | awk '{print $2}' | head -1)"
2801
+ [ -z "$node" ] && continue
2802
+ [ "$node" = "$self_node" ] && continue
2803
+ port="$(ss -tlnpH 2>/dev/null | awk -v p="pid=$pid," '$0 ~ p {print $4}' \
2804
+ | grep -oE '[0-9]+$' | head -1 || true)"
2805
+ if [ "$found" -eq 0 ]; then
2806
+ echo "aiur: heads up — another aiur daemon is still running that this stop did not touch" >&2
2807
+ echo " (launched from a different directory, so it has a different instance key):" >&2
2808
+ found=1
2809
+ fi
2810
+ echo " pid=$pid node=$node${port:+ dashboard-port=$port} — stop it with: kill $pid" >&2
2811
+ done
2812
+ }
2813
+
2814
+ # Set when cmd_stop returns nonzero only because there was nothing to stop. It
2815
+ # lets a caller (cmd_restart) tell that benign outcome apart from a stop that
2816
+ # failed for a real reason, without changing `aiur stop`'s own exit code.
2817
+ AIUR_STOP_FOUND_NOTHING=0
2818
+
1938
2819
  # Stop the running session: kill this instance's tmux + BEAM, then sweep.
1939
2820
  cmd_stop() {
2821
+ AIUR_STOP_FOUND_NOTHING=0
1940
2822
  resolve_release
1941
2823
  aiur_resolve_identity
1942
2824
  RELEASE_NODE="$AIUR_RELEASE_NODE"
@@ -1944,8 +2826,8 @@ cmd_stop() {
1944
2826
  export RELEASE_NODE ERL_EPMD_ADDRESS
1945
2827
  resolve_control_identity_from_records
1946
2828
 
1947
- local workspace_root
1948
- workspace_root="$(current_workspace_root 2>/dev/null || true)"
2829
+ local workspace_root_file
2830
+ workspace_root_file="$(workspace_root_file_from_instance_record 2>/dev/null || true)"
1949
2831
 
1950
2832
  local tmux_bin
1951
2833
  tmux_bin="$(command -v tmux || true)"
@@ -1963,7 +2845,9 @@ cmd_stop() {
1963
2845
  [ "$has_session" -eq 0 ] && \
1964
2846
  [ ! -f "$(aiur_crash_marker_path)" ]; then
1965
2847
  echo "aiur: no running aiur node at ${AIUR_RELEASE_NODE}; nothing stopped" >&2
2848
+ warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
1966
2849
  print_global_config_control_hint
2850
+ AIUR_STOP_FOUND_NOTHING=1
1967
2851
  return 1
1968
2852
  fi
1969
2853
 
@@ -1984,9 +2868,10 @@ cmd_stop() {
1984
2868
  # it. Do not sweep by release dir: sibling instances share dev releases.
1985
2869
  kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
1986
2870
 
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"
2871
+ # Final cwd-sweep backstop after the BEAM is dead. The launcher's instance
2872
+ # record points to the BEAM-written root handoff, so degraded stop never has
2873
+ # to wait for an overloaded daemon before it starts tearing workers down.
2874
+ reap_workspace_cwd_from_file "$workspace_root_file"
1990
2875
 
1991
2876
  # The BEAM (alive until the TERM above) reaped its own headless agents through
1992
2877
  # ProcessReaper on Application.stop. kill-server is the guarantee the earlier
@@ -2011,12 +2896,244 @@ cmd_stop() {
2011
2896
  sweep_dead_tmux_sockets
2012
2897
  sweep_stale_tmp_artifacts
2013
2898
  rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
2899
+ warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
2900
+ }
2901
+
2902
+ # Stop, refresh the release, start again — `aiur restart`.
2903
+ #
2904
+ # The refresh deliberately runs BETWEEN the stop and the start. A dev rebuild
2905
+ # rewrites the release directory in place, and a BEAM booted from that directory
2906
+ # is exactly what the control-retry path (EX_TEMPFAIL) exists to survive; doing
2907
+ # it while the daemon is down means the new daemon boots from a release nothing
2908
+ # else is holding half-written.
2909
+ #
2910
+ # The engine itself never knows how to build: the installed CLI runs a pinned
2911
+ # platform release with no source tree beside it, so its restart is a plain
2912
+ # bounce. The dev shim supplies its build-if-stale step through
2913
+ # AIUR_RESTART_BUILD_CMD, a shell command line run via `bash -c` so the wrapper
2914
+ # (not this parser) owns quoting.
2915
+ aiur_restart_daemon_state="untouched"
2916
+
2917
+ # Everything between the stop and a confirmed start runs with this armed, so the
2918
+ # operator hears about a fleet that is down no matter which step failed — a
2919
+ # rebuild, a missing tmux, a BEAM that would not boot, or a Ctrl-C during the
2920
+ # minutes-long rebuild. Naming only the step that failed would let "aiur failed
2921
+ # to start" read as "nothing changed", which is the silently stopped fleet.
2922
+ aiur_restart_report_state() {
2923
+ [ "$aiur_restart_daemon_state" = "stopped" ] || return 0
2924
+
2925
+ # release_dir is resolved by the stop; fall back to the caller's declared dir
2926
+ # for the paths that fail before that.
2927
+ local dir="${release_dir:-${AIUR_RELEASE_DIR:-}}"
2928
+
2929
+ echo "aiur: the daemon is STOPPED and was NOT restarted" >&2
2930
+ if [ -n "$dir" ] && [ -x "$dir/bin/aiur" ]; then
2931
+ echo "aiur: fix the cause and run 'aiur restart' again, or 'aiur restart --no-build' to start" >&2
2932
+ echo " on the release already on disk" >&2
2933
+ else
2934
+ # The dev builder removes an incomplete release, so after a failed rebuild
2935
+ # there is nothing for --no-build to start. Advertising it anyway would cost
2936
+ # the operator a second failed cycle while the fleet stays down.
2937
+ echo "aiur: no complete release is left on disk, so --no-build has nothing to start —" >&2
2938
+ echo " fix the build and run 'aiur restart' again" >&2
2939
+ fi
2940
+ }
2941
+
2942
+ # The rebuild runs in a process this command cannot see into, so "the builder
2943
+ # exited 0" is not the same claim as "the release I am about to boot is the one
2944
+ # it just built" — a wrapper pointed at another checkout satisfies the first and
2945
+ # not the second, which is exactly how a bounce ships a stale release while every
2946
+ # step reports success. The builder leaves a receipt naming the release dir it
2947
+ # vouches for and the commit it built from; this checks both against the release
2948
+ # on disk. Anything unverifiable is reported as unverifiable, never as success.
2949
+ verify_restart_build() {
2950
+ local receipt="$1"
2951
+ local built_dir built_sha stamped_sha stamp target_dir
2952
+
2953
+ if [ ! -s "$receipt" ]; then
2954
+ echo "aiur: the rebuild left no build receipt, so restart cannot confirm which" >&2
2955
+ echo " release it produced" >&2
2956
+ return 1
2957
+ fi
2958
+
2959
+ built_dir="$(sed -n 's/^release_dir=//p' "$receipt" | head -n 1)"
2960
+ built_sha="$(sed -n 's/^source_sha=//p' "$receipt" | head -n 1)"
2961
+
2962
+ # Fail on the missing field rather than on whatever an empty one happens to
2963
+ # compare equal to further down.
2964
+ if [ -z "$built_dir" ] || [ -z "$built_sha" ]; then
2965
+ echo "aiur: the build receipt is malformed; restart cannot confirm what was built" >&2
2966
+ return 1
2967
+ fi
2968
+
2969
+ # Compare canonical paths: the builder and this engine can reach the same
2970
+ # release through different symlinked parents.
2971
+ built_dir="$(cd -P "$built_dir" 2>/dev/null && pwd || printf '%s' "$built_dir")"
2972
+ target_dir="$(cd -P "${AIUR_RELEASE_DIR:-}" 2>/dev/null && pwd || printf '%s' "${AIUR_RELEASE_DIR:-}")"
2973
+
2974
+ if [ "$built_dir" != "$target_dir" ]; then
2975
+ echo "aiur: the rebuild targeted a different release than the one about to boot" >&2
2976
+ echo " rebuilt : $built_dir" >&2
2977
+ echo " booting : $target_dir" >&2
2978
+ return 1
2979
+ fi
2980
+
2981
+ stamp="$target_dir/AIUR_BUILD_STAMP"
2982
+ if [ ! -r "$stamp" ]; then
2983
+ echo "aiur: the release carries no build stamp, so restart cannot confirm it is" >&2
2984
+ echo " the one just built ($stamp)" >&2
2985
+ return 1
2986
+ fi
2987
+
2988
+ stamped_sha="$(sed -n 's/^source_sha=//p' "$stamp" | head -n 1)"
2989
+ if [ "$stamped_sha" != "$built_sha" ]; then
2990
+ echo "aiur: the release on disk was not built from the commit the rebuild reported" >&2
2991
+ echo " rebuild reported : $built_sha" >&2
2992
+ echo " release stamped : $stamped_sha" >&2
2993
+ return 1
2994
+ fi
2995
+
2996
+ # Say what was actually established. "verified ... built from unknown" would be
2997
+ # a silent-pass guard reporting success over a release it cannot identify, and
2998
+ # a dirty tree means the SHA is a floor rather than an identity.
2999
+ if [ "$stamped_sha" = "unknown" ]; then
3000
+ echo "aiur: release $target_dir matches the rebuild, but its source commit is" >&2
3001
+ echo " unknown (built outside a git work tree) — provenance unverified" >&2
3002
+ return 0
3003
+ fi
3004
+
3005
+ if [ "$(sed -n 's/^dirty=//p' "$stamp" | head -n 1)" = "yes" ]; then
3006
+ echo "aiur: release $target_dir built from $stamped_sha with uncommitted changes" >&2
3007
+ return 0
3008
+ fi
3009
+
3010
+ echo "aiur: verified release $target_dir built from $stamped_sha" >&2
3011
+ }
3012
+
3013
+ cmd_restart() {
3014
+ local skip_build=0 arg
3015
+ local run_args=()
3016
+
3017
+ # Refuse a stale config before stopping a healthy daemon. dispatch_run also
3018
+ # checks this for ordinary launches, but restart mutates state before it gets
3019
+ # that far.
3020
+ reject_legacy_config "$@"
3021
+
3022
+ for arg in "$@"; do
3023
+ case "$arg" in
3024
+ --no-build) skip_build=1 ;;
3025
+ *) run_args+=("$arg") ;;
3026
+ esac
3027
+ done
3028
+
3029
+ # Restarting something that is already down is a start, not an error: the
3030
+ # operator asked for a running daemon on current code, and that is what they
3031
+ # get. Any OTHER stop failure is fatal — building and starting on top of a
3032
+ # half-stopped instance is the in-place rewrite this ordering exists to avoid.
3033
+ local stop_status=0
3034
+ cmd_stop || stop_status=$?
3035
+ if [ "$stop_status" -ne 0 ]; then
3036
+ if [ "${AIUR_STOP_FOUND_NOTHING:-0}" -eq 1 ]; then
3037
+ echo "aiur: nothing was running; restart will start a fresh session" >&2
3038
+ else
3039
+ die "restart aborted: stop failed (exit $stop_status); the daemon may still be running"
3040
+ fi
3041
+ fi
3042
+
3043
+ # cmd_stop's tmux teardown is best-effort (`|| true`), so a stop can report
3044
+ # success while the session survives. Starting from there would rebuild the
3045
+ # release under a live BEAM and then hit run_session's idempotent "already
3046
+ # running" return — exit 0, stale release, no bounce. Refuse instead.
3047
+ # release_bin is unset only when the stop was stubbed out, i.e. in tests.
3048
+ if [ -n "${release_bin:-}" ] && [ "$(probe_control_liveness)" = "up" ]; then
3049
+ die "restart aborted: the previous session still answers after the stop; nothing was rebuilt or restarted"
3050
+ fi
3051
+
3052
+ aiur_restart_daemon_state="stopped"
3053
+ trap aiur_restart_report_state EXIT INT TERM
3054
+
3055
+ if [ -n "${AIUR_RESTART_BUILD_CMD:-}" ] && [ "$skip_build" -eq 0 ]; then
3056
+ local build_status=0 receipt
3057
+ receipt="$(mktemp "${TMPDIR:-/tmp}/aiur-restart-receipt.XXXXXX")"
3058
+ AIUR_RESTART_BUILD_RECEIPT="$receipt" bash -c "$AIUR_RESTART_BUILD_CMD" || build_status=$?
3059
+ if [ "$build_status" -ne 0 ]; then
3060
+ # Exit with the builder's own status rather than a flat 1, so a caller can
3061
+ # still tell a failed rebuild from any other abort.
3062
+ rm -f "$receipt"
3063
+ echo "❌ restart aborted before start: the rebuild failed" >&2
3064
+ aiur_restart_report_state
3065
+ trap - EXIT INT TERM
3066
+ exit "$build_status"
3067
+ fi
3068
+
3069
+ # A builder that declares AIUR_RESTART_BUILD_VERIFIES has promised a receipt,
3070
+ # so a missing or contradicted one is a real failure and an unverifiable
3071
+ # rebuild is not a lesser success — starting anyway would hand back the
3072
+ # stale-release outcome this ordering exists to prevent, with a "restarted"
3073
+ # line on top of it. A builder that makes no such promise cannot be held to
3074
+ # it; say plainly that the guarantee did not apply rather than convert an
3075
+ # unknown wrapper into a stopped fleet.
3076
+ if [ "${AIUR_RESTART_BUILD_VERIFIES:-0}" = "1" ]; then
3077
+ local verify_status=0
3078
+ verify_restart_build "$receipt" || verify_status=$?
3079
+ rm -f "$receipt"
3080
+ if [ "$verify_status" -ne 0 ]; then
3081
+ echo "❌ restart aborted before start: the rebuild could not be verified" >&2
3082
+ # Naming the builder makes an inherited AIUR_RESTART_BUILD_CMD — a dev
3083
+ # shim leaking into another shell — diagnosable from this output alone,
3084
+ # instead of an identical refusal on every retry.
3085
+ echo " rebuild command: ${AIUR_RESTART_BUILD_CMD}" >&2
3086
+ echo " Rebuild from the checkout you mean and retry, or 'aiur restart --no-build'" >&2
3087
+ echo " to start the release on disk without the guarantee." >&2
3088
+ aiur_restart_report_state
3089
+ trap - EXIT INT TERM
3090
+ exit 70
3091
+ fi
3092
+ else
3093
+ rm -f "$receipt"
3094
+ echo "aiur: UNVERIFIED rebuild — this builder reports no receipt, so restart" >&2
3095
+ echo " cannot confirm the release it is about to boot is the one just built" >&2
3096
+ fi
3097
+ elif [ "$skip_build" -eq 1 ]; then
3098
+ echo "aiur: --no-build — restarting on the release already on disk" >&2
3099
+ fi
3100
+
3101
+ # Always detached: the daemon this stopped was itself detached, and a restart
3102
+ # that captured the terminal would be a surprise. `--interactive` still opts
3103
+ # into the attachable session (`aiur --bg --interactive`), and a purely
3104
+ # foreground restart remains `aiur stop && aiur run`.
3105
+ dispatch_run --bg "${run_args[@]+"${run_args[@]}"}"
3106
+
3107
+ aiur_restart_daemon_state="running"
3108
+ trap - EXIT INT TERM
2014
3109
  }
2015
3110
 
2016
3111
  # --- dispatch ----------------------------------------------------------------
2017
3112
 
3113
+ dispatch_run() {
3114
+ local mode="foreground" arg
3115
+ local args=()
3116
+
3117
+ reject_legacy_config "$@"
3118
+
3119
+ for arg in "$@"; do
3120
+ if [ "$arg" = "--bg" ]; then
3121
+ mode="background"
3122
+ else
3123
+ args+=("$arg")
3124
+ fi
3125
+ done
3126
+
3127
+ # bash 3.2 (macOS default) errors on "${args[@]}" when args is empty under
3128
+ # `set -u` — happens for a bare `--bg` run. Guard the expansion.
3129
+ run_session "$mode" "${args[@]+"${args[@]}"}"
3130
+ }
3131
+
2018
3132
  aiur_engine_main() {
2019
3133
  local cmd="${1:-}"
3134
+ # Names the running subcommand in control-RPC diagnostics so a failure says
3135
+ # which command failed instead of a generic "control rpc" (#1684).
3136
+ AIUR_CONTROL_COMMAND="${cmd:-run}"
2020
3137
  case "$cmd" in
2021
3138
  __identity)
2022
3139
  aiur_print_identity
@@ -2027,25 +3144,69 @@ aiur_engine_main() {
2027
3144
  --version)
2028
3145
  run_version "$@"
2029
3146
  ;;
3147
+ --todo)
3148
+ run_todo "$@"
3149
+ ;;
3150
+ --only)
3151
+ echo "aiur: --only is valid only with --todo" >&2
3152
+ exit 64
3153
+ ;;
2030
3154
  init)
2031
3155
  run_init "$@"
2032
3156
  ;;
2033
- --bg)
3157
+ findings)
3158
+ run_findings "$@"
3159
+ ;;
3160
+ guard-pr-deletions)
2034
3161
  shift
2035
- run_session background "$@"
3162
+ "$engine_dir/guard-pr-deletions.sh" "$@"
3163
+ ;;
3164
+ ask | asks)
3165
+ run_asks "$@"
3166
+ ;;
3167
+ --bg)
3168
+ dispatch_run "$@"
2036
3169
  ;;
2037
3170
  run)
2038
3171
  shift
2039
- run_session foreground "$@"
3172
+ dispatch_run "$@"
2040
3173
  ;;
2041
3174
  status)
2042
3175
  shift
2043
3176
  cmd_status "$@"
2044
3177
  ;;
3178
+ usage)
3179
+ shift
3180
+ cmd_usage "$@"
3181
+ ;;
2045
3182
  agents)
2046
3183
  shift
2047
3184
  cmd_agents "$@"
2048
3185
  ;;
3186
+ commands)
3187
+ shift
3188
+ cmd_commands "$@"
3189
+ ;;
3190
+ executor-answer)
3191
+ shift
3192
+ cmd_executor_answer "$@"
3193
+ ;;
3194
+ executor-escalate)
3195
+ shift
3196
+ cmd_executor_escalate "$@"
3197
+ ;;
3198
+ units)
3199
+ shift
3200
+ cmd_units "$@"
3201
+ ;;
3202
+ build-orders)
3203
+ shift
3204
+ cmd_build_orders "$@"
3205
+ ;;
3206
+ analytics)
3207
+ shift
3208
+ cmd_analytics "$@"
3209
+ ;;
2049
3210
  alerts)
2050
3211
  shift
2051
3212
  cmd_alerts "$@"
@@ -2054,6 +3215,26 @@ aiur_engine_main() {
2054
3215
  shift
2055
3216
  cmd_watch "$@"
2056
3217
  ;;
3218
+ executor-listen)
3219
+ shift
3220
+ cmd_executor_listen "$@"
3221
+ ;;
3222
+ executor-wait)
3223
+ shift
3224
+ cmd_executor_wait "$@"
3225
+ ;;
3226
+ executor-emit)
3227
+ shift
3228
+ cmd_executor_emit "$@"
3229
+ ;;
3230
+ executor-subscribe | executor-unsubscribe)
3231
+ shift
3232
+ cmd_executor_subscription "$cmd" "$@"
3233
+ ;;
3234
+ executor-subscriptions)
3235
+ shift
3236
+ cmd_executor_subscriptions "$@"
3237
+ ;;
2057
3238
  set)
2058
3239
  shift
2059
3240
  cmd_set "$@"
@@ -2062,6 +3243,10 @@ aiur_engine_main() {
2062
3243
  shift
2063
3244
  cmd_pause_resume "$cmd" "$@"
2064
3245
  ;;
3246
+ reset-budget)
3247
+ shift
3248
+ cmd_reset_budget "$@"
3249
+ ;;
2065
3250
  message)
2066
3251
  shift
2067
3252
  cmd_message "$@"
@@ -2073,19 +3258,24 @@ aiur_engine_main() {
2073
3258
  stop)
2074
3259
  cmd_stop
2075
3260
  ;;
3261
+ restart)
3262
+ shift
3263
+ cmd_restart "$@"
3264
+ ;;
2076
3265
  "")
2077
- run_session foreground
3266
+ dispatch_run
2078
3267
  ;;
2079
3268
  -*)
2080
3269
  # leading-flag forms (e.g. `aiur --interactive <config>`) are a run
2081
- run_session foreground "$@"
3270
+ dispatch_run "$@"
2082
3271
  ;;
2083
3272
  *)
2084
3273
  # a path/config argument is a run; anything else is a usage error
2085
3274
  if [ -e "$cmd" ]; then
2086
- run_session foreground "$@"
3275
+ dispatch_run "$@"
2087
3276
  else
2088
3277
  echo "aiur: unknown command: $cmd" >&2
3278
+ warn_if_cli_behind_release_checkout
2089
3279
  usage >&2
2090
3280
  exit 64
2091
3281
  fi