aiur-cli 0.0.8 → 0.0.9-nightly.1de41ef

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.
@@ -20,4001 +20,121 @@
20
20
  #
21
21
  # The release is self-contained (bundled ERTS), so it runs without mise/Elixir on
22
22
  # PATH. Dev's build-if-stale step lives in the aiurdev shim, not here.
23
-
24
- set -euo pipefail
25
-
26
- # Raise the soft open-file limit toward the hard maximum. High agent concurrency
27
- # spawns many tmux/opencode/git subprocesses + sockets; on hosts with a low
28
- # default (macOS ships 256) that exhausts file descriptors (:emfile) and crashes
29
- # the node. Soft<=hard needs no privilege; best-effort, never fatal.
30
- __aiur_hard_nofile="$(ulimit -Hn 2>/dev/null || echo)"
31
- if [ "${__aiur_hard_nofile}" = "unlimited" ]; then
32
- ulimit -Sn 65536 2>/dev/null || true
33
- elif [ -n "${__aiur_hard_nofile}" ]; then
34
- ulimit -Sn "${__aiur_hard_nofile}" 2>/dev/null || true
35
- fi
36
- unset __aiur_hard_nofile
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
-
61
- die() {
62
- echo "❌ $*" >&2
63
- exit 1
64
- }
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
-
147
- engine_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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
-
206
- # --- distribution identity (per-instance: keyed by the aiur project root) -----
207
-
208
- # The aiur project root used to key this instance. AIUR_REPO_ROOT (set by the dev
209
- # shim) wins. Otherwise walk up from $PWD to the first dir holding a REPO-LOCAL
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
212
- # project under $HOME onto one key (#443). When no repo-local config is found, the
213
- # BEAM serves this run via the global config (mirroring its discovery order in
214
- # src/lib/aiur/workflow.ex) — or via none — and in both cases the project being
215
- # served is the cwd, so we key by realpath($PWD). That gives each global-config
216
- # project a distinct identity instead of an empty key (legacy aiur-$USER@…) or $HOME.
217
- #
218
- # Caveat: a global-config run's key is cwd-derived (there is no repo root to
219
- # converge on), so control commands (status/pause/stop) must be run from the SAME
220
- # directory the run was launched from. A repo with a repo-local .aiur/config keeps
221
- # the walk-up, so its control commands still resolve from any subdir.
222
- aiur_project_root() {
223
- if [ -n "${AIUR_REPO_ROOT:-}" ]; then printf '%s' "$AIUR_REPO_ROOT"; return; fi
224
-
225
- # Canonicalize $PWD and $HOME so the home-boundary test below holds even when
226
- # either is reached through a symlink.
227
- local pwd_real home_real
228
- pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
229
- # ${HOME:-} (not bare $HOME) so an unset HOME under `set -u` can't abort the
230
- # script here; an empty home_real simply disables the boundary (walk to /).
231
- home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
232
-
233
- local d="$pwd_real"
234
- while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
235
- if [ -f "$d/.aiur/config" ]; then
236
- printf '%s' "$d"
237
- return
238
- fi
239
- d="$(dirname "$d")"
240
- done
241
-
242
- printf '%s' "$pwd_real"
243
- }
244
-
245
- aiur_project_root_source() {
246
- if [ -n "${AIUR_REPO_ROOT:-}" ]; then printf 'env'; return; fi
247
-
248
- local pwd_real home_real
249
- pwd_real="$(pwd -P 2>/dev/null || printf '%s' "$PWD")"
250
- home_real="$(cd "${HOME:-}" 2>/dev/null && pwd -P || printf '%s' "${HOME:-}")"
251
-
252
- local d="$pwd_real"
253
- while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "$home_real" ]; do
254
- if [ -f "$d/.aiur/config" ]; then
255
- printf 'repo'
256
- return
257
- fi
258
- d="$(dirname "$d")"
259
- done
260
-
261
- printf 'cwd'
262
- }
263
-
264
- # Short, stable, node-name-legal (lowercase hex) key for the project root, so two
265
- # aiur instances for the same user get distinct node/session/socket names and can't
266
- # reap each other. Any real cwd now resolves a key (global-config runs key by
267
- # realpath($PWD), #443); only a degenerate unreadable cwd yields empty, falling back
268
- # to the legacy un-keyed name.
269
- aiur_instance_key() {
270
- local root
271
- root="$(aiur_project_root)"
272
- [ -n "$root" ] || return 0
273
- # Canonicalize (resolve symlinks) so launch and control commands invoked via
274
- # different logical paths to the same project agree on the key. Fall back to the
275
- # literal path when the dir doesn't exist (e.g. a test-fixture root).
276
- root="$(cd "$root" 2>/dev/null && pwd -P || printf '%s' "$root")"
277
- printf '%s' "$root" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-10
278
- }
279
-
280
- aiur_resolve_identity() {
281
- local config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
282
- AIUR_PROJECT_ROOT="$(aiur_project_root)"
283
- AIUR_PROJECT_ROOT_SOURCE="$(aiur_project_root_source)"
284
-
285
- : "${AIUR_BG_STATE_DIR:=$config_home/aiur}"
286
- : "${AIUR_COOKIE_FILE:=$AIUR_BG_STATE_DIR/cookie}"
287
- : "${AIUR_SESSION_PREFIX:=aiur}"
288
- : "${AIUR_PROFILES_FILE:=$config_home/aiur/aiur.profiles}"
289
- # Compute the per-instance key once. `${VAR+x}` so an explicit empty value (no
290
- # project resolved) is honored and not recomputed on each call.
291
- if [ -z "${AIUR_INSTANCE_KEY+x}" ]; then
292
- AIUR_INSTANCE_KEY="$(aiur_instance_key)"
293
- fi
294
- : "${AIUR_RELEASE_NODE:=aiur-${USER}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}@127.0.0.1}"
295
-
296
- export AIUR_BG_STATE_DIR AIUR_COOKIE_FILE AIUR_SESSION_PREFIX \
297
- AIUR_PROFILES_FILE AIUR_RELEASE_NODE AIUR_INSTANCE_KEY \
298
- AIUR_PROJECT_ROOT AIUR_PROJECT_ROOT_SOURCE
299
- }
300
-
301
- aiur_print_identity() {
302
- aiur_resolve_identity
303
- printf 'AIUR_RELEASE_DIR=%s\n' "${AIUR_RELEASE_DIR:-}"
304
- printf 'AIUR_BG_STATE_DIR=%s\n' "$AIUR_BG_STATE_DIR"
305
- printf 'AIUR_SESSION_PREFIX=%s\n' "$AIUR_SESSION_PREFIX"
306
- printf 'AIUR_PROFILES_FILE=%s\n' "$AIUR_PROFILES_FILE"
307
- printf 'AIUR_RELEASE_NODE=%s\n' "$AIUR_RELEASE_NODE"
308
- printf 'AIUR_INSTANCE_KEY=%s\n' "$AIUR_INSTANCE_KEY"
309
- printf 'AIUR_PROJECT_ROOT=%s\n' "$AIUR_PROJECT_ROOT"
310
- printf 'AIUR_PROJECT_ROOT_SOURCE=%s\n' "$AIUR_PROJECT_ROOT_SOURCE"
311
- printf 'AIUR_COOKIE_FILE=%s\n' "$AIUR_COOKIE_FILE"
312
- }
313
-
314
- # --- BEAM distribution (cookie + named node) ---------------------------------
315
-
316
- ensure_bg_state_dir() {
317
- aiur_resolve_identity
318
- mkdir -p "$AIUR_BG_STATE_DIR"
319
- }
320
-
321
- ensure_erlang_cookie() {
322
- aiur_resolve_identity
323
- ensure_bg_state_dir
324
-
325
- local cookie_file="$AIUR_COOKIE_FILE"
326
-
327
- if [ ! -f "$cookie_file" ]; then
328
- (
329
- umask 0177
330
- tmp_file="$(mktemp "$AIUR_BG_STATE_DIR/cookie.XXXXXX")"
331
- head -c 32 /dev/urandom | base64 | tr -d '\n=+/' | head -c 32 >"$tmp_file"
332
- mv "$tmp_file" "$cookie_file"
333
- )
334
- chmod 0400 "$cookie_file"
335
- fi
336
-
337
- [ -r "$cookie_file" ] || die "$cookie_file is not readable"
338
-
339
- if [ "$(stat -c '%U' "$cookie_file" 2>/dev/null || stat -f '%Su' "$cookie_file")" != "$USER" ]; then
340
- die "$cookie_file is not owned by $USER"
341
- fi
342
-
343
- local size
344
- size="$(wc -c <"$cookie_file" | tr -d ' ')"
345
- [ "$size" -ge 16 ] || die "$cookie_file is shorter than 16 bytes"
346
-
347
- printf '%s' "$cookie_file"
348
- }
349
-
350
- prepare_distribution() {
351
- aiur_resolve_identity
352
-
353
- local cookie_file
354
- cookie_file="$(ensure_erlang_cookie)" || return 1
355
-
356
- local cookie
357
- cookie="$(cat "$cookie_file")"
358
-
359
- export RELEASE_DISTRIBUTION="name"
360
- export RELEASE_NODE="$AIUR_RELEASE_NODE"
361
- export RELEASE_COOKIE="$cookie"
362
- # Pin the distribution listener to 127.0.0.1 too, matching the node-name IP.
363
- # {127,0,0,1} is the Erlang tuple — unquoted because bash inside the
364
- # surrounding double quotes does not brace-expand it.
365
- export ERL_AFLAGS=" -proto_dist inet_tcp -kernel inet_dist_use_interface {127,0,0,1}"
366
- export ERL_EPMD_ADDRESS="127.0.0.1"
367
- export AIUR_NODE="$RELEASE_NODE"
368
- export AIUR_ERLANG_COOKIE="$RELEASE_COOKIE"
369
- }
370
-
371
- # --- release resolution ------------------------------------------------------
372
-
373
- release_dir=""
374
- vsn_dir=""
375
- release_bin=""
376
-
377
- control_release_retry() {
378
- [ -n "${AIUR_CONTROL_RELEASE_RETRY_SIGNAL:-}" ] && : >"$AIUR_CONTROL_RELEASE_RETRY_SIGNAL"
379
- return 75
380
- }
381
-
382
- resolve_release() {
383
- release_dir="${AIUR_RELEASE_DIR:-}"
384
- [ -n "$release_dir" ] || die "AIUR_RELEASE_DIR is not set; the engine must be invoked via the aiur or aiurdev wrapper"
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
394
-
395
- local release_vsn
396
- release_vsn="$(cut -d' ' -f2 "$release_dir/releases/start_erl.data" 2>/dev/null || true)"
397
- vsn_dir="$release_dir/releases/$release_vsn"
398
- release_bin="$release_dir/bin/aiur"
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
406
- }
407
-
408
- # --- argv round-trip (System.argv is empty under `elixir --eval`) -------------
409
-
410
- argv_file=""
411
- init_argv_file() {
412
- argv_file="$(mktemp "${TMPDIR:-/tmp}/aiur-argv.XXXXXX")"
413
- : >"$argv_file"
414
- }
415
- write_argv() {
416
- local a
417
- for a in "$@"; do printf '%s\n' "$a" >>"$argv_file"; done
418
- }
419
-
420
- release_cmd=()
421
- build_release_cmd() {
422
- release_cmd=(
423
- "$vsn_dir/elixir"
424
- --cookie "$RELEASE_COOKIE"
425
- --name "$RELEASE_NODE"
426
- --erl-config "$vsn_dir/sys"
427
- --boot "$vsn_dir/start_clean"
428
- --boot-var RELEASE_LIB "$release_dir/lib"
429
- --vm-args "$vsn_dir/vm.args"
430
- --eval "Aiur.CLI.main(Aiur.CLI.argv_from_file())"
431
- )
432
- }
433
-
434
- # Distribution-free boot for the `init` wizard: same interactive `--eval` form
435
- # (so the wizard's prompts receive keystrokes — `bin/aiur eval` is -noinput),
436
- # but with no --name/--cookie since the wizard makes no RPC calls.
437
- build_init_cmd() {
438
- release_cmd=(
439
- "$vsn_dir/elixir"
440
- --erl-config "$vsn_dir/sys"
441
- --boot "$vsn_dir/start_clean"
442
- --boot-var RELEASE_LIB "$release_dir/lib"
443
- --vm-args "$vsn_dir/vm.args"
444
- --eval "Aiur.CLI.main(Aiur.CLI.argv_from_file())"
445
- )
446
- }
447
-
448
- usage() {
449
- cat <<'EOF'
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
454
- aiur stop stop the running session
455
- aiur restart [--no-build] [run flags] stop, refresh the build, start again (detached)
456
- aiur status show agent status
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> [--supersede] [--executor-id <id>]
460
- aiur executor-escalate <decision-id> --expected-version <n> --reason <text> [--executor-id <id>]
461
- aiur executor-moot <decision-id> --expected-version <n> --reason-class <class> [--reason <text>] [--executor-id <id>]
462
- aiur units [--scope live|unfinished|all|none] [--condition active|alert|paused|queued|finished]... [--format auto|table|records] [--json]
463
- aiur build-orders [<root>] [--json] show the Build Order catalog or one root
464
- aiur analytics [--range run|full] [--since <ISO-8601>] [--until <ISO-8601>] [--build-order <id>] [--json]
465
- aiur github-cost [--budget graphql|core|all] [--format auto|table|records] [--json] rank GitHub API spend by call site
466
- aiur github-usage [--json] per-actor (daemon vs agent) GitHub usage and ceilings
467
- aiur alerts [--needs-attention] show structured alert feed
468
- aiur watch [--full|--changes] [--interval <secs>] server-side status board
469
- aiur executor-listen [--topic <pattern>] stream Executor events as JSON lines
470
- aiur executor-wait [--timeout <seconds>] [--json] block until Executor work arrives
471
- aiur executor-fast-forward <wake-id> [--as <id>] acknowledge an externally covered wake prefix
472
- aiur executor-emit <topic> --payload <json> publish an Executor event
473
- aiur executor-subscribe|executor-unsubscribe <pattern>
474
- aiur executor-subscriptions list persistent Executor bindings
475
- aiur executor-roster [--json] list Executor consumers with their liveness evidence
476
- aiur executor-claim [--as <id>] claim the wake stream, or refuse and name the live owner
477
- aiur executor-release [--as <id>] give up this consumer's claim
478
- aiur executor-revoke <consumer-id> operator-only revoke of a live owner's claim
479
- aiur set max-agents <n> change the concurrent-agent cap at runtime
480
- aiur upgrade [--force] install the newer aiur-cli on your channel
481
- aiur pause | resume flip the global pause switch (whole daemon)
482
- aiur pause <ids|--all> | resume <ids|--all> per-agent pause/resume
483
- aiur message <id> [--message-id ID] <text> send Executor text to a running agent
484
- aiur --todo <ids...> [--only] queue tickets; optionally dequeue all other pending tickets
485
- aiur findings [--unfiled] [--slugs] [--scope aiur|repo] inspect host-local findings
486
- aiur findings --record <json> --repo <owner/repo> append one validated finding
487
- aiur findings --digest [--scope aiur|repo] generate the promoted Markdown digest
488
- aiur ask <title> [--body <text>|--body-file <path>] [--urgency low|normal|high] [--blocking]
489
- aiur ask --done <id> [--note <text>] create or resolve an operator request
490
- aiur asks [--open|--all] [--json] inspect current-repository operator requests
491
- aiur cleanup-stale [--dry-run] list/reap stale manual-smoke leftovers
492
- aiur --version
493
-
494
- Bare aiur: start or attach to this directory's interactive session.
495
- EOF
496
- }
497
-
498
- # --- one-shot: --version (no tmux) -------------------------------------------
499
-
500
- run_version() {
501
- resolve_release
502
- AIUR_CLI_VERSION="$(cli_package_version || true)"
503
- export AIUR_CLI_VERSION
504
- # Distribution-free (like init): printing the version is a compile-time
505
- # constant, so never claim the node name — otherwise `aiur --version` fails
506
- # whenever an aiur session is already running.
507
- build_init_cmd
508
- init_argv_file
509
- write_argv "$@"
510
- export AIUR_ARGV_FILE="$argv_file"
511
- exec "${release_cmd[@]}"
512
- }
513
-
514
- # --- one-shot: init (interactive wizard, distribution-free, no tmux) ----------
515
-
516
- run_init() {
517
- resolve_release
518
- build_init_cmd
519
- init_argv_file
520
- write_argv "$@"
521
- export AIUR_ARGV_FILE="$argv_file"
522
- exec "${release_cmd[@]}"
523
- }
524
-
525
- # --- one-shot: --todo (control RPC; requires a running daemon) ----------------
526
-
527
- parsed_todo_only=0
528
-
529
- parse_todo_args() {
530
- parsed_targets=()
531
- parsed_todo_only=0
532
-
533
- local saw_todo=0 raw part parts
534
- for raw in "$@"; do
535
- case "$raw" in
536
- --todo)
537
- [ "$saw_todo" -eq 0 ] || return 1
538
- saw_todo=1
539
- ;;
540
- --only)
541
- parsed_todo_only=1
542
- ;;
543
- -*)
544
- return 1
545
- ;;
546
- *)
547
- IFS=',' read -ra parts <<<"$raw"
548
- for part in "${parts[@]}"; do
549
- part="$(trim "$part")"
550
- if [ -z "$part" ] || [[ ! "$part" =~ ^[0-9]+$ ]]; then return 1; fi
551
- while [ "${#part}" -gt 1 ] && [ "${part#0}" != "$part" ]; do
552
- part="${part#0}"
553
- done
554
- parsed_targets+=("$part")
555
- done
556
- ;;
557
- esac
558
- done
559
-
560
- [ "$saw_todo" -eq 1 ] && [ "${#parsed_targets[@]}" -gt 0 ]
561
- }
562
-
563
- run_todo() {
564
- if ! parse_todo_args "$@"; then
565
- echo "aiur: --todo expects one or more numeric issue IDs, optionally followed by --only" >&2
566
- exit 64
567
- fi
568
-
569
- local only_arg="false"
570
- [ "$parsed_todo_only" -eq 1 ] && only_arg="true"
571
- run_control_rpc "Aiur.AgentControlCLI.todo($(elixir_list_literal "${parsed_targets[@]}"), only: $only_arg, emit_exit_marker: true)"
572
- }
573
-
574
- # --- one-shot: findings (distribution-free, no daemon/tmux) -------------------
575
-
576
- run_findings() {
577
- run_init "$@"
578
- }
579
-
580
- # --- one-shot: ask / asks (distribution-free, no daemon/tmux) ----------------
581
-
582
- run_asks() {
583
- run_init "$@"
584
- }
585
-
586
- # --- interactive / background run -------------------------------------------
587
- #
588
- # mode=foreground attaches the UI and tears down on exit; mode=background leaves
589
- # the detached tmux session running and returns.
590
-
591
- # Dotenv precedence: shell exports win, then ./.env, then ~/.aiur/.env. Each
592
- # file only fills names that are still unset, so the repo-local file (the more
593
- # specific scope) is read first and the machine-wide file only fills its gaps.
594
- # A blank value is a placeholder, not a setting: `aiur init` scaffolds
595
- # `GITHUB_TOKEN=` and `.env.example` renders every name blank, so a blank line
596
- # neither exports nor shadows anything.
597
- #
598
- # GitHub credentials are one group. A repo `.env` that sets any member has
599
- # chosen that repository's auth mode, so none of the global file's members may
600
- # leak in beside it: a global GITHUB_APP_* triple filling the gaps around a
601
- # repo-local GITHUB_TOKEN would outrank that token and force the daemon onto an
602
- # App that is not installed on the repo (#2638).
603
- GITHUB_CREDENTIAL_ENV_NAMES="GITHUB_TOKEN GITHUB_APP_ID GITHUB_APP_INSTALLATION_ID GITHUB_APP_PRIVATE_KEY_PATH GITHUB_APP_PRIVATE_KEY"
604
-
605
- load_dotenv() {
606
- local global_skip=""
607
- if dotenv_file_sets_github_credential ".env"; then
608
- global_skip="github_credentials"
609
- fi
610
- load_dotenv_file ".env"
611
- load_dotenv_file "$HOME/.aiur/.env" "$global_skip"
612
- }
613
-
614
- github_credential_env_name() {
615
- case " $GITHUB_CREDENTIAL_ENV_NAMES " in
616
- *" $1 "*) return 0 ;;
617
- esac
618
- return 1
619
- }
620
-
621
- # Parses one dotenv line into DOTENV_KEY / DOTENV_VAL. Returns 1 for blank,
622
- # comment, malformed, and blank-valued lines so callers skip them. Shared by
623
- # the loader and the credential-group probe so both agree on what counts.
624
- parse_dotenv_line() {
625
- local line="$1" key val
626
- DOTENV_KEY="" DOTENV_VAL=""
627
- line="${line#"${line%%[![:space:]]*}"}"
628
- case "$line" in '' | '#'*) return 1 ;; esac
629
- [ "${line#*=}" = "$line" ] && return 1
630
- key="${line%%=*}"
631
- key="${key%"${key##*[![:space:]]}"}"
632
- case "$key" in '' | *[!A-Za-z0-9_]*) return 1 ;; esac
633
- val="${line#*=}"
634
- val="${val#"${val%%[![:space:]]*}"}"
635
- case "$val" in
636
- \"*\") val="${val#\"}" && val="${val%\"}" ;;
637
- \'*\') val="${val#\'}" && val="${val%\'}" ;;
638
- esac
639
- [ -n "$val" ] || return 1
640
- DOTENV_KEY="$key"
641
- DOTENV_VAL="$val"
642
- }
643
-
644
- # True when the file sets any GitHub credential name to a non-blank value.
645
- dotenv_file_sets_github_credential() {
646
- local file="$1" line
647
- [ -f "$file" ] || return 1
648
- while IFS= read -r line || [ -n "$line" ]; do
649
- parse_dotenv_line "$line" || continue
650
- if github_credential_env_name "$DOTENV_KEY"; then
651
- return 0
652
- fi
653
- done <"$file"
654
- return 1
655
- }
656
-
657
- # Usage: load_dotenv_file <file> [github_credentials]
658
- # The optional second argument skips the GitHub credential group.
659
- load_dotenv_file() {
660
- local file="$1" skip="${2:-}" line
661
- [ -f "$file" ] || return 0
662
- while IFS= read -r line || [ -n "$line" ]; do
663
- parse_dotenv_line "$line" || continue
664
- if [ "$skip" = "github_credentials" ] && github_credential_env_name "$DOTENV_KEY"; then
665
- continue
666
- fi
667
- [ -n "${!DOTENV_KEY+x}" ] && continue
668
- export "$DOTENV_KEY=$DOTENV_VAL"
669
- done <"$file"
670
- }
671
-
672
- # The readiness token grants the one-shot `aiur init` assessment access that a
673
- # normal daemon and its child agents must never inherit. Keep dotenv loading
674
- # generic, then remove this run-only secret before any session process starts.
675
- scrub_run_only_env() {
676
- unset AIUR_CI_READINESS_TOKEN
677
- }
678
-
679
- run_argv=()
680
- # Default dashboard bind host. Prefer this machine's Tailscale IPv4 so the
681
- # dashboard is reachable across the tailnet by default (when config omits it);
682
- # fall back to loopback when Tailscale is absent, or when dashboard credentials
683
- # are unset (a non-loopback bind requires them, so we stay on loopback rather
684
- # than refuse to start). The BEAM applies this below an explicit `server.host`,
685
- # while an explicit `--host` remains the highest-precedence value.
686
- default_dashboard_host() {
687
- local ip=""
688
- if command -v tailscale >/dev/null 2>&1; then
689
- ip="$(tailscale ip -4 2>/dev/null | grep -m1 -E '^100\.[0-9]+\.[0-9]+\.[0-9]+$' || true)"
690
- fi
691
- if [ -n "$ip" ] && [ -n "${AIUR_DASHBOARD_USERNAME:-}" ] && [ -n "${AIUR_DASHBOARD_PASSWORD:-}" ]; then
692
- printf '%s' "$ip"
693
- else
694
- printf '127.0.0.1'
695
- fi
696
- }
697
-
698
- build_run_argv() {
699
- local mode="$1"
700
- shift
701
-
702
- local has_interactive=0 has_headless=0 has_ack=0 arg
703
- for arg in "$@"; do
704
- case "$arg" in
705
- --interactive) has_interactive=1 ;;
706
- --headless) has_headless=1 ;;
707
- --i-understand-that-this-will-be-running-without-the-usual-guardrails) has_ack=1 ;;
708
- esac
709
- done
710
-
711
- local injected=()
712
- if [ "$mode" = "background" ] && [ "$has_interactive" -eq 0 ]; then
713
- [ "$has_headless" -eq 1 ] || injected+=(--headless)
714
- else
715
- [ "$has_interactive" -eq 1 ] || injected+=(--interactive)
716
- fi
717
- [ "$has_ack" -eq 1 ] || injected+=(--i-understand-that-this-will-be-running-without-the-usual-guardrails)
718
-
719
- run_argv=("${injected[@]}" "$@")
720
- }
721
-
722
- run_session() {
723
- local mode="$1"
724
- shift
725
-
726
- # --debug is an engine-level convenience on the run path: turn on verbose
727
- # logging (AIUR_DEBUG) and strip the flag before the release parses argv.
728
- local run_args=() run_arg
729
- for run_arg in "$@"; do
730
- case "$run_arg" in
731
- --debug) export AIUR_DEBUG=1 ;;
732
- *) run_args+=("$run_arg") ;;
733
- esac
734
- done
735
- if [ "${#run_args[@]}" -gt 0 ]; then set -- "${run_args[@]}"; else set --; fi
736
-
737
- resolve_release
738
-
739
- local tmux_bin
740
- tmux_bin="$(command -v tmux || true)"
741
- [ -n "$tmux_bin" ] || die "tmux is required to run aiur; install tmux and retry"
742
-
743
- aiur_resolve_identity
744
- prepare_distribution
745
-
746
- local session="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default"
747
- local socket="${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}"
748
- local conf launch_lock
749
- conf="$(resolve_tmux_conf)"
750
- [ -f "$conf" ] || die "tmux conf not found at $conf"
751
- launch_lock="$(aiur_launch_lock_path)"
752
- acquire_aiur_launch_lock "$launch_lock" || return 1
753
-
754
- # An invocation targeting an existing directory session resolves here before
755
- # fresh-start credentials, argv files, run logs, watchdogs, or teardown traps.
756
- # Foreground attaches; background exits with explicit attach guidance.
757
- if "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
758
- if [ "$(probe_control_liveness)" = "up" ]; then
759
- # Preserve the live launch's record and workspace-root handoff. Only
760
- # backfill old/missing records, without pointing them at this attach
761
- # invocation's transient state.
762
- AIUR_WORKSPACE_ROOT_FILE="" write_aiur_instance_record "$session" "$socket" if-absent
763
-
764
- if [ "$mode" = "background" ]; then
765
- echo "aiur is already running in the background (tmux session ${session})." >&2
766
- echo "Attach with: aiur" >&2
767
- echo "Use: aiur status # inspect agents" >&2
768
- echo "Use: aiur stop # stop it before starting a fresh session" >&2
769
- release_aiur_launch_lock "$launch_lock"
770
- return 0
771
- fi
772
-
773
- echo "aiur is already running; attaching to the running session ${session}." >&2
774
- release_aiur_launch_lock "$launch_lock"
775
- attach_tmux_session "$tmux_bin" "$socket" "$conf" "$session"
776
- return $?
777
- fi
778
-
779
- # A tmux holder can exist while a healthy BEAM is still booting or while
780
- # epmd itself is temporarily unreachable. Never turn one failed control
781
- # probe into destructive cleanup unless distribution confirms the keyed
782
- # node is absent.
783
- case "$(probe_node_liveness)" in
784
- down)
785
- if ! confirm_stale_tmux_session "$tmux_bin" "$socket" "$conf" "$session"; then
786
- echo "aiur found session ${session}, but it became live while checking stale state; leaving it intact." >&2
787
- echo "Retry: aiur" >&2
788
- release_aiur_launch_lock "$launch_lock"
789
- return 1
790
- fi
791
- echo "aiur found stale tmux session ${session}; cleaning it up before restart" >&2
792
- reap_aiur_agents "$socket" ""
793
- kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
794
- ;;
795
- up | unknown)
796
- echo "aiur found session ${session}, but its control plane is not ready; leaving the running session intact." >&2
797
- echo "Retry: aiur" >&2
798
- release_aiur_launch_lock "$launch_lock"
799
- return 1
800
- ;;
801
- esac
802
- fi
803
-
804
- # Pick up GITHUB_TOKEN / dashboard creds the wizard wrote to ./.env so the
805
- # running tracker can authenticate. Shell exports still take precedence.
806
- load_dotenv
807
- scrub_run_only_env
808
- export AIUR_DEFAULT_DASHBOARD_HOST="$(default_dashboard_host)"
809
-
810
- # The BEAM lives in tmux, but a fresh foreground run belongs to this shell:
811
- # it waits on the UI attach and owns the teardown trap. Hand its pid to the
812
- # pane watchdog so a hard-killed launcher cannot leave agents running.
813
- # A detached run has no such owner; discard any inherited stale value.
814
- if [ "$mode" = "foreground" ]; then
815
- export AIUR_LAUNCHER_PID="$$"
816
- else
817
- unset AIUR_LAUNCHER_PID
818
- fi
819
-
820
- # The daemon's `Aiur.Upgrade` check uses the CLI package version (not the mix
821
- # version) as the "installed" version, so an npm install's notice names what
822
- # the user actually has. `run_version` sets the same var for `--version`.
823
- AIUR_CLI_VERSION="$(cli_package_version || true)"
824
- export AIUR_CLI_VERSION
825
-
826
- init_argv_file
827
-
828
- # Supply the flags a bare `aiur` needs: UI mode and the no-guardrails ack.
829
- # The exported dashboard host fills only a missing config value; an explicit
830
- # server.host or --host remains authoritative. Foreground runs are
831
- # interactive; `--bg` runs headless (no panes/chat backfill) and is driven
832
- # over the control RPC (status/agents/message/pause/set). Dashboard binding is
833
- # independent: it remains enabled in either mode unless `--no-dashboard` is
834
- # supplied. `aiur --bg --interactive` opts back into the full terminal stack
835
- # for an attachable background session.
836
- build_run_argv "$mode" "$@"
837
- local no_dashboard=0
838
- for run_arg in "${run_argv[@]}"; do
839
- [ "$run_arg" = "--no-dashboard" ] && no_dashboard=1
840
- done
841
- write_argv "${run_argv[@]}"
842
- export AIUR_ARGV_FILE="$argv_file"
843
-
844
- build_release_cmd
845
-
846
- # Force +fnu when no locale is set so the BEAM does not mangle non-ASCII paths.
847
- if [ -z "${LANG:-}" ] && [ -z "${LC_ALL:-}" ] && [ -z "${LC_CTYPE:-}" ]; then
848
- export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS:-} +fnu"
849
- fi
850
-
851
- preflight_stale_manual_smoke
852
-
853
- mkdir -p "$AIUR_BG_STATE_DIR"
854
- printf '%s\n' "$session" >"$AIUR_BG_STATE_DIR/state"
855
- export AIUR_TMUX_SESSION="$session"
856
- export AIUR_TMUX_SOCKET="$socket"
857
- export AIUR_TMUX_CONF="$conf"
858
- export AIUR_BIN="${BASH_SOURCE[0]}"
859
-
860
- local session_root="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
861
- export AIUR_SESSION_TMPFILE="${session_root}/aiur-${$}-sessions"
862
- : >"$AIUR_SESSION_TMPFILE"
863
-
864
- # Agent pidfile: the BEAM appends one line per spawned agent (pane or headless
865
- # os_pid) via Aiur.ProcessReaper. The BEAM-death watchdog and session_cleanup
866
- # reap from it after the BEAM is gone — a crashed BEAM can kill nothing itself.
867
- export AIUR_AGENT_TMPFILE="${session_root}/aiur-${$}-agents"
868
- : >"$AIUR_AGENT_TMPFILE"
869
-
870
- # Workspace-root handoff for the shell cwd-sweep backstop. The BEAM writes the
871
- # resolved Aiur.Config.workspace_root() here after config loads.
872
- export AIUR_WORKSPACE_ROOT_FILE="${session_root}/aiur-${$}-workspace-root"
873
- : >"$AIUR_WORKSPACE_ROOT_FILE"
874
-
875
- # Background runs persist under a known run log dir so (a) the BEAM-death
876
- # watchdog, which outlives the BEAM, can drop a crash record next to aiur.log,
877
- # and (b) the post-start boot capture is not lost to /tmp when the launcher
878
- # removes its startup tempfile. Minting it here and exporting it makes the
879
- # shell and the BEAM (Aiur.LogFile honors AIUR_LOGS_ROOT) agree on one dir.
880
- if [ "$mode" = "background" ] && [ -z "${AIUR_LOGS_ROOT:-}" ]; then
881
- AIUR_LOGS_ROOT="$(printf '%s/%s-%s' "$HOME/.aiur/logs" "$(date -u +%Y%m%dT%H%M%SZ)" "$$")"
882
- export AIUR_LOGS_ROOT
883
- fi
884
-
885
- # Capture an erl_crash.dump on daemon BEAM death (#852) so a crash under load
886
- # is diagnosable instead of vanishing. Written next to the run's aiur.log so it
887
- # survives the launcher's tempfile cleanup; ERL_CRASH_DUMP_SECONDS bounds the
888
- # write so a wedged BEAM can't hang the dump indefinitely. Nothing in the
889
- # release boot disables dumps, and an Executor override of either var is kept.
890
- # Requires a durable logs root (background run or agent IR sandbox).
891
- if [ -n "${AIUR_LOGS_ROOT:-}" ]; then
892
- export ERL_CRASH_DUMP="${ERL_CRASH_DUMP:-$AIUR_LOGS_ROOT/erl_crash.dump}"
893
- export ERL_CRASH_DUMP_SECONDS="${ERL_CRASH_DUMP_SECONDS:-30}"
894
- fi
895
-
896
- # Crash recording needs two launcher/BEAM handoffs: the canonical alert
897
- # ledger path and the launch-time identity of the configured dump path. Keep
898
- # these for foreground launches too: their external watchdog is also the only
899
- # process left to surface a dump after an unexpected daemon death.
900
- export AIUR_ALERT_LEDGER_PATH_FILE=""
901
- local crash_dump_baseline_file=""
902
- if [ -n "${ERL_CRASH_DUMP:-}" ]; then
903
- AIUR_ALERT_LEDGER_PATH_FILE="${session_root}/aiur-${$}-alert-ledger"
904
- : >"$AIUR_ALERT_LEDGER_PATH_FILE"
905
- crash_dump_baseline_file="${session_root}/aiur-${$}-crash-dump-baseline"
906
- crash_dump_identity "${ERL_CRASH_DUMP:-}" >"$crash_dump_baseline_file" 2>/dev/null || : >"$crash_dump_baseline_file"
907
- fi
908
-
909
- # Capture sink for BEAM startup (and, in background mode, the whole run's
910
- # boot stdout/stderr). Foreground uses a throwaway tempfile; background points
911
- # at a durable file in the run log dir. The dir/file is created lazily just
912
- # before launch (below) so an idempotent early-return start leaves no empty dir.
913
- local startup_capture
914
- if [ "$mode" = "background" ] && [ -n "${AIUR_LOGS_ROOT:-}" ]; then
915
- startup_capture="$AIUR_LOGS_ROOT/log/boot.out.log"
916
- else
917
- startup_capture="$(mktemp "${TMPDIR:-/tmp}/aiur-startup.XXXXXX")"
918
- fi
919
-
920
- # Inner pane script: tmux's server may pre-exist and not inherit our env, so
921
- # re-export every var the BEAM needs. tee preserves a startup capture.
922
- local launcher
923
- launcher="$(mktemp "${TMPDIR:-/tmp}/aiur-pane.XXXXXX")"
924
- chmod +x "$launcher"
925
- {
926
- printf '#!/usr/bin/env bash\n'
927
- printf 'set -o pipefail\n'
928
- printf 'unset AIUR_CI_READINESS_TOKEN\n'
929
- printf 'cd %q || exit 1\n' "$PWD"
930
- local v
931
- for v in AIUR_RELEASE_DIR AIUR_ARGV_FILE RELEASE_DISTRIBUTION RELEASE_NODE \
932
- RELEASE_COOKIE ERL_AFLAGS ERL_EPMD_ADDRESS AIUR_NODE AIUR_ERLANG_COOKIE \
933
- AIUR_TMUX_SESSION AIUR_TMUX_SOCKET AIUR_TMUX_CONF AIUR_BIN \
934
- AIUR_SESSION_TMPFILE AIUR_AGENT_TMPFILE AIUR_WORKSPACE_ROOT_FILE AIUR_ALERT_LEDGER_PATH_FILE \
935
- ELIXIR_ERL_OPTIONS AIUR_LOGS_ROOT AIUR_OPENCODE_BRIDGE_PORT AIUR_DEFAULT_DASHBOARD_HOST AIUR_DEBUG AIUR_DEV_TEST_TICKET_IDS \
936
- AIUR_OPERATOR_PID AIUR_LAUNCHER_PID AIUR_NOFILE_SOFT_LIMIT ERL_CRASH_DUMP ERL_CRASH_DUMP_SECONDS \
937
- AIUR_BG_STATE_DIR AIUR_CLI_VERSION; do
938
- if [ -n "${!v:-}" ]; then printf 'export %s=%q\n' "$v" "${!v}"; fi
939
- done
940
- printf 'capture=%q\n' "$startup_capture"
941
- printf '%q' "${release_cmd[0]}"
942
- for arg in "${release_cmd[@]:1}"; do printf ' %q' "$arg"; done
943
- printf ' 2>&1 | tee -a "$capture"\n'
944
- printf 'exit ${PIPESTATUS[0]}\n'
945
- } >"$launcher"
946
-
947
- local inner_cmd
948
- printf -v inner_cmd '%q; rc=$?; rm -f %q; exit $rc' "$launcher" "$launcher"
949
-
950
- local launch_tempfiles=(
951
- "$startup_capture" "$argv_file" "$launcher" "$AIUR_SESSION_TMPFILE"
952
- "$AIUR_AGENT_TMPFILE" "$AIUR_WORKSPACE_ROOT_FILE"
953
- )
954
- [ -n "$AIUR_ALERT_LEDGER_PATH_FILE" ] && launch_tempfiles+=("$AIUR_ALERT_LEDGER_PATH_FILE")
955
- [ -n "$crash_dump_baseline_file" ] && launch_tempfiles+=("$crash_dump_baseline_file")
956
-
957
- if [ "$mode" = "foreground" ]; then
958
- _session_socket="$socket" _session_name="$session" _session_conf="$conf" \
959
- _session_tmpfile="$AIUR_SESSION_TMPFILE" _session_capture="$startup_capture" \
960
- _session_argv="$argv_file" _session_release="$release_dir" _session_tmux="$tmux_bin" \
961
- _session_node="$AIUR_RELEASE_NODE" _session_pidfile="$AIUR_AGENT_TMPFILE" \
962
- _session_workspace_root_file="$AIUR_WORKSPACE_ROOT_FILE" \
963
- _session_alert_ledger_path_file="$AIUR_ALERT_LEDGER_PATH_FILE" \
964
- _session_crash_dump_baseline_file="$crash_dump_baseline_file" \
965
- _session_launch_lock="$launch_lock"
966
- install_foreground_traps
967
- fi
968
-
969
- # An orphaned BEAM (its tmux session gone) can still hold THIS instance's node
970
- # name. With no live session on our (instance-keyed) socket, reap the name-holder
971
- # so the launch isn't blocked by "name seems to be in use". The keyed name means
972
- # this only ever reaps our own instance's orphan, never another live aiur.
973
- if ! "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
974
- kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
975
- fi
976
-
977
- # Transition reclaim: a keyed instance also reaps a stale beam under the legacy
978
- # un-keyed name (aiur-$USER@127.0.0.1) — but only when no live legacy session
979
- # exists, so a pre-fix run still in progress is never killed. (Runs on every keyed
980
- # launch, not latched; it's a cheap best-effort sweep guarded by has-session.)
981
- if [ -n "${AIUR_INSTANCE_KEY:-}" ]; then
982
- local legacy_socket="${AIUR_SESSION_PREFIX}-${USER:-user}"
983
- if ! "$tmux_bin" -L "$legacy_socket" -f "$conf" has-session -t "${legacy_socket}-default" 2>/dev/null; then
984
- kill_beams_matching "-name aiur-${USER}@127.0.0.1"
985
- fi
986
- fi
987
-
988
- # Create the capture sink now (after the idempotent early-return checks above,
989
- # so a no-op "already running" start never mints an empty run log dir). The
990
- # launcher's `tee -a` needs the file's parent to exist when the pane runs.
991
- mkdir -p "$(dirname "$startup_capture")" 2>/dev/null || true
992
- : >"$startup_capture" 2>/dev/null || true
993
-
994
- if ! "$tmux_bin" -L "$socket" -f "$conf" new-session -d -s "$session" \
995
- -x "${COLUMNS:-200}" -y "${LINES:-50}" "$inner_cmd"; then
996
- echo "❌ aiur failed to start; captured output:" >&2
997
- tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
998
- rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
999
- release_aiur_launch_lock "$launch_lock"
1000
- exit 1
1001
- fi
1002
-
1003
- # Each instance has its own tmux socket. Publish the executable shipped next
1004
- # to this engine before the TUI opens a chat pane; tmux receives the path as
1005
- # one argument, including when the npm install directory contains spaces.
1006
- if [ "$mode" = "foreground" ]; then
1007
- local ctrlc_helper="$engine_dir/aiur-pane-ctrlc"
1008
- if [ ! -x "$ctrlc_helper" ] ||
1009
- ! "$tmux_bin" -L "$socket" -f "$conf" set-option -g @aiur_ctrlc "$ctrlc_helper"; then
1010
- "$tmux_bin" -L "$socket" -f "$conf" kill-session -t "$session" 2>/dev/null || true
1011
- rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
1012
- release_aiur_launch_lock "$launch_lock"
1013
- die "aiur pane control helper is unavailable at $ctrlc_helper"
1014
- fi
1015
- fi
1016
-
1017
- # Title the agent-list pane (the only pane in the fresh session). The conf's
1018
- # `pane-border-status`/`pane-border-format` render it; PaneManager titles the
1019
- # chat panes it opens. Best-effort — a missing title just shows the default.
1020
- "$tmux_bin" -L "$socket" -f "$conf" select-pane -t "$session" -T "AIUR Agents" 2>/dev/null || true
1021
-
1022
- # Grace window: surface a boot crash instead of attaching to nothing. Prove
1023
- # the control plane can answer before declaring startup usable: background
1024
- # control commands depend on that RPC path, and foreground automation must not
1025
- # treat a pre-TUI application crash as a successful `tmux attach`.
1026
- local require_control=1
1027
- if ! wait_for_session_startup "$tmux_bin" "$socket" "$conf" "$session" "$startup_capture" "$require_control"; then
1028
- print_config_status "$startup_capture"
1029
- # The application can write the ledger handoff and then crash before its
1030
- # control plane becomes ready. Inspect the completed dump before launcher
1031
- # cleanup removes that handoff; this path intentionally writes no generic
1032
- # crash marker when there is no new completed dump.
1033
- record_new_crash_dump_alert "$AIUR_ALERT_LEDGER_PATH_FILE" "$crash_dump_baseline_file"
1034
- if [ "$mode" = "background" ]; then
1035
- "$tmux_bin" -L "$socket" -f "$conf" kill-session -t "$session" 2>/dev/null || true
1036
- reap_aiur_agents "$socket" "$AIUR_AGENT_TMPFILE"
1037
- kill_beams_matching "-name ${AIUR_RELEASE_NODE}"
1038
- fi
1039
- release_aiur_launch_lock "$launch_lock"
1040
- _session_launch_lock=""
1041
- [ "$mode" = "foreground" ] && return 1
1042
- rm -f "${launch_tempfiles[@]}" 2>/dev/null || true
1043
- exit 1
1044
- fi
1045
-
1046
- write_aiur_instance_record "$session" "$socket"
1047
- release_aiur_launch_lock "$launch_lock"
1048
- _session_launch_lock=""
1049
- print_config_status "$startup_capture"
1050
- print_dashboard_status "$no_dashboard" "$startup_capture"
1051
- # The `aiur run` version notice: a cheap local state read (the daemon's
1052
- # `Aiur.Upgrade` check writes it during boot), never a network call, so it
1053
- # can never delay startup. Silent under a dev launcher and when opted out.
1054
- # `|| true` keeps a display failure from ever failing the run — the notice
1055
- # is strictly additive.
1056
- maybe_surface_upgrade_notice || true
1057
-
1058
- if [ "$mode" = "foreground" ]; then
1059
- echo "aiur foreground tmux socket ${socket}, session ${session}" >&2
1060
- fi
1061
-
1062
- if [ "$mode" = "background" ]; then
1063
- # Fresh run: drop any stale crash/stop state from a prior dead instance so
1064
- # `status` doesn't report a phantom orphan and the watchdog starts clean.
1065
- rm -f "$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)" 2>/dev/null || true
1066
-
1067
- local background_watchdog_pid
1068
- background_watchdog_pid="$(start_beam_death_watchdog \
1069
- "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1 1 \
1070
- "$AIUR_RELEASE_NODE" "${AIUR_LOGS_ROOT:-}" \
1071
- "$(aiur_stop_sentinel_path)" "$(aiur_crash_marker_path)" "$AIUR_WORKSPACE_ROOT_FILE" \
1072
- "$AIUR_ALERT_LEDGER_PATH_FILE" "$crash_dump_baseline_file")"
1073
- disown "$background_watchdog_pid" 2>/dev/null || true
1074
- echo "aiur started in the background (tmux socket ${socket}, session ${session}). Attach with: aiur" >&2
1075
- # Keep $startup_capture (boot.out.log) for the run's lifetime; only the
1076
- # transient argv file is no longer needed.
1077
- rm -f "$argv_file" 2>/dev/null || true
1078
- return 0
1079
- fi
1080
-
1081
- # Arm the BEAM-death watchdog before attaching. If the BEAM crashes
1082
- # (:emfile) mid-run, agent windows keep the orphaned session alive so the
1083
- # `tmux attach` below never returns and the EXIT trap never fires — the
1084
- # watchdog is the external reaper that survives the dead BEAM, kill-servers
1085
- # the session, and unblocks the attach. It polls the node name rather than a
1086
- # captured pid, so another Aiur instance from the same release cannot hold it open.
1087
- _session_watchdog_pid="$(start_beam_death_watchdog \
1088
- "-name ${AIUR_RELEASE_NODE}" "$socket" "$AIUR_AGENT_TMPFILE" 1 0 \
1089
- "$AIUR_RELEASE_NODE" "" "" "" "$AIUR_WORKSPACE_ROOT_FILE" \
1090
- "$AIUR_ALERT_LEDGER_PATH_FILE" "$crash_dump_baseline_file")"
1091
-
1092
- # Foreground: attach the UI. Do not exec — that would drop the teardown trap.
1093
- # Avoid process substitution here: some sandboxed non-TTY launchers reject
1094
- # opening /dev/fd/* during the real manual-test wrapper path.
1095
- attach_tmux_session "$tmux_bin" "$socket" "$conf" "$session"
1096
- }
1097
-
1098
- attach_tmux_session() {
1099
- local tmux_bin="$1" socket="$2" conf="$3" session="$4"
1100
- local attach_stderr attach_code
1101
- attach_stderr="$(mktemp "${TMPDIR:-/tmp}/aiur-attach-stderr.XXXXXX")"
1102
- if "$tmux_bin" -L "$socket" -f "$conf" attach -t "$session" 2>"$attach_stderr"; then
1103
- attach_code=0
1104
- else
1105
- attach_code=$?
1106
- fi
1107
- grep -v -F "[server exited]" "$attach_stderr" >&2 || true
1108
- rm -f "$attach_stderr" 2>/dev/null || true
1109
- return "$attach_code"
1110
- }
1111
-
1112
- resolve_tmux_conf() {
1113
- if [ -n "${AIUR_TMUX_CONF:-}" ] && [ -f "${AIUR_TMUX_CONF:-}" ]; then
1114
- printf '%s\n' "$AIUR_TMUX_CONF"
1115
- return
1116
- fi
1117
- local user_conf="${XDG_CONFIG_HOME:-$HOME/.config}/aiur/tmux.conf"
1118
- if [ -f "$user_conf" ]; then
1119
- printf '%s\n' "$user_conf"
1120
- return
1121
- fi
1122
- printf '%s\n' "$engine_dir/../share/aiur.tmux.conf"
1123
- }
1124
-
1125
- # TERM-then-KILL every BEAM whose command line matches $1. Reaps a stale node by
1126
- # name (covers orphans from any release dir): under the unified identity a dev
1127
- # `_build` BEAM and an installed BEAM both claim the same node name, so a
1128
- # release-path pgrep misses whichever one this run didn't launch.
1129
- # TERM the node(s) matching $pattern, give them $grace_ticks (0.1s) to exit
1130
- # gracefully, then SIGKILL whatever remains. The default grace is short (3s)
1131
- # because startup reclaim wants to clear a stale BEAM quickly; the stop paths
1132
- # pass a longer grace so the BEAM can finish its own agent reap + session
1133
- # deletion instead of being SIGKILLed mid-cleanup (which is what orphaned agent
1134
- # processes on `aiur stop`). Callers may override the grace internally; invalid
1135
- # or excessive values fall back to the 3s startup-reclaim default instead of
1136
- # skipping the wait and jumping straight to SIGKILL.
1137
- kill_beams_matching() {
1138
- local pattern="$1" grace_ticks="${2:-30}" pids pid waited=0
1139
- case "$grace_ticks" in
1140
- "" | *[!0-9]*) grace_ticks=30 ;;
1141
- *)
1142
- # Reject overlong digit strings before numeric comparison so shell integer
1143
- # overflow cannot turn an excessive grace into an immediate SIGKILL.
1144
- if [ "${#grace_ticks}" -gt 5 ] ||
1145
- { [ "${#grace_ticks}" -eq 5 ] && [ "$grace_ticks" -gt 36000 ]; }; then
1146
- grace_ticks=30
1147
- fi
1148
- ;;
1149
- esac
1150
- pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
1151
- [ -n "$pids" ] || return 0
1152
- for pid in $pids; do kill -TERM "$pid" 2>/dev/null || true; done
1153
- while [ "$waited" -lt "$grace_ticks" ]; do
1154
- pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
1155
- [ -z "$pids" ] && break
1156
- sleep 0.1
1157
- waited=$((waited + 1))
1158
- done
1159
- pids="$(pgrep -f -- "$pattern" 2>/dev/null || true)"
1160
- for pid in $pids; do kill -KILL "$pid" 2>/dev/null || true; done
1161
- }
1162
-
1163
- pid_owner() {
1164
- ps -p "$1" -o user= 2>/dev/null | awk '{print $1}' || true
1165
- }
1166
-
1167
- pid_command() {
1168
- ps -p "$1" -o command= 2>/dev/null || true
1169
- }
1170
-
1171
- pid_ppid() {
1172
- ps -p "$1" -o ppid= 2>/dev/null | awk '{print $1}' || true
1173
- }
1174
-
1175
- pid_cwd() {
1176
- local pid="$1" cwd
1177
- if [ -e "/proc/$pid/cwd" ]; then
1178
- readlink "/proc/$pid/cwd" 2>/dev/null || true
1179
- return 0
1180
- fi
1181
- if command -v lsof >/dev/null 2>&1; then
1182
- cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)"
1183
- [ -n "$cwd" ] && printf '%s\n' "$cwd"
1184
- fi
1185
- }
1186
-
1187
- kill_pid_with_escalation() {
1188
- local pid="$1" waited=0
1189
- [ -n "$pid" ] || return 0
1190
- kill -TERM "$pid" 2>/dev/null || true
1191
- while [ "$waited" -lt 20 ]; do
1192
- kill -0 "$pid" 2>/dev/null || return 0
1193
- sleep 0.1
1194
- waited=$((waited + 1))
1195
- done
1196
- kill -KILL "$pid" 2>/dev/null || true
1197
- }
1198
-
1199
- aiur_node_from_command() {
1200
- local cmd="$1" node
1201
- node="$(printf '%s\n' "$cmd" | sed -n "s/.*--name[[:space:]]\\(aiur-${USER}[-A-Za-z0-9_]*@127\\.0\\.0\\.1\\).*/\\1/p" | head -n 1)"
1202
- [ -n "$node" ] && printf '%s\n' "$node"
1203
- }
1204
-
1205
- aiur_release_root_from_command() {
1206
- local cmd="$1"
1207
- if [[ "$cmd" =~ --boot-var[[:space:]]+RELEASE_LIB[[:space:]]+([^[:space:]]+)/lib ]]; then
1208
- printf '%s\n' "${BASH_REMATCH[1]}"
1209
- return 0
1210
- fi
1211
- if [[ "$cmd" =~ ([^[:space:]]*/src/_build/dev/rel/aiur)/releases/ ]]; then
1212
- printf '%s\n' "${BASH_REMATCH[1]}"
1213
- return 0
1214
- fi
1215
- if [[ "$cmd" =~ ([^[:space:]]*/release)/releases/ ]]; then
1216
- printf '%s\n' "${BASH_REMATCH[1]}"
1217
- return 0
1218
- fi
1219
- return 1
1220
- }
1221
-
1222
- workspace_root_from_aiur_release_root() {
1223
- local release_root="${1%/}"
1224
- case "$release_root" in
1225
- */src/_build/dev/rel/aiur)
1226
- printf '%s\n' "${release_root%/src/_build/dev/rel/aiur}"
1227
- ;;
1228
- *)
1229
- printf '%s\n' "$release_root"
1230
- ;;
1231
- esac
1232
- }
1233
-
1234
- aiur_node_tmux_session_alive() {
1235
- local node="$1" short tmux_bin
1236
- short="${node%@*}"
1237
- tmux_bin="$(command -v tmux || true)"
1238
- [ -n "$tmux_bin" ] || return 1
1239
- "$tmux_bin" -L "$short" has-session -t "${short}-default" 2>/dev/null
1240
- }
1241
-
1242
- stale_manual_smoke_beam_inventory() {
1243
- local pid cmd node release_root workspace_root owner
1244
- aiur_resolve_identity
1245
-
1246
- for pid in $(pgrep -f -- "beam\\.smp.*--name aiur-${USER}.*@127\\.0\\.0\\.1" 2>/dev/null || true); do
1247
- [ -n "$pid" ] || continue
1248
- owner="$(pid_owner "$pid")"
1249
- [ "$owner" = "$USER" ] || continue
1250
-
1251
- cmd="$(pid_command "$pid")"
1252
- node="$(aiur_node_from_command "$cmd")"
1253
- [ -n "$node" ] || continue
1254
-
1255
- release_root="$(aiur_release_root_from_command "$cmd" || true)"
1256
- [ -n "$release_root" ] || continue
1257
- workspace_root="$(workspace_root_from_aiur_release_root "$release_root")"
1258
-
1259
- # Scope this broad cleanup to stale issue/manual-smoke workspaces. The
1260
- # current instance cleanup path handles the active daemon's own node.
1261
- case "$workspace_root" in
1262
- */aiur-workspaces/*) ;;
1263
- *) continue ;;
1264
- esac
1265
-
1266
- # A matching live aiur tmux session means this node may still be in use.
1267
- aiur_node_tmux_session_alive "$node" && continue
1268
-
1269
- printf '%s\t%s\t%s\t%s\n' "$pid" "$node" "$workspace_root" "$release_root"
1270
- done
1271
- }
1272
-
1273
- manual_smoke_wrapper_tmux_sockets() {
1274
- local tmux_bin sockdir path name panes current pane_path start manual_context all_sleep
1275
- tmux_bin="$(command -v tmux || true)"
1276
- [ -n "$tmux_bin" ] || return 0
1277
- sockdir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
1278
- [ -d "$sockdir" ] || return 0
1279
-
1280
- for path in "$sockdir"/*; do
1281
- [ -S "$path" ] || continue
1282
- name="${path##*/}"
1283
- case "$name" in
1284
- codex-driver-* | codex-aiur* | codex-[0-9]* | claude-driver*) ;;
1285
- *) continue ;;
1286
- esac
1287
- panes="$("$tmux_bin" -L "$name" list-panes -a -F '#{pane_current_command} #{pane_current_path} #{pane_start_command}' 2>/dev/null || true)"
1288
- [ -n "$panes" ] || continue
1289
- manual_context=0
1290
- all_sleep=1
1291
- while IFS=$'\t' read -r current pane_path start; do
1292
- case "$start" in
1293
- *aiurdev\ --test* | *scripts/aiurdev\ --test*) manual_context=1 ;;
1294
- esac
1295
- [ "$current" = "sleep" ] || all_sleep=0
1296
- done <<<"$panes"
1297
- [ "$manual_context" -eq 1 ] && [ "$all_sleep" -eq 1 ] && printf '%s\n' "$name"
1298
- done
1299
- }
1300
-
1301
- orphaned_opencode_attach_inventory() {
1302
- local pid owner ppid cmd cwd
1303
- for pid in $(pgrep -f -- "opencode attach http://127\\.0\\.0\\.1:" 2>/dev/null || true); do
1304
- owner="$(pid_owner "$pid")"
1305
- [ "$owner" = "$USER" ] || continue
1306
- ppid="$(pid_ppid "$pid")"
1307
- [ "$ppid" = "1" ] || continue
1308
- cwd="$(pid_cwd "$pid")"
1309
- case "$cwd" in
1310
- */aiur-workspaces/*) ;;
1311
- *) continue ;;
1312
- esac
1313
- cmd="$(pid_command "$pid")"
1314
- case "$cmd" in
1315
- *"opencode attach http://127.0.0.1:"*) printf '%s\t%s\t%s\n' "$pid" "$cwd" "$cmd" ;;
1316
- esac
1317
- done
1318
- }
1319
-
1320
- report_stale_manual_smoke() {
1321
- local found=0 pid node workspace_root release_root socket cwd cmd
1322
-
1323
- while IFS=$'\t' read -r pid node workspace_root release_root; do
1324
- [ -n "$pid" ] || continue
1325
- if [ "$found" -eq 0 ]; then
1326
- echo "aiur: stale manual-smoke leftovers detected:" >&2
1327
- found=1
1328
- fi
1329
- echo " BEAM pid=${pid} node=${node} workspace=${workspace_root} release=${release_root}" >&2
1330
- done < <(stale_manual_smoke_beam_inventory)
1331
-
1332
- while IFS= read -r socket; do
1333
- [ -n "$socket" ] || continue
1334
- if [ "$found" -eq 0 ]; then
1335
- echo "aiur: stale manual-smoke leftovers detected:" >&2
1336
- found=1
1337
- fi
1338
- echo " wrapper tmux socket=${socket}" >&2
1339
- done < <(manual_smoke_wrapper_tmux_sockets)
1340
-
1341
- while IFS=$'\t' read -r pid cwd cmd; do
1342
- [ -n "$pid" ] || continue
1343
- if [ "$found" -eq 0 ]; then
1344
- echo "aiur: stale manual-smoke leftovers detected:" >&2
1345
- found=1
1346
- fi
1347
- echo " orphan opencode attach pid=${pid} workspace=${cwd}" >&2
1348
- done < <(orphaned_opencode_attach_inventory)
1349
-
1350
- if [ "$found" -eq 1 ]; then
1351
- echo "aiur: run 'aiur cleanup-stale' to TERM/KILL only these same-user Aiur leftovers." >&2
1352
- fi
1353
-
1354
- return "$found"
1355
- }
1356
-
1357
- preflight_stale_manual_smoke() {
1358
- report_stale_manual_smoke >/dev/null || true
1359
- }
1360
-
1361
- reap_stale_manual_smoke() {
1362
- local include_wrappers="${1:-0}"
1363
- local pid node workspace_root release_root socket cwd cmd tmux_bin reaped=0
1364
-
1365
- while IFS=$'\t' read -r pid node workspace_root release_root; do
1366
- [ -n "$pid" ] || continue
1367
- echo "aiur: reaping stale BEAM pid=${pid} node=${node} workspace=${workspace_root}" >&2
1368
- kill_pid_with_escalation "$pid"
1369
- reaped=$((reaped + 1))
1370
- done < <(stale_manual_smoke_beam_inventory)
1371
-
1372
- tmux_bin="$(command -v tmux || true)"
1373
- if [ "$include_wrappers" = "1" ] && [ -n "$tmux_bin" ]; then
1374
- while IFS= read -r socket; do
1375
- [ -n "$socket" ] || continue
1376
- echo "aiur: reaping stale wrapper tmux socket=${socket}" >&2
1377
- "$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
1378
- reaped=$((reaped + 1))
1379
- done < <(manual_smoke_wrapper_tmux_sockets)
1380
- fi
1381
-
1382
- while IFS=$'\t' read -r pid cwd cmd; do
1383
- [ -n "$pid" ] || continue
1384
- echo "aiur: reaping orphan opencode attach pid=${pid}" >&2
1385
- kill_pid_with_escalation "$pid"
1386
- reaped=$((reaped + 1))
1387
- done < <(orphaned_opencode_attach_inventory)
1388
-
1389
- if [ "$reaped" -eq 0 ]; then
1390
- echo "aiur: no stale manual-smoke leftovers found" >&2
1391
- fi
1392
- }
1393
-
1394
- # Echo $1 and every descendant pid, depth-first. Mac-safe (`pgrep -P`, no
1395
- # /proc), mirroring Aiur.RemoteControl.collect_descendants so the launcher reaps
1396
- # the same tree the BEAM-side reaper would. The recorded agent pid is a bash
1397
- # `-c` wrapper; the model process (claude --print / codex) is its child and
1398
- # reparents to init if only the wrapper is signalled — orphan-and-survive is
1399
- # exactly the bug — so the whole tree must be collected before any kill lands.
1400
- agent_pid_tree() {
1401
- local root="$1" child
1402
- printf '%s\n' "$root"
1403
- for child in $(pgrep -P "$root" 2>/dev/null || true); do
1404
- agent_pid_tree "$child"
1405
- done
1406
- }
1407
-
1408
- # pid-reuse guard: succeeds when pid $1 is alive AND its command still contains
1409
- # the comm substring $2 the BEAM recorded. An empty comm kills unconditionally.
1410
- # Mirrors the BEAM-side cmdline guard (Aiur.ProcessReaper) but Mac-safe via
1411
- # `ps -o command=`, so a recycled pid whose command no longer matches is spared.
1412
- agent_pid_matches() {
1413
- local pid="$1" comm="$2" cmd
1414
- kill -0 "$pid" 2>/dev/null || return 1
1415
- [ -n "$comm" ] || return 0
1416
- cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
1417
- case "$cmd" in *"$comm"*) return 0 ;; *) return 1 ;; esac
1418
- }
1419
-
1420
- # Reap every aiur agent the BEAM can no longer reap itself.
1421
- #
1422
- # $1 socket aiur tmux socket (-L); kill-server nukes all panes (may be empty)
1423
- # $2 pidfile AIUR_AGENT_TMPFILE (BEAM-written agent refs; may be empty/missing)
1424
- #
1425
- # tmux runs on aiur's PRIVATE socket (-L aiur-$USER), so kill-server tears down
1426
- # every REPL/chat pane agent in one shot AND leaves no live aiur tmux server —
1427
- # never touching the Executor’s own default tmux. Headless agents (claude/codex
1428
- # app-servers spawned via Port) are bare OS processes that reparent to init on a
1429
- # BEAM crash; kill-server can't see them, so they're reaped from the pidfile by
1430
- # process tree, checking the recorded command. A recycled pid with a matching
1431
- # command is still possible (#2844). Idempotent.
1432
- reap_aiur_agents() {
1433
- local socket="$1" pidfile="${2:-}"
1434
- local tmux_bin
1435
- tmux_bin="$(command -v tmux || true)"
1436
-
1437
- if [ -n "$tmux_bin" ] && [ -n "$socket" ]; then
1438
- "$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
1439
- fi
1440
-
1441
- [ -n "$pidfile" ] && [ -r "$pidfile" ] || return 0
1442
-
1443
- # Snapshot the full process tree of every still-matching headless agent before
1444
- # signalling, so descendants that reparent mid-reap are already on the list.
1445
- local kind pid comm tree=() p
1446
- while read -r kind pid comm; do
1447
- [ "$kind" = "pid" ] && [ -n "$pid" ] || continue
1448
- agent_pid_matches "$pid" "$comm" || continue
1449
- while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
1450
- done <"$pidfile"
1451
-
1452
- [ "${#tree[@]}" -gt 0 ] || return 0
1453
-
1454
- for p in "${tree[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
1455
- local waited=0
1456
- while [ "$waited" -lt 20 ]; do
1457
- local any=0
1458
- for p in "${tree[@]}"; do kill -0 "$p" 2>/dev/null && { any=1; break; }; done
1459
- [ "$any" -eq 0 ] && break
1460
- sleep 0.1
1461
- waited=$((waited + 1))
1462
- done
1463
- for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
1464
- }
1465
-
1466
- # Stable, per-instance state paths the background BEAM-death machinery shares
1467
- # across `run`, `stop`, and `status`. Keyed by the (instance-keyed) release node
1468
- # rather than the tmux socket so `status` — which resolves the node but not the
1469
- # socket — agrees on the same files. AIUR_BG_STATE_DIR and AIUR_RELEASE_NODE are
1470
- # resolved by aiur_resolve_identity, which every one of those paths calls first.
1471
- aiur_state_slug() {
1472
- printf '%s' "${AIUR_RELEASE_NODE:-aiur}" | tr -c 'A-Za-z0-9._-' '_'
1473
- }
1474
-
1475
- aiur_instances_dir() {
1476
- printf '%s/instances' "${AIUR_BG_STATE_DIR:?}"
1477
- }
1478
-
1479
- aiur_instance_record_path() {
1480
- printf '%s/%s.instance' "$(aiur_instances_dir)" "$(aiur_state_slug)"
1481
- }
1482
-
1483
- aiur_launch_lock_path() {
1484
- printf '%s/locks/%s.launch' "${AIUR_BG_STATE_DIR:?}" "$(aiur_state_slug)"
1485
- }
1486
-
1487
- acquire_aiur_launch_lock() {
1488
- local lock="$1" owner_file="$1/owner" recovery_lock="$1.recovery"
1489
- local owner tick lock_age
1490
- local max_ticks="${AIUR_LAUNCH_LOCK_TICKS:-${AIUR_NODE_GRACE_TICKS:-1200}}"
1491
- local ownerless_stale_seconds="${AIUR_OWNERLESS_LOCK_STALE_SECONDS:-120}"
1492
- mkdir -p "$(dirname "$lock")" 2>/dev/null || return 1
1493
-
1494
- for ((tick = 0; tick < max_ticks; tick++)); do
1495
- if mkdir "$lock" 2>/dev/null; then
1496
- printf '%s\n' "$$" >"$owner_file" || { rmdir "$lock" 2>/dev/null || true; return 1; }
1497
- return 0
1498
- fi
1499
-
1500
- owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1501
- if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" 2>/dev/null; then
1502
- sleep 0.1
1503
- continue
1504
- fi
1505
-
1506
- lock_age="$(aiur_lock_age_seconds "$lock")"
1507
- if [ -z "$owner" ] && { [ -z "$lock_age" ] || [ "$lock_age" -lt "$ownerless_stale_seconds" ]; }; then
1508
- sleep 0.1
1509
- continue
1510
- fi
1511
-
1512
- # Only one contender may reclaim a dead lock. Re-read ownership while the
1513
- # recovery mutex is held so another contender cannot delete a newly claimed
1514
- # launch directory after losing the original stale-lock race.
1515
- if mkdir "$recovery_lock" 2>/dev/null; then
1516
- owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1517
- lock_age="$(aiur_lock_age_seconds "$lock")"
1518
- if { [ -z "$owner" ] && [ -n "$lock_age" ] && [ "$lock_age" -ge "$ownerless_stale_seconds" ]; } || \
1519
- { [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; }; then
1520
- rm -f "$owner_file" 2>/dev/null || true
1521
- rmdir "$lock" 2>/dev/null || true
1522
- fi
1523
- rmdir "$recovery_lock" 2>/dev/null || true
1524
- fi
1525
- sleep 0.1
1526
- done
1527
-
1528
- echo "aiur: another launch for this directory is still in progress; retry aiur" >&2
1529
- return 1
1530
- }
1531
-
1532
- aiur_lock_age_seconds() {
1533
- local path="$1" now modified
1534
- now="$(date +%s 2>/dev/null || true)"
1535
- modified="$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || true)"
1536
- [[ "$now" =~ ^[0-9]+$ ]] && [[ "$modified" =~ ^[0-9]+$ ]] || return 0
1537
- [ "$now" -ge "$modified" ] || return 0
1538
- printf '%s' "$((now - modified))"
1539
- }
1540
-
1541
- release_aiur_launch_lock() {
1542
- local lock="${1:-}" owner_file owner
1543
- [ -n "$lock" ] || return 0
1544
- owner_file="$lock/owner"
1545
- owner="$(sed -n '1p' "$owner_file" 2>/dev/null || true)"
1546
- [ "$owner" = "$$" ] || return 0
1547
- rm -f "$owner_file" 2>/dev/null || true
1548
- rmdir "$lock" 2>/dev/null || true
1549
- }
1550
-
1551
- write_aiur_instance_record() {
1552
- local session="$1" socket="$2" write_mode="${3:-replace}" record_dir record tmp root
1553
- record_dir="$(aiur_instances_dir)"
1554
- record="$(aiur_instance_record_path)"
1555
- root="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
1556
- mkdir -p "$record_dir" 2>/dev/null || return 0
1557
- tmp="$(mktemp "$record_dir/.${AIUR_INSTANCE_KEY:-aiur}.XXXXXX" 2>/dev/null)" || return 0
1558
- {
1559
- printf 'AIUR_RECORD_NODE=%q\n' "$AIUR_RELEASE_NODE"
1560
- printf 'AIUR_RECORD_INSTANCE_KEY=%q\n' "$AIUR_INSTANCE_KEY"
1561
- printf 'AIUR_RECORD_SESSION=%q\n' "$session"
1562
- printf 'AIUR_RECORD_SOCKET=%q\n' "$socket"
1563
- printf 'AIUR_RECORD_AGENT_TMPFILE=%q\n' "${AIUR_AGENT_TMPFILE:-}"
1564
- printf 'AIUR_RECORD_WORKSPACE_ROOT_FILE=%q\n' "${AIUR_WORKSPACE_ROOT_FILE:-}"
1565
- printf 'AIUR_RECORD_PROJECT_ROOT=%q\n' "$root"
1566
- printf 'AIUR_RECORD_PROJECT_ROOT_SOURCE=%q\n' "${AIUR_PROJECT_ROOT_SOURCE:-}"
1567
- printf 'AIUR_RECORD_WRITTEN_AT=%q\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
1568
- } >"$tmp" || { rm -f "$tmp" 2>/dev/null || true; return 0; }
1569
- chmod 0600 "$tmp" 2>/dev/null || true
1570
- if [ "$write_mode" = "if-absent" ]; then
1571
- # Same-directory hard-link creation is atomic: a concurrent live launcher
1572
- # can win without an attaching shell overwriting its workspace handoff.
1573
- ln "$tmp" "$record" 2>/dev/null || true
1574
- rm -f "$tmp" 2>/dev/null || true
1575
- else
1576
- mv "$tmp" "$record" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
1577
- fi
1578
- }
1579
-
1580
- # Marker `status` reads to tell "daemon crashed, agents may be orphaned" apart
1581
- # from "nothing was ever running". Written by the watchdog on an unexpected exit,
1582
- # cleared on a clean stop and on the next successful background start.
1583
- aiur_crash_marker_path() {
1584
- printf '%s/%s.last-crash' "${AIUR_BG_STATE_DIR:?}" "$(aiur_state_slug)"
1585
- }
1586
-
1587
- # Sentinel `stop` drops before killing the BEAM so the watchdog knows the exit
1588
- # was intentional and does not record a false crash.
1589
- aiur_stop_sentinel_path() {
1590
- printf '%s/%s.stopping' "${AIUR_BG_STATE_DIR:?}" "$(aiur_state_slug)"
1591
- }
1592
-
1593
- json_escape_alert_field() {
1594
- LC_ALL=C printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g'
1595
- }
1596
-
1597
- crash_dump_identity() {
1598
- local dump_path="${1:-}"
1599
- [ -n "$dump_path" ] && [ -f "$dump_path" ] || return 0
1600
- # Constant-time and replacement-sensitive. GNU stat supplies nanosecond
1601
- # timestamps; BSD/macOS stat still includes device + inode, so an atomic
1602
- # replacement with byte-identical contents cannot match the launch baseline.
1603
- stat -Lc '%d:%i:%s:%y:%z' "$dump_path" 2>/dev/null || \
1604
- stat -f '%d:%i:%z:%m:%c:%B' "$dump_path" 2>/dev/null || true
1605
- }
1606
-
1607
- new_completed_crash_dump_slogan() {
1608
- local dump_path="${1:-}" baseline_file="${2:-}" baseline="" current_identity="" slogan=""
1609
- [ -n "$baseline_file" ] && [ -r "$baseline_file" ] && IFS= read -r baseline <"$baseline_file" || true
1610
- current_identity="$(crash_dump_identity "$dump_path")"
1611
- [ -n "$current_identity" ] && [ "$current_identity" != "$baseline" ] || return 0
1612
- tail -n 1 "$dump_path" 2>/dev/null | grep -q '^=end$' || return 0
1613
- slogan="$(LC_ALL=C awk '/^Slogan: / { sub(/^Slogan: /, ""); print substr($0, 1, 512); exit }' "$dump_path" 2>/dev/null || true)"
1614
- LC_ALL=C printf '%s' "$slogan" | tr -d '\000-\037'
1615
- }
1616
-
1617
- write_crash_dump_alert() {
1618
- local ledger_path_file="$1" timestamp="$2" dump_path="$3" slogan="$4" ledger_path=""
1619
- local escaped_timestamp escaped_path escaped_slogan escaped_message escaped_reason
1620
- [ -n "$ledger_path_file" ] && [ -s "$ledger_path_file" ] || return 0
1621
- # File.write/2 handoffs are valid without a trailing newline. `read` returns
1622
- # nonzero at EOF in that case but still assigns the complete path.
1623
- IFS= read -r ledger_path <"$ledger_path_file" || true
1624
- [ -n "$ledger_path" ] || return 0
1625
-
1626
- escaped_timestamp="$(json_escape_alert_field "$timestamp")"
1627
- escaped_path="$(json_escape_alert_field "$dump_path")"
1628
- escaped_slogan="$(json_escape_alert_field "$slogan")"
1629
- escaped_message="$(json_escape_alert_field "BEAM crash dump: $slogan")"
1630
- escaped_reason="$(json_escape_alert_field "Unexpected daemon exit wrote $dump_path")"
1631
-
1632
- mkdir -p "$(dirname "$ledger_path")" 2>/dev/null || return 0
1633
- printf '{"event":"alert","agent":"system","timestamp":"%s","name":"system.beam.crash_dump","topic":"system.beam.crash_dump","message":"%s","reason":"%s","severity":"warning","needs_attention":true,"dump_path":"%s","slogan":"%s"}\n' \
1634
- "$escaped_timestamp" "$escaped_message" "$escaped_reason" "$escaped_path" "$escaped_slogan" \
1635
- >>"$ledger_path" 2>/dev/null || true
1636
- }
1637
-
1638
- record_new_crash_dump_alert() {
1639
- local ledger_path_file="${1:-}" baseline_file="${2:-}" crash_dump_path="${ERL_CRASH_DUMP:-}"
1640
- local crash_dump_slogan="" ts=""
1641
- crash_dump_slogan="$(new_completed_crash_dump_slogan "$crash_dump_path" "$baseline_file")"
1642
- [ -n "$crash_dump_slogan" ] || return 0
1643
- ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
1644
- write_crash_dump_alert "$ledger_path_file" "${ts:-unknown}" "$crash_dump_path" "$crash_dump_slogan"
1645
- }
1646
-
1647
- # Write a durable record that the background BEAM exited unexpectedly. Two sinks:
1648
- # the run log dir (full record next to aiur.log, for forensics) and the stable
1649
- # per-instance marker (for `status` to surface). Best-effort throughout — this
1650
- # runs in the disowned watchdog after the BEAM is already gone, so it must never
1651
- # fail loudly or block the reap that follows.
1652
- record_beam_crash() {
1653
- local node="$1" run_log_dir="$2" marker="$3" ledger_path_file="${4:-}" baseline_file="${5:-}"
1654
- local ts boot_tail="" crash_dump_path="" crash_dump_slogan=""
1655
- ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)"
1656
- if [ -n "$run_log_dir" ] && [ -r "$run_log_dir/log/boot.out.log" ]; then
1657
- boot_tail="$(tail -n 20 "$run_log_dir/log/boot.out.log" 2>/dev/null || true)"
1658
- fi
1659
- crash_dump_path="${ERL_CRASH_DUMP:-}"
1660
- crash_dump_slogan="$(new_completed_crash_dump_slogan "$crash_dump_path" "$baseline_file")"
1661
-
1662
- local body
1663
- body="$(
1664
- printf 'aiur background BEAM exited unexpectedly\n'
1665
- printf 'timestamp: %s\n' "${ts:-unknown}"
1666
- printf 'node: %s\n' "${node:-unknown}"
1667
- printf 'run_log_dir: %s\n' "${run_log_dir:-unknown}"
1668
- printf 'detected_by: background BEAM-death watchdog (no clean stop sentinel)\n'
1669
- if [ -n "$crash_dump_slogan" ]; then
1670
- printf 'crash_dump_path: %s\n' "$crash_dump_path"
1671
- printf 'crash_dump_slogan: %s\n' "$crash_dump_slogan"
1672
- fi
1673
- if [ -n "$boot_tail" ]; then
1674
- printf -- '--- last 20 lines of boot.out.log ---\n%s\n' "$boot_tail"
1675
- fi
1676
- )"
1677
-
1678
- if [ -n "$run_log_dir" ]; then
1679
- mkdir -p "$run_log_dir/log" 2>/dev/null || true
1680
- printf '%s\n' "$body" >>"$run_log_dir/log/aiur.crash" 2>/dev/null || true
1681
- fi
1682
- if [ -n "$marker" ]; then
1683
- printf '%s\n' "$body" >"$marker" 2>/dev/null || true
1684
- fi
1685
- if [ -n "$crash_dump_slogan" ]; then
1686
- write_crash_dump_alert "$ledger_path_file" "${ts:-unknown}" "$crash_dump_path" "$crash_dump_slogan"
1687
- fi
1688
- }
1689
-
1690
- canonical_workspace_root() {
1691
- local root="$1"
1692
- # The BEAM hands off Config.workspace_root() verbatim, which may be a `~/...`
1693
- # path (e.g. this repo's `workspace.root: ~/code/aiur-workspaces`). `cd` does
1694
- # not expand `~` inside a quoted variable, so without this a `~`-rooted cwd
1695
- # sweep compared against the literal `~/...` string, matched no /proc cwd, and
1696
- # silently reaped nothing — the exact backstop that must catch workspace-rooted
1697
- # agents on stop. Expand a leading `~` before canonicalizing so the sweep and
1698
- # the /proc cwd values (always absolute) agree.
1699
- case "$root" in
1700
- "~" | "~/"*) root="${HOME:-}${root#\~}" ;;
1701
- esac
1702
- if [ -d "$root" ]; then
1703
- (cd "$root" 2>/dev/null && pwd -P) || printf '%s\n' "$root"
1704
- else
1705
- printf '%s\n' "$root"
1706
- fi
1707
- }
1708
-
1709
- workspace_root_is_shallow() {
1710
- local root="${1%/}" trimmed
1711
- trimmed="${root#/}"
1712
- [ -n "$trimmed" ] || return 0
1713
- case "$trimmed" in
1714
- */*) return 1 ;;
1715
- *) return 0 ;;
1716
- esac
1717
- }
1718
-
1719
- workspace_cwd_pids() {
1720
- local root="${1%/}" proc_dir="${2:-/proc}" entry pid cwd
1721
- [ -d "$proc_dir" ] || return 0
1722
-
1723
- for entry in "$proc_dir"/[0-9]*; do
1724
- [ -d "$entry" ] || continue
1725
- pid="${entry##*/}"
1726
- cwd="$(readlink "$entry/cwd" 2>/dev/null || true)"
1727
- [ -n "$cwd" ] || continue
1728
- cwd="${cwd%/}"
1729
- if [ "$cwd" != "$root" ] && [[ "$cwd/" == "$root/"* ]]; then
1730
- printf '%s\n' "$pid"
1731
- fi
1732
- done
1733
- }
1734
-
1735
- shell_protected_pid_list() {
1736
- local p
1737
- printf '%s\n' "$$"
1738
- [ "${BASHPID:-$$}" = "$$" ] || printf '%s\n' "$BASHPID"
1739
- [ -n "${PPID:-}" ] && printf '%s\n' "$PPID"
1740
- for p in $(agent_pid_tree "$$" 2>/dev/null || true); do
1741
- printf '%s\n' "$p"
1742
- done
1743
- }
1744
-
1745
- reap_workspace_cwd_agents() {
1746
- local root="${1:-}"
1747
- [ -n "$root" ] || return 0
1748
- [ -d /proc ] || return 0
1749
-
1750
- root="$(canonical_workspace_root "$root")"
1751
- if workspace_root_is_shallow "$root"; then
1752
- echo "⚠️ refusing shallow workspace cwd sweep root: $root" >&2
1753
- return 0
1754
- fi
1755
-
1756
- local max_sweeps="${AIUR_WORKSPACE_REAP_SWEEPS:-6}"
1757
- case "$max_sweeps" in '' | *[!0-9]*) max_sweeps=6 ;; esac
1758
- [ "$max_sweeps" -gt 0 ] || return 0
1759
-
1760
- local protected pids=() filtered=() pid p sweep waited any
1761
- protected=" $(shell_protected_pid_list | tr '\n' ' ') "
1762
-
1763
- sweep=0
1764
- while [ "$sweep" -lt "$max_sweeps" ]; do
1765
- mapfile -t pids < <(workspace_cwd_pids "$root" /proc)
1766
- filtered=()
1767
- for pid in "${pids[@]}"; do
1768
- case "$protected" in *" $pid "*) continue ;; esac
1769
- kill -0 "$pid" 2>/dev/null && filtered+=("$pid")
1770
- done
1771
-
1772
- [ "${#filtered[@]}" -gt 0 ] || return 0
1773
-
1774
- for p in "${filtered[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
1775
- waited=0
1776
- while [ "$waited" -lt 10 ]; do
1777
- any=0
1778
- for p in "${filtered[@]}"; do kill -0 "$p" 2>/dev/null && { any=1; break; }; done
1779
- [ "$any" -eq 0 ] && break
1780
- sleep 0.1
1781
- waited=$((waited + 1))
1782
- done
1783
- for p in "${filtered[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
1784
-
1785
- sweep=$((sweep + 1))
1786
- sleep 0.1
1787
- done
1788
- }
1789
-
1790
- reap_workspace_cwd_from_file() {
1791
- local root_file="${1:-}" root
1792
- [ -n "$root_file" ] && [ -s "$root_file" ] || return 0
1793
- IFS= read -r root <"$root_file" || root=""
1794
- reap_workspace_cwd_agents "$root"
1795
- }
1796
-
1797
- workspace_root_file_from_instance_record() {
1798
- load_aiur_instance_record "$(aiur_instance_record_path)" || return 1
1799
- [ -n "${AIUR_RECORD_WORKSPACE_ROOT_FILE:-}" ] || return 1
1800
- printf '%s\n' "$AIUR_RECORD_WORKSPACE_ROOT_FILE"
1801
- }
1802
-
1803
- agent_pidfile_from_instance_record() {
1804
- load_aiur_instance_record "$(aiur_instance_record_path)" || return 1
1805
- if [ -n "${AIUR_RECORD_AGENT_TMPFILE:-}" ]; then
1806
- printf '%s\n' "$AIUR_RECORD_AGENT_TMPFILE"
1807
- return 0
1808
- fi
1809
-
1810
- # Records written before the pidfile field still carry the handoff created
1811
- # by the same launcher: aiur-PID-workspace-root and aiur-PID-agents share a
1812
- # runtime directory. Derive that one file only when the old path has the
1813
- # exact launch shape under this runtime root; never scan other instances.
1814
- local session_root="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}" handoff pid
1815
- session_root="${session_root%/}"
1816
- [ -n "$session_root" ] || session_root=/
1817
- handoff="${AIUR_RECORD_WORKSPACE_ROOT_FILE:-}"
1818
- case "$handoff" in
1819
- "$session_root"/aiur-*-workspace-root) ;;
1820
- *) return 1 ;;
1821
- esac
1822
- pid="${handoff#"$session_root"/aiur-}"
1823
- pid="${pid%-workspace-root}"
1824
- [[ "$pid" =~ ^[0-9]+$ ]] || return 1
1825
- printf '%s/aiur-%s-agents\n' "$session_root" "$pid"
1826
- }
1827
-
1828
- # Background watchdog that survives the BEAM. Polls for the release BEAM by
1829
- # command pattern and, once it has SEEN the BEAM and then the BEAM disappears
1830
- # (orderly halt OR :emfile-style crash), reaps every agent. Polling the pattern
1831
- # rather than a captured pid avoids two failure modes: a recycled BEAM pid the
1832
- # watchdog would poll forever, and an empty pid at arm time that would silently
1833
- # disarm the only crash reaper. The `seen` latch prevents a startup race from
1834
- # reaping before the BEAM has come up. kill-server collapses the orphaned tmux
1835
- # session, which returns the foreground `tmux attach` so the EXIT trap
1836
- # (session_cleanup) runs its idempotent reap too.
1837
- #
1838
- # $1 beam_pattern $2 socket $3 pidfile $4 interval_s $5 initial_seen
1839
- # $6 node $7 run_log_dir $8 stop_sentinel $9 crash_marker
1840
- # $10 workspace_root_file $11 alert_ledger_path_file $12 dump_baseline_file
1841
- # Any of args 6-9 or 11 arm crash recording. Foreground supplies only the node
1842
- # and alert-ledger handoff, so it can alert on a new dump without writing the
1843
- # background-only run marker.
1844
- # Prints the watchdog's own pid so the caller can kill it on a clean teardown.
1845
- start_beam_death_watchdog() {
1846
- local beam_pattern="$1" socket="$2" pidfile="$3" interval="${4:-1}" initial_seen="${5:-0}"
1847
- local node="${6:-}" run_log_dir="${7:-}" stop_sentinel="${8:-}" crash_marker="${9:-}" workspace_root_file="${10:-}"
1848
- local alert_ledger_path_file="${11:-}" dump_baseline_file="${12:-}"
1849
- # Redirect the subshell's stdout so a command-substitution caller
1850
- # (`pid=$(start_beam_death_watchdog ...)`) returns immediately instead of
1851
- # blocking on the still-open pipe until the watchdog finishes.
1852
- (
1853
- seen="$initial_seen"
1854
- while :; do
1855
- if pgrep -f -- "$beam_pattern" >/dev/null 2>&1; then
1856
- seen=1
1857
- elif [ "$seen" = 1 ]; then
1858
- break
1859
- fi
1860
- sleep "$interval"
1861
- done
1862
- # The BEAM we had seen is gone. A clean `aiur stop` drops the sentinel first;
1863
- # its presence means an intentional exit (consume it, no crash record).
1864
- # Anything else is an unexpected exit worth a durable record before reaping.
1865
- if [ -n "$stop_sentinel" ] && [ -f "$stop_sentinel" ]; then
1866
- rm -f "$stop_sentinel" 2>/dev/null || true
1867
- elif [ -n "$crash_marker" ] || [ -n "$run_log_dir" ] || [ -n "$alert_ledger_path_file" ]; then
1868
- record_beam_crash "$node" "$run_log_dir" "$crash_marker" "$alert_ledger_path_file" "$dump_baseline_file"
1869
- fi
1870
- reap_aiur_agents "$socket" "$pidfile"
1871
- reap_workspace_cwd_from_file "$workspace_root_file"
1872
- rm -f "$alert_ledger_path_file" "$dump_baseline_file" 2>/dev/null || true
1873
- ) >/dev/null 2>&1 &
1874
- printf '%s\n' "$!"
1875
- }
1876
-
1877
- probe_control_liveness() {
1878
- local expression output status
1879
- # The Orchestrator starts before the dashboard child, so an answering status
1880
- # call alone does not prove startup (or the bind attempt) finished. Wait until
1881
- # OTP marks :aiur started before printing the effective dashboard status.
1882
- 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'
1883
-
1884
- set +e
1885
- output="$("$release_bin" rpc "$expression" 2>&1)"
1886
- status=$?
1887
- set -e
1888
-
1889
- if [ "$status" -eq 0 ] && [[ "$output" == *"__AIUR_CONTROL_READY__"* ]]; then
1890
- printf 'up'
1891
- else
1892
- printf 'down'
1893
- fi
1894
- }
1895
-
1896
- # Return the useful URL and effective bind host/port only when the running node
1897
- # confirms that Bandit actually bound a listener. An empty result means the
1898
- # listener was suppressed or refused; configured server.port alone is never
1899
- # proof of service.
1900
- probe_dashboard_status() {
1901
- local expression output status marker="__AIUR_DASHBOARD_STATUS__:"
1902
- 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'
1903
-
1904
- set +e
1905
- run_release_rpc_with_timeout "$expression"
1906
- status=$?
1907
- set -e
1908
- output="$AIUR_CONTROL_RPC_OUTPUT"
1909
-
1910
- if [ "$status" -eq 0 ] && [[ "$output" == *"$marker"* ]]; then
1911
- output="${output#*"$marker"}"
1912
- printf '%s' "${output%%$'\n'*}"
1913
- fi
1914
- }
1915
-
1916
- # Replay the exact path selected by the CLI inside tmux. The BEAM's startup
1917
- # output is captured rather than shown directly, so the marker crosses that
1918
- # boundary without duplicating config-discovery rules in this launcher.
1919
- print_config_status() {
1920
- local startup_capture="$1" marker="__AIUR_CONFIG_PATH__:" config_path
1921
-
1922
- config_path="$(awk -v marker="$marker" 'index($0, marker) == 1 { print substr($0, length(marker) + 1); exit }' "$startup_capture" 2>/dev/null || true)"
1923
-
1924
- if [ -n "$config_path" ]; then
1925
- echo "Config: ${config_path}" >&2
1926
- else
1927
- echo "⚠️ selected config path unavailable in captured startup output." >&2
1928
- fi
1929
- }
1930
-
1931
- print_dashboard_status() {
1932
- local no_dashboard="$1" startup_capture="$2" dashboard_status
1933
-
1934
- if [ "$no_dashboard" -eq 1 ]; then
1935
- echo "Dashboard disabled by --no-dashboard." >&2
1936
- return
1937
- fi
1938
-
1939
- dashboard_status="$(probe_dashboard_status)"
1940
- if [ -n "$dashboard_status" ]; then
1941
- echo "${dashboard_status}" >&2
1942
- else
1943
- echo "⚠️ dashboard listener unavailable; inspect ${startup_capture} for bind or authentication refusal." >&2
1944
- fi
1945
- }
1946
-
1947
- wait_for_session_startup() {
1948
- local tmux_bin="$1" socket="$2" conf="$3" session="$4" startup_capture="$5" require_control="$6"
1949
- local max_ticks="${AIUR_TMUX_GRACE_TICKS:-30}" tick control_state
1950
- if [ "$require_control" = "1" ]; then
1951
- # 0.1s per tick. The old 100-tick (10s) budget was under a cold control
1952
- # plane's real boot time, so a healthy daemon was killed mid-startup and
1953
- # reported as a failure. Boot time grows with the module count; keep the
1954
- # headroom generous, since the loop exits as soon as the probe says "up".
1955
- max_ticks="${AIUR_NODE_GRACE_TICKS:-1200}"
1956
- fi
1957
-
1958
- for ((tick = 0; tick < max_ticks; tick++)); do
1959
- if ! "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
1960
- echo "❌ aiur exited during startup; captured output:" >&2
1961
- tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
1962
- return 1
1963
- fi
1964
-
1965
- if [ "$require_control" != "1" ]; then
1966
- sleep 0.1
1967
- continue
1968
- fi
1969
-
1970
- control_state="$(probe_control_liveness)"
1971
- if [ "$control_state" = "up" ]; then
1972
- return 0
1973
- fi
1974
-
1975
- sleep 0.1
1976
- done
1977
-
1978
- if [ "$require_control" = "1" ]; then
1979
- # A live session at this point means the BEAM never died -- we ran out of
1980
- # patience, not the daemon out of health. Saying so is the difference
1981
- # between "your release is broken" and "give it longer", and the empty
1982
- # capture below is evidence of the latter, not of a silent crash.
1983
- if "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null; then
1984
- 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
1985
- echo " Raise the budget with AIUR_NODE_GRACE_TICKS (ticks of 0.1s) if this box is just slow." >&2
1986
- else
1987
- echo "❌ aiur control plane did not become ready at ${RELEASE_NODE:-unknown} during startup; captured output:" >&2
1988
- fi
1989
- tail -n 30 "$startup_capture" 2>/dev/null | sed 's/^/ /' >&2 || true
1990
- return 1
1991
- fi
1992
-
1993
- return 0
1994
- }
1995
-
1996
- # Foreground teardown: kill the session + BEAM and reap opencode sessions on exit.
1997
- _session_socket="" _session_name="" _session_conf="" _session_tmpfile=""
1998
- _session_capture="" _session_argv="" _session_release="" _session_tmux="" _session_node=""
1999
- _session_pidfile="" _session_watchdog_pid="" _session_workspace_root_file=""
2000
- _session_alert_ledger_path_file="" _session_crash_dump_baseline_file="" _session_launch_lock=""
2001
- _cleanup_ran=0
2002
- session_cleanup() {
2003
- [ "$_cleanup_ran" = 1 ] && return 0
2004
- _cleanup_ran=1
2005
- local code=$?
2006
-
2007
- # Stop the BEAM-death watchdog: cleanup is running, so it has no work left and
2008
- # a lingering poller would outlive this teardown.
2009
- [ -n "$_session_watchdog_pid" ] && kill "$_session_watchdog_pid" 2>/dev/null || true
2010
-
2011
- # kill-session on the detached aiur session FIRST: this is what propagates
2012
- # SIGHUP to the BEAM in that session so it begins its own orderly shutdown.
2013
- # Without it the BEAM survives Ctrl+C, holding port 4000 + the node name and
2014
- # breaking the next launch (regression test/aiur/regression/shutdown_cleanup_test.exs).
2015
- if [ -n "$_session_tmux" ] && [ -n "$_session_socket" ] && [ -n "$_session_name" ]; then
2016
- "$_session_tmux" -L "$_session_socket" kill-session -t "$_session_name" 2>/dev/null || true
2017
- fi
2018
-
2019
- # Then kill-server on aiur's private socket: tears down every pane agent across
2020
- # all windows AND leaves no live aiur tmux server, then reaps headless agents
2021
- # from the pidfile. Idempotent with the watchdog's own reap and the kill-session
2022
- # above. reap_aiur_agents re-resolves tmux itself and reaps headless agents even
2023
- # when tmux is absent, so it is not gated on $_session_tmux.
2024
- reap_aiur_agents "$_session_socket" "$_session_pidfile"
2025
-
2026
- # Reap only this instance's BEAM. Multiple keyed instances can share the same
2027
- # release dir, so release-path pgrep would terminate sibling workflows. Give
2028
- # the BEAM a generous TERM grace (the stop grace, not the 3s startup reclaim
2029
- # default) so its own graceful shutdown — ProcessReaper reaping the agent tree
2030
- # plus the BEAM-side workspace sweep — completes before any SIGKILL lands.
2031
- [ -n "$_session_node" ] && kill_beams_matching "-name ${_session_node}" 300
2032
-
2033
- # Final cwd-sweep backstop after the BEAM is gone: catches workspace-rooted
2034
- # agents or test children that registered too late, reparented during the
2035
- # BEAM-side sweep, or survived a bounded in-BEAM cleanup.
2036
- reap_workspace_cwd_from_file "$_session_workspace_root_file"
2037
-
2038
- # Reap the epmd our BEAM spawned for distribution so it doesn't linger after
2039
- # exit. The node is dead by now, so `epmd -kill` succeeds; it safely refuses
2040
- # while any *other* Erlang node is alive, so a daemon shared with an unrelated
2041
- # Erlang program is never disrupted. Use the release's bundled epmd and pin it
2042
- # to the loopback address aiur starts it on.
2043
- for _epmd in "$_session_release"/erts-*/bin/epmd; do
2044
- [ -x "$_epmd" ] || continue
2045
- ERL_EPMD_ADDRESS="${ERL_EPMD_ADDRESS:-127.0.0.1}" "$_epmd" -kill >/dev/null 2>&1 || true
2046
- done
2047
-
2048
- if command -v opencode >/dev/null 2>&1 && [ -s "$_session_tmpfile" ]; then
2049
- local id
2050
- while IFS= read -r id; do
2051
- [ -n "$id" ] || continue
2052
- opencode session delete "$id" >/dev/null 2>&1 &
2053
- done <"$_session_tmpfile"
2054
- wait 2>/dev/null || true
2055
- fi
2056
-
2057
- # Reap agent-driver sockets this run orphaned at close, not next launch.
2058
- sweep_dead_tmux_sockets || true
2059
-
2060
- # Reap stale aiur /tmp debris (per-run tempfiles, leaked test artifacts) so a
2061
- # tmpfs /tmp can't fill across runs. Age-gated + pid-safe; our own still-present
2062
- # tempfiles are spared (fresh + live pid) before the explicit rm below clears them.
2063
- sweep_stale_tmp_artifacts || true
2064
-
2065
- rm -f "$_session_tmpfile" "$_session_capture" "$_session_argv" "$_session_pidfile" \
2066
- "$_session_workspace_root_file" "$_session_alert_ledger_path_file" \
2067
- "$_session_crash_dump_baseline_file" 2>/dev/null || true
2068
- rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
2069
- release_aiur_launch_lock "$_session_launch_lock"
2070
- _session_launch_lock=""
2071
- return $code
2072
- }
2073
- install_foreground_traps() {
2074
- trap 'session_cleanup' EXIT
2075
- trap 'trap - EXIT INT TERM HUP; session_cleanup; exit 130' INT
2076
- trap 'trap - EXIT INT TERM HUP; session_cleanup; exit 143' TERM
2077
- trap 'trap - EXIT INT TERM HUP; session_cleanup; exit 129' HUP
2078
- }
2079
-
2080
- # --- lifecycle (RPC into the running node + tmux ops) ------------------------
2081
-
2082
- trim() {
2083
- local s="$1"
2084
- s="${s#"${s%%[![:space:]]*}"}"
2085
- s="${s%"${s##*[![:space:]]}"}"
2086
- printf '%s' "$s"
2087
- }
2088
-
2089
- # Classify our distribution node's liveness via epmd, which only advertises a
2090
- # node while its BEAM holds the registration. Echoes exactly one word:
2091
- # up — node is registered: it is alive, so an rpc failure is a real error
2092
- # down — epmd answered and our node is absent: it genuinely is not running
2093
- # unknown — epmd could not be queried (binary missing, or daemon unreachable),
2094
- # so node state is indeterminate and callers must NOT assume "down"
2095
- # and mask the real error
2096
- # Distinguishing `unknown` from `down` matters: a live node whose epmd we cannot
2097
- # reach must still surface its real rpc error rather than the "start aiur" hint.
2098
- # Relies on RELEASE_NODE + ERL_EPMD_ADDRESS (prepare_distribution) and
2099
- # release_dir (resolve_release), so call it only after both have run.
2100
- probe_node_liveness() {
2101
- local epmd names short="${RELEASE_NODE%@*}"
2102
- for epmd in "$release_dir"/erts-*/bin/epmd; do
2103
- [ -x "$epmd" ] || continue
2104
- names="$(ERL_EPMD_ADDRESS="${ERL_EPMD_ADDRESS:-127.0.0.1}" "$epmd" -names 2>/dev/null)" \
2105
- || { printf 'unknown'; return; }
2106
- case "$names" in
2107
- *"name ${short} at port "*) printf 'up' ;;
2108
- *) printf 'down' ;;
2109
- esac
2110
- return
2111
- done
2112
- printf 'unknown'
2113
- }
2114
-
2115
- confirm_stale_tmux_session() {
2116
- local tmux_bin="$1" socket="$2" conf="$3" session="$4"
2117
- local max_ticks="${AIUR_STALE_SESSION_GRACE_TICKS:-30}" tick
2118
-
2119
- for ((tick = 0; tick < max_ticks; tick++)); do
2120
- "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null || return 0
2121
- [ "$(probe_control_liveness)" = "up" ] && return 1
2122
- [ "$(probe_node_liveness)" = "down" ] || return 1
2123
- sleep 0.1
2124
- done
2125
-
2126
- "$tmux_bin" -L "$socket" -f "$conf" has-session -t "$session" 2>/dev/null || return 0
2127
- [ "$(probe_control_liveness)" != "up" ] && [ "$(probe_node_liveness)" = "down" ]
2128
- }
2129
-
2130
- probe_named_node_liveness() {
2131
- local node="$1" old_node="${RELEASE_NODE:-}" state
2132
- RELEASE_NODE="$node"
2133
- state="$(probe_node_liveness)"
2134
- RELEASE_NODE="$old_node"
2135
- printf '%s' "$state"
2136
- }
2137
-
2138
- load_aiur_instance_record() {
2139
- local file="$1"
2140
- AIUR_RECORD_NODE=""
2141
- AIUR_RECORD_INSTANCE_KEY=""
2142
- AIUR_RECORD_SESSION=""
2143
- AIUR_RECORD_SOCKET=""
2144
- AIUR_RECORD_AGENT_TMPFILE=""
2145
- AIUR_RECORD_WORKSPACE_ROOT_FILE=""
2146
- AIUR_RECORD_PROJECT_ROOT=""
2147
- AIUR_RECORD_PROJECT_ROOT_SOURCE=""
2148
- [ -r "$file" ] || return 1
2149
- # Records are written by this engine as KEY=%q lines under the user's state dir.
2150
- # shellcheck disable=SC1090
2151
- source "$file" 2>/dev/null || return 1
2152
- [ -n "$AIUR_RECORD_NODE" ] && [ -n "$AIUR_RECORD_SESSION" ] && \
2153
- [ -n "$AIUR_RECORD_SOCKET" ] && [ -n "$AIUR_RECORD_PROJECT_ROOT" ]
2154
- }
2155
-
2156
- path_is_within_root() {
2157
- local root child
2158
- root="$(canonical_workspace_root "$1")"
2159
- child="$(canonical_workspace_root "$2")"
2160
- [ "$child" = "$root" ] || [[ "$child/" == "$root/"* ]]
2161
- }
2162
-
2163
- AIUR_CONTROL_ADOPTED_RECORD=0
2164
- AIUR_CONTROL_CURRENT_NODE_STATE=""
2165
- AIUR_CONTROL_HINT_ROOTS=""
2166
- AIUR_CONTROL_CALLER_ROOT=""
2167
- AIUR_CONTROL_CALLER_NODE=""
2168
- AIUR_CONTROL_CALLER_ROOT_SOURCE=""
2169
-
2170
- resolve_control_identity_from_records() {
2171
- AIUR_CONTROL_ADOPTED_RECORD=0
2172
- AIUR_CONTROL_CURRENT_NODE_STATE="$(probe_node_liveness)"
2173
- AIUR_CONTROL_HINT_ROOTS=""
2174
- AIUR_CONTROL_CALLER_ROOT="$(canonical_workspace_root "${AIUR_PROJECT_ROOT:-}")"
2175
- AIUR_CONTROL_CALLER_NODE="$AIUR_RELEASE_NODE"
2176
- AIUR_CONTROL_CALLER_ROOT_SOURCE="${AIUR_PROJECT_ROOT_SOURCE:-}"
2177
-
2178
- [ "$AIUR_CONTROL_CURRENT_NODE_STATE" = "down" ] || return 0
2179
- [ "${AIUR_PROJECT_ROOT_SOURCE:-}" = "cwd" ] || return 0
2180
-
2181
- local dir file state root match_count=0
2182
- local match_node="" match_key="" match_session="" match_socket="" match_root="" match_source=""
2183
- dir="$(aiur_instances_dir)"
2184
- [ -d "$dir" ] || return 0
2185
-
2186
- for file in "$dir"/*.instance; do
2187
- [ -e "$file" ] || continue
2188
- load_aiur_instance_record "$file" || continue
2189
- state="$(probe_named_node_liveness "$AIUR_RECORD_NODE")"
2190
- [ "$state" = "up" ] || continue
2191
-
2192
- root="$(canonical_workspace_root "$AIUR_RECORD_PROJECT_ROOT")"
2193
- case "$AIUR_CONTROL_HINT_ROOTS" in
2194
- *"
2195
- $root
2196
- "*) ;;
2197
- *) AIUR_CONTROL_HINT_ROOTS="${AIUR_CONTROL_HINT_ROOTS}${root}
2198
- " ;;
2199
- esac
2200
-
2201
- path_is_within_root "$root" "$AIUR_CONTROL_CALLER_ROOT" || continue
2202
- match_count=$((match_count + 1))
2203
- match_node="$AIUR_RECORD_NODE"
2204
- match_key="$AIUR_RECORD_INSTANCE_KEY"
2205
- match_session="$AIUR_RECORD_SESSION"
2206
- match_socket="$AIUR_RECORD_SOCKET"
2207
- match_root="$root"
2208
- match_source="$AIUR_RECORD_PROJECT_ROOT_SOURCE"
2209
- done
2210
-
2211
- [ "$match_count" -eq 1 ] || return 0
2212
-
2213
- AIUR_RELEASE_NODE="$match_node"
2214
- AIUR_INSTANCE_KEY="$match_key"
2215
- AIUR_PROJECT_ROOT="$match_root"
2216
- AIUR_PROJECT_ROOT_SOURCE="$match_source"
2217
- AIUR_ADOPTED_TMUX_SESSION="$match_session"
2218
- AIUR_ADOPTED_TMUX_SOCKET="$match_socket"
2219
- export AIUR_RELEASE_NODE AIUR_INSTANCE_KEY AIUR_PROJECT_ROOT AIUR_PROJECT_ROOT_SOURCE \
2220
- AIUR_ADOPTED_TMUX_SESSION AIUR_ADOPTED_TMUX_SOCKET
2221
- RELEASE_NODE="$AIUR_RELEASE_NODE"
2222
- export RELEASE_NODE
2223
- AIUR_CONTROL_ADOPTED_RECORD=1
2224
- }
2225
-
2226
- print_global_config_control_hint() {
2227
- [ -n "${AIUR_CONTROL_HINT_ROOTS:-}" ] || return 0
2228
- [ "${AIUR_CONTROL_CALLER_ROOT_SOURCE:-}" = "cwd" ] || return 0
2229
- [ "${AIUR_CONTROL_CURRENT_NODE_STATE:-}" = "down" ] || return 0
2230
-
2231
- echo "aiur: global-config control identity is keyed by cwd ${AIUR_CONTROL_CALLER_ROOT:-${AIUR_PROJECT_ROOT:-unknown}}" >&2
2232
- echo "aiur: run control commands from the launch directory, or from a subdirectory of that launch directory" >&2
2233
- echo "aiur: live launch directory candidate(s):" >&2
2234
- printf '%s' "$AIUR_CONTROL_HINT_ROOTS" | sed '/^$/d; s/^/ /' >&2
2235
- }
2236
-
2237
- control_rpc_timeout_seconds() {
2238
- local seconds="${AIUR_CONTROL_RPC_TIMEOUT_SECONDS:-10}"
2239
- case "$seconds" in
2240
- '' | *[!0-9]* | 0) seconds=10 ;;
2241
- esac
2242
- printf '%s' "$seconds"
2243
- }
2244
-
2245
- print_not_running_message() {
2246
- echo "error: aiur is not running. Start it with \`aiurdev run\` (or \`aiurdev --bg\`), then retry." >&2
2247
- }
2248
-
2249
- print_control_down_message() {
2250
- local crash_marker
2251
- crash_marker="$(aiur_crash_marker_path)"
2252
- if [ -f "$crash_marker" ]; then
2253
- echo "aiur: background daemon at ${RELEASE_NODE} is DOWN after an unexpected exit; agents may be orphaned" >&2
2254
- sed 's/^/ /' "$crash_marker" >&2 2>/dev/null || true
2255
- echo "aiur: run 'aiur stop' to reap any orphaned agents, then start aiur again" >&2
2256
- else
2257
- print_not_running_message
2258
- print_global_config_control_hint
2259
- fi
2260
- }
2261
-
2262
- kill_control_rpc_process() {
2263
- local pid="$1" grouped="$2" p tree=()
2264
- [ -n "$pid" ] || return 0
2265
-
2266
- # Snapshot descendants before signalling the wrapper. A release launcher may
2267
- # fork its rpc BEAM into another process group and then exit; group signalling
2268
- # alone would orphan that child and lose the only relationship we can reap.
2269
- while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
2270
-
2271
- if [ "$grouped" = "1" ]; then
2272
- kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
2273
- fi
2274
-
2275
- for p in "${tree[@]}"; do kill -TERM "$p" 2>/dev/null || true; done
2276
- sleep 0.2
2277
- if [ "$grouped" = "1" ]; then
2278
- kill -KILL "-$pid" 2>/dev/null || true
2279
- fi
2280
- for p in "${tree[@]}"; do kill -KILL "$p" 2>/dev/null || true; done
2281
- }
2282
-
2283
- # Signalling the watchdog subshell alone leaves its `sleep` child running and
2284
- # orphaned: bash forks the subshell, and the sleep is a separate process the
2285
- # subshell's death does not reap. Kill the descendants too so a fast reply
2286
- # leaves nothing behind that outlives the command.
2287
- # Deepest-first: a bash subshell that is TERMed while waiting on a foreground
2288
- # child defers the signal until that child exits, so signalling the wrapper
2289
- # before the sleep it is waiting on can leave the sleep running.
2290
- cancel_timeout_watchdog() {
2291
- local pid="$1" p tree=() i
2292
- [ -n "$pid" ] || return 0
2293
- while IFS= read -r p; do tree+=("$p"); done < <(agent_pid_tree "$pid")
2294
- for ((i = ${#tree[@]} - 1; i >= 0; i--)); do kill -TERM "${tree[i]}" 2>/dev/null || true; done
2295
- }
2296
-
2297
- run_release_rpc_with_timeout() {
2298
- local expression="$1" timeout output_file timeout_file pid watchdog_pid status grouped=0
2299
- timeout="$(control_rpc_timeout_seconds)"
2300
- output_file="$(mktemp "${TMPDIR:-/tmp}/aiur-control-rpc-output.XXXXXX")"
2301
- timeout_file="$(mktemp "${TMPDIR:-/tmp}/aiur-control-rpc-timeout.XXXXXX")"
2302
- rm -f "$timeout_file" 2>/dev/null || true
2303
-
2304
- if command -v setsid >/dev/null 2>&1; then
2305
- setsid "$release_bin" rpc "$expression" >"$output_file" 2>&1 &
2306
- grouped=1
2307
- else
2308
- "$release_bin" rpc "$expression" >"$output_file" 2>&1 &
2309
- fi
2310
- pid=$!
2311
-
2312
- # The watchdog must not hold the caller's stdout/stderr. A caller that captures
2313
- # our output (command substitution, a pipe, a language runtime's subprocess
2314
- # capture) reads until EOF, and EOF arrives only when every process holding the
2315
- # write end exits - including a watchdog that is merely sleeping. Closing those
2316
- # descriptors here is what keeps the timeout a ceiling instead of a floor.
2317
- (
2318
- sleep "$timeout"
2319
- if kill -0 "$pid" 2>/dev/null; then
2320
- : >"$timeout_file"
2321
- kill_control_rpc_process "$pid" "$grouped"
2322
- fi
2323
- ) >/dev/null 2>&1 &
2324
- watchdog_pid=$!
2325
-
2326
- if wait "$pid"; then
2327
- status=0
2328
- else
2329
- status=$?
2330
- fi
2331
- if [ -f "$timeout_file" ]; then
2332
- # The timeout watchdog owns descendant cleanup. Once it has marked the
2333
- # timeout, let it finish its TERM/KILL sequence; cancelling it here can
2334
- # strand descendants that ignored TERM after the root process exits.
2335
- :
2336
- else
2337
- cancel_timeout_watchdog "$watchdog_pid"
2338
- fi
2339
- wait "$watchdog_pid" 2>/dev/null || true
2340
-
2341
- AIUR_CONTROL_RPC_OUTPUT="$(cat "$output_file" 2>/dev/null || true)"
2342
- if [ -f "$timeout_file" ]; then
2343
- AIUR_CONTROL_RPC_TIMED_OUT=1
2344
- status=124
2345
- else
2346
- AIUR_CONTROL_RPC_TIMED_OUT=0
2347
- fi
2348
-
2349
- rm -f "$output_file" "$timeout_file" 2>/dev/null || true
2350
- return "$status"
2351
- }
2352
-
2353
- control_command_label() {
2354
- printf '%s' "${AIUR_CONTROL_COMMAND:-control rpc}"
2355
- }
2356
-
2357
- control_attempt_sentence() {
2358
- [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ] || return 0
2359
- printf ' Attempted %s against daemon endpoint %s.' "$AIUR_CONTROL_ATTEMPT_CONTEXT" "${RELEASE_NODE:-unknown}"
2360
- }
2361
-
2362
- # Every line a control RPC surfaces to the operator routes through one of these
2363
- # two helpers, so `run_control_rpc` can prove it never returns non-zero while
2364
- # saying nothing (#1684): a silent failure is indistinguishable from a healthy
2365
- # idle fleet, and nobody reads exit codes interactively.
2366
- control_rpc_say() {
2367
- AIUR_CONTROL_RPC_DIAGNOSED=1
2368
- printf '%s\n' "$1" >&2
2369
- }
2370
-
2371
- control_rpc_echo_output() {
2372
- [ -n "$1" ] || return 0
2373
- AIUR_CONTROL_RPC_DIAGNOSED=1
2374
- printf '%s\n' "$1" >&2
2375
- }
2376
-
2377
- # RPC an expression into the running node. The control CLI prints a trailing
2378
- # `__AIUR_CONTROL_EXIT__:<code>` marker we translate into the process exit code.
2379
- #
2380
- # Wrapper contract: a non-zero return ALWAYS carries at least one stderr line
2381
- # naming the command that failed. EX_TEMPFAIL (75) is exempt — it is the dev
2382
- # shim's internal rebuild-and-retry signal, never an operator-visible outcome.
2383
- run_control_rpc() {
2384
- local status=0
2385
-
2386
- AIUR_CONTROL_RPC_DIAGNOSED=0
2387
- run_control_rpc_dispatch "$@" || status=$?
2388
-
2389
- if [ "$status" -ne 0 ] && [ "$status" -ne 75 ] && [ "${AIUR_CONTROL_RPC_DIAGNOSED:-0}" -ne 1 ]; then
2390
- if [ "$status" -eq 124 ]; then
2391
- 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."
2392
- elif [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2393
- 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."
2394
- else
2395
- 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."
2396
- fi
2397
- fi
2398
-
2399
- return "$status"
2400
- }
2401
-
2402
- run_control_rpc_dispatch() {
2403
- local expression="$1"
2404
- resolve_release || return $?
2405
- prepare_distribution || die "distribution setup failed; cannot contact aiur"
2406
- resolve_control_identity_from_records
2407
- if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
2408
- prepare_distribution || die "distribution setup failed; cannot contact aiur"
2409
- fi
2410
-
2411
- local marker="__AIUR_CONTROL_EXIT__:" error_marker="__AIUR_CONTROL_ERROR__:"
2412
- local output status exit_code=0 saw_marker=0 saw_error=0 saw_output=0 line partial_suffix=""
2413
-
2414
- set +e
2415
- run_release_rpc_with_timeout "$expression"
2416
- status=$?
2417
- output="$AIUR_CONTROL_RPC_OUTPUT"
2418
- set -e
2419
-
2420
- if [ "$status" -ne 0 ] && [ "${AIUR_CONTROL_RELEASE_RETRYABLE:-0}" = "1" ] && \
2421
- { [ ! -x "$release_bin" ] || [ ! -x "$vsn_dir/elixir" ] || [ ! -r "$release_dir/releases/start_erl.data" ]; }; then
2422
- control_release_retry
2423
- return $?
2424
- fi
2425
-
2426
- if [ "${AIUR_CONTROL_RPC_TIMED_OUT:-0}" -eq 1 ]; then
2427
- [ -n "$output" ] && partial_suffix="; partial output was discarded"
2428
- 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."
2429
- return 124
2430
- fi
2431
-
2432
- if [ "$status" -ne 0 ]; then
2433
- # A non-zero exit here is the rpc transport failing — application-level
2434
- # outcomes ride the marker path below. `bin/aiur rpc` (Elixir --rpc-eval)
2435
- # reports a genuinely-down node and a live-but-unreachable one identically
2436
- # (`:noconnection`), and prints any exception raised inside the expression.
2437
- # So the reason string can't be trusted; classify via epmd instead. Only a
2438
- # node epmd confirms is down earns the friendly "start aiur" hint; an `up`
2439
- # node failed for a real reason, and an `unknown` probe must not be assumed
2440
- # down — in both of those cases surface the actual rpc output, never mask it.
2441
- #
2442
- # An empty buffer is its own case: `--rpc-eval` kills itself without a word
2443
- # when the evaluated expression exits (a GenServer call timing out against a
2444
- # saturated daemon does exactly that), so "see the error above" would point
2445
- # at nothing (#1684). Name the failure instead.
2446
- case "$(probe_node_liveness)" in
2447
- down)
2448
- print_control_down_message
2449
- AIUR_CONTROL_RPC_DIAGNOSED=1
2450
- ;;
2451
- up)
2452
- control_rpc_echo_output "$output"
2453
- if [ -n "$output" ]; then
2454
- control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (node is running); see the error above"
2455
- elif [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2456
- 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."
2457
- else
2458
- 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."
2459
- fi
2460
- ;;
2461
- *)
2462
- control_rpc_echo_output "$output"
2463
- if [ -n "$output" ]; then
2464
- control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} (could not query epmd to confirm node state); see the error above"
2465
- elif [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2466
- 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."
2467
- else
2468
- control_rpc_say "aiur: $(control_command_label) failed against ${RELEASE_NODE} with no output (could not query epmd to confirm node state)"
2469
- fi
2470
- ;;
2471
- esac
2472
- return 1
2473
- fi
2474
-
2475
- while IFS= read -r line; do
2476
- case "$line" in
2477
- "$marker"*)
2478
- saw_marker=1
2479
- exit_code="${line#"$marker"}"
2480
- ;;
2481
- "$error_marker"*)
2482
- saw_error=1
2483
- AIUR_CONTROL_RPC_DIAGNOSED=1
2484
- printf '%s\n' "${line#"$error_marker"}" >&2
2485
- ;;
2486
- :ok | "") ;;
2487
- *)
2488
- saw_output=1
2489
- AIUR_CONTROL_RPC_DIAGNOSED=1
2490
- printf '%s\n' "$line"
2491
- ;;
2492
- esac
2493
- done <<<"$output"
2494
-
2495
- if [ "$saw_marker" -ne 1 ]; then
2496
- if [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2497
- control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} returned no exit marker; command output may be incomplete.$(control_attempt_sentence)"
2498
- else
2499
- control_rpc_say "aiur: $(control_command_label) to ${RELEASE_NODE} returned no exit marker; command output may be incomplete"
2500
- fi
2501
- return 1
2502
- fi
2503
-
2504
- if [ "$exit_code" -ne 0 ] && [ "$saw_error" -ne 1 ] && [ "$saw_output" -ne 1 ]; then
2505
- if [ -n "${AIUR_CONTROL_ATTEMPT_CONTEXT:-}" ]; then
2506
- control_rpc_say "aiur: $(control_command_label) failed with exit ${exit_code} and produced no diagnostic output.$(control_attempt_sentence) Outcome is unknown."
2507
- else
2508
- control_rpc_say "aiur: $(control_command_label) failed with exit ${exit_code} and returned no diagnostic output"
2509
- fi
2510
- fi
2511
-
2512
- return "$exit_code"
2513
- }
2514
-
2515
- # A streaming Executor listener must retain the RPC process and its stdout. It
2516
- # intentionally bypasses the one-shot control timeout and exit-marker wrapper.
2517
- run_control_stream() {
2518
- local expression="$1"
2519
- resolve_release || return $?
2520
- prepare_distribution || die "distribution setup failed; cannot contact aiur"
2521
- resolve_control_identity_from_records
2522
- if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -eq 1 ]; then
2523
- prepare_distribution || die "distribution setup failed; cannot contact aiur"
2524
- fi
2525
- if [ "$(probe_node_liveness)" = "down" ]; then
2526
- print_control_down_message
2527
- return 1
2528
- fi
2529
- if "$release_bin" rpc "$expression"; then
2530
- return 0
2531
- else
2532
- local status=$?
2533
- echo "aiur: streaming control RPC failed with exit ${status}" >&2
2534
- return "$status"
2535
- fi
2536
- }
2537
-
2538
- elixir_list_literal() {
2539
- local first=1 item
2540
- printf '['
2541
- for item in "$@"; do
2542
- [ "$first" -eq 0 ] && printf ', '
2543
- printf '"%s"' "$item"
2544
- first=0
2545
- done
2546
- printf ']'
2547
- }
2548
-
2549
- # Parse issue-id targets (e.g. `44 45,46` or `--all`) into parsed_targets/parsed_all.
2550
- parsed_targets=()
2551
- parsed_all=0
2552
- parse_issue_targets() {
2553
- parsed_targets=()
2554
- parsed_all=0
2555
- [ "$#" -gt 0 ] || return 1
2556
-
2557
- if [ "$#" -eq 1 ] && [ "$1" = "--all" ]; then
2558
- parsed_all=1
2559
- return 0
2560
- fi
2561
-
2562
- local raw part parts
2563
- for raw in "$@"; do
2564
- [ "$raw" = "--all" ] && return 1
2565
- IFS=',' read -ra parts <<<"$raw"
2566
- for part in "${parts[@]}"; do
2567
- part="$(trim "$part")"
2568
- if [ -z "$part" ] || [[ ! "$part" =~ ^[0-9]+$ ]]; then return 1; fi
2569
- parsed_targets+=("$part")
2570
- done
2571
- done
2572
-
2573
- [ "${#parsed_targets[@]}" -gt 0 ]
2574
- }
2575
-
2576
- cmd_status() {
2577
- [ "$#" -eq 0 ] || die "status does not accept arguments"
2578
- run_control_rpc "Aiur.AgentControlCLI.status()"
2579
- }
2580
-
2581
- # `aiur usage` — Codex/Claude limit headroom from the daemon's meter
2582
- # projection, each value carrying the age of its observation.
2583
- cmd_usage() {
2584
- [ "$#" -eq 0 ] || die "usage does not accept arguments"
2585
- run_control_rpc "Aiur.AgentControlCLI.usage()"
2586
- }
2587
-
2588
- cmd_pause_resume() {
2589
- local command="$1"
2590
- shift
2591
-
2592
- # Bare `aiur pause` / `aiur resume` (no IDs, no --all) flips the single
2593
- # global pause switch: a daemon-wide halt distinct from per-agent pause.
2594
- if [ "$#" -eq 0 ]; then
2595
- run_control_rpc "Aiur.AgentControlCLI.${command}_global()"
2596
- return
2597
- fi
2598
-
2599
- if ! parse_issue_targets "$@"; then
2600
- 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
2601
- exit 64
2602
- fi
2603
-
2604
- local expression
2605
- if [ "$parsed_all" -eq 1 ]; then
2606
- expression="Aiur.AgentControlCLI.${command}(:all)"
2607
- else
2608
- expression="Aiur.AgentControlCLI.${command}($(elixir_list_literal "${parsed_targets[@]}"))"
2609
- fi
2610
-
2611
- run_control_rpc "$expression"
2612
- }
2613
-
2614
- # `aiur reset-budget <id>...` — clear the lifetime dispatch latch for one or
2615
- # more tickets (the supported exit from the #1453 latch; no JSON hand-editing).
2616
- cmd_reset_budget() {
2617
- if ! parse_issue_targets "$@"; then
2618
- echo "aiur: reset-budget expects issue IDs (e.g. aiur reset-budget 44 45,46)" >&2
2619
- exit 64
2620
- fi
2621
-
2622
- # --all is rejected (exit 64 with guidance) rather than silently no-opping:
2623
- # clearing every ticket's latch at once is not a documented operation and
2624
- # would mask which tickets are structurally stuck (#1453 review P2d).
2625
- if [ "$parsed_all" -eq 1 ]; then
2626
- echo "aiur: reset-budget does not accept --all; name ticket IDs explicitly (e.g. aiur reset-budget 44 45,46)" >&2
2627
- exit 64
2628
- fi
2629
-
2630
- local expression
2631
- expression="Aiur.AgentControlCLI.reset_budget($(elixir_list_literal "${parsed_targets[@]}"))"
2632
- run_control_rpc "$expression"
2633
- }
2634
-
2635
- # `aiur message <issue> <text>` — deliver Executor text to one running agent.
2636
- # The text is base64-encoded for the RPC hop so arbitrary content (quotes,
2637
- # backslashes, `#{}`, newlines) survives without Elixir-string escaping.
2638
- cmd_message() {
2639
- local usage="aiur: message expects an issue ID and text (e.g. aiur message 44 \"ship it\" or aiur message 44 --message-id ID \"ship it\")"
2640
- local message_id="" message_id_given=0
2641
-
2642
- local issue="${1:-}"
2643
- if [ -z "$issue" ] || [[ ! "$issue" =~ ^[0-9]+$ ]]; then
2644
- echo "$usage" >&2
2645
- exit 64
2646
- fi
2647
- shift
2648
-
2649
- # `--message-id ID` names this send, so a retry after an unknown outcome
2650
- # returns the first copy instead of queueing a second one (#2717).
2651
- case "${1:-}" in
2652
- --message-id)
2653
- [ "$#" -gt 1 ] || { echo "aiur: message --message-id requires a value" >&2; exit 64; }
2654
- message_id="$2"
2655
- message_id_given=1
2656
- shift 2
2657
- ;;
2658
- --message-id=*)
2659
- message_id="${1#--message-id=}"
2660
- message_id_given=1
2661
- shift
2662
- ;;
2663
- esac
2664
- if [ "$message_id_given" = 1 ] && [[ ! "$message_id" =~ ^[A-Za-z0-9._:-]{1,128}$ ]]; then
2665
- echo "aiur: message --message-id must be 1-128 letters, digits, '.', '_', ':' or '-' (it cannot be empty)" >&2
2666
- exit 64
2667
- fi
2668
-
2669
- local text="$*"
2670
- if [ -z "$text" ]; then
2671
- echo "$usage" >&2
2672
- exit 64
2673
- fi
2674
-
2675
- local encoded
2676
- encoded="$(printf '%s' "$text" | base64 | tr -d '\n')"
2677
- if [ -n "$message_id" ]; then
2678
- run_control_rpc "Aiur.AgentControlCLI.message(\"$issue\", Base.decode64!(\"$encoded\"), \"$message_id\")"
2679
- else
2680
- run_control_rpc "Aiur.AgentControlCLI.message(\"$issue\", Base.decode64!(\"$encoded\"))"
2681
- fi
2682
- }
2683
-
2684
- # `aiur agents` — concise one-line-per-agent state + current activity from a
2685
- # live node (the headless equivalent of the dashboard / aiur-status skill).
2686
- cmd_agents() {
2687
- [ "$#" -eq 0 ] || die "agents does not accept arguments"
2688
- run_control_rpc "Aiur.AgentControlCLI.agents()"
2689
- }
2690
-
2691
- # `aiur commands` — read the dashboard's retained Decision projection without
2692
- # exposing any dispatch or answer mutation path.
2693
- cmd_commands() {
2694
- local filter="all" blocking=0 json=0 decision_id="" ticket="" search="" cursor="" limit="" arg
2695
-
2696
- while [ "$#" -gt 0 ]; do
2697
- arg="$1"
2698
- case "$arg" in
2699
- --filter) [ "$#" -gt 1 ] || { echo "aiur: commands --filter requires a value" >&2; exit 64; }; shift; filter="$1" ;;
2700
- --filter=*) filter="${arg#--filter=}" ;;
2701
- --blocking) blocking=1 ;;
2702
- --ticket) [ "$#" -gt 1 ] || { echo "aiur: commands --ticket requires a value" >&2; exit 64; }; shift; ticket="$1" ;;
2703
- --ticket=*) ticket="${arg#--ticket=}" ;;
2704
- --search) [ "$#" -gt 1 ] || { echo "aiur: commands --search requires a value" >&2; exit 64; }; shift; search="$1" ;;
2705
- --search=*) search="${arg#--search=}" ;;
2706
- --cursor) [ "$#" -gt 1 ] || { echo "aiur: commands --cursor requires a value" >&2; exit 64; }; shift; cursor="$1" ;;
2707
- --cursor=*) cursor="${arg#--cursor=}" ;;
2708
- --limit) [ "$#" -gt 1 ] || { echo "aiur: commands --limit requires a value" >&2; exit 64; }; shift; limit="$1" ;;
2709
- --limit=*) limit="${arg#--limit=}" ;;
2710
- --json) json=1 ;;
2711
- -*) echo "aiur: commands received an unknown option: $arg" >&2; exit 64 ;;
2712
- *)
2713
- if [ -n "$decision_id" ]; then
2714
- echo "aiur: commands accepts at most one decision ID" >&2
2715
- exit 64
2716
- fi
2717
- decision_id="$arg"
2718
- ;;
2719
- esac
2720
- shift
2721
- done
2722
-
2723
- case "$filter" in all|open|blocking|resolved) ;; *) echo "aiur: commands --filter accepts all, open, blocking, or resolved" >&2; exit 64 ;; esac
2724
- [ -z "$limit" ] || [[ "$limit" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: commands --limit expects a positive integer" >&2; exit 64; }
2725
- [ -z "$ticket" ] || [ "$filter" = "all" ] || { echo "aiur: commands --ticket requires --filter all" >&2; exit 64; }
2726
- [ -z "$search" ] || [ "$filter" = "all" ] || { echo "aiur: commands --search requires --filter all" >&2; exit 64; }
2727
-
2728
- local opts="filter: :$filter"
2729
- [ "$blocking" -eq 1 ] && opts="$opts, blocking: true"
2730
- [ "$json" -eq 1 ] && opts="$opts, json: true"
2731
- [ -n "$limit" ] && opts="$opts, limit: $limit"
2732
- local key raw encoded
2733
- for key in decision_id ticket search cursor; do
2734
- raw="${!key}"
2735
- [ -n "$raw" ] || continue
2736
- encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2737
- opts="$opts, $key: Base.decode64!(\"$encoded\")"
2738
- done
2739
-
2740
- run_control_rpc "Aiur.AgentControlCLI.commands([$opts])"
2741
- }
2742
-
2743
- encode_control_value() {
2744
- printf '%s' "$1" | base64 | tr -d '\n'
2745
- }
2746
-
2747
- # These are explicit trusted-Executor mutations, deliberately separate from
2748
- # the read-only `commands` catalog. Revisions remain dashboard-owned.
2749
- cmd_executor_answer() {
2750
- local decision_id="${1:-}" expected_version="" option_id="" custom_response="" rationale="" idempotency_key="" executor_id="aiur-cli" supersede=0 arg
2751
- if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2752
- echo "aiur: executor-answer expects exactly one decision ID" >&2
2753
- exit 64
2754
- fi
2755
- shift
2756
-
2757
- while [ "$#" -gt 0 ]; do
2758
- arg="$1"
2759
- case "$arg" in
2760
- --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2761
- --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2762
- --option) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --option requires a value" >&2; exit 64; }; shift; option_id="$1" ;;
2763
- --option=*) option_id="${arg#--option=}" ;;
2764
- --custom-response) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --custom-response requires a value" >&2; exit 64; }; shift; custom_response="$1" ;;
2765
- --custom-response=*) custom_response="${arg#--custom-response=}" ;;
2766
- --rationale) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --rationale requires a value" >&2; exit 64; }; shift; rationale="$1" ;;
2767
- --rationale=*) rationale="${arg#--rationale=}" ;;
2768
- --idempotency-key) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --idempotency-key requires a value" >&2; exit 64; }; shift; idempotency_key="$1" ;;
2769
- --idempotency-key=*) idempotency_key="${arg#--idempotency-key=}" ;;
2770
- --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-answer --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2771
- --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2772
- --supersede) supersede=1 ;;
2773
- -*) echo "aiur: executor-answer received an unknown option: $arg" >&2; exit 64 ;;
2774
- *) echo "aiur: executor-answer expects exactly one decision ID" >&2; exit 64 ;;
2775
- esac
2776
- shift
2777
- done
2778
-
2779
- [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-answer --expected-version expects a positive integer" >&2; exit 64; }
2780
- if { [ -n "$option_id" ] && [ -n "$custom_response" ]; } || { [ -z "$option_id" ] && [ -z "$custom_response" ]; }; then
2781
- echo "aiur: executor-answer requires exactly one of --option or --custom-response" >&2
2782
- exit 64
2783
- fi
2784
- [ -n "$rationale" ] || { echo "aiur: executor-answer --rationale is required" >&2; exit 64; }
2785
- [ -n "$idempotency_key" ] || { echo "aiur: executor-answer --idempotency-key is required" >&2; exit 64; }
2786
- [ -n "$executor_id" ] || { echo "aiur: executor-answer --executor-id must not be empty" >&2; exit 64; }
2787
-
2788
- local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2789
- if [ -n "$option_id" ]; then
2790
- opts="$opts, option_id: Base.decode64!(\"$(encode_control_value "$option_id")\")"
2791
- else
2792
- opts="$opts, custom_response: Base.decode64!(\"$(encode_control_value "$custom_response")\")"
2793
- fi
2794
- opts="$opts, rationale: Base.decode64!(\"$(encode_control_value "$rationale")\")"
2795
- opts="$opts, idempotency_key: Base.decode64!(\"$(encode_control_value "$idempotency_key")\")"
2796
- opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2797
- if [ "$supersede" -eq 1 ]; then
2798
- opts="$opts, supersede: true"
2799
- fi
2800
- local AIUR_CONTROL_ATTEMPT_CONTEXT="decision ID ${decision_id} with expected version ${expected_version}"
2801
- run_control_rpc "Aiur.AgentControlCLI.executor_answer([$opts])"
2802
- }
2803
-
2804
- cmd_executor_escalate() {
2805
- local decision_id="${1:-}" expected_version="" reason="" executor_id="aiur-cli" arg
2806
- if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2807
- echo "aiur: executor-escalate expects exactly one decision ID" >&2
2808
- exit 64
2809
- fi
2810
- shift
2811
-
2812
- while [ "$#" -gt 0 ]; do
2813
- arg="$1"
2814
- case "$arg" in
2815
- --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2816
- --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2817
- --reason) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --reason requires a value" >&2; exit 64; }; shift; reason="$1" ;;
2818
- --reason=*) reason="${arg#--reason=}" ;;
2819
- --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-escalate --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2820
- --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2821
- -*) echo "aiur: executor-escalate received an unknown option: $arg" >&2; exit 64 ;;
2822
- *) echo "aiur: executor-escalate expects exactly one decision ID" >&2; exit 64 ;;
2823
- esac
2824
- shift
2825
- done
2826
-
2827
- [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-escalate --expected-version expects a positive integer" >&2; exit 64; }
2828
- [ -n "$reason" ] || { echo "aiur: executor-escalate --reason is required" >&2; exit 64; }
2829
- [ -n "$executor_id" ] || { echo "aiur: executor-escalate --executor-id must not be empty" >&2; exit 64; }
2830
-
2831
- local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2832
- opts="$opts, reason: Base.decode64!(\"$(encode_control_value "$reason")\")"
2833
- opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2834
- local AIUR_CONTROL_ATTEMPT_CONTEXT="decision ID ${decision_id} with expected version ${expected_version}"
2835
- run_control_rpc "Aiur.AgentControlCLI.executor_escalate([$opts])"
2836
- }
2837
-
2838
- cmd_executor_moot() {
2839
- local decision_id="${1:-}" expected_version="" reason_class="" reason="" executor_id="aiur-cli" arg
2840
- if [ -z "$decision_id" ] || [[ "$decision_id" = -* ]]; then
2841
- echo "aiur: executor-moot expects exactly one decision ID" >&2
2842
- exit 64
2843
- fi
2844
- shift
2845
-
2846
- while [ "$#" -gt 0 ]; do
2847
- arg="$1"
2848
- case "$arg" in
2849
- --expected-version) [ "$#" -gt 1 ] || { echo "aiur: executor-moot --expected-version requires a value" >&2; exit 64; }; shift; expected_version="$1" ;;
2850
- --expected-version=*) expected_version="${arg#--expected-version=}" ;;
2851
- --reason-class) [ "$#" -gt 1 ] || { echo "aiur: executor-moot --reason-class requires a value" >&2; exit 64; }; shift; reason_class="$1" ;;
2852
- --reason-class=*) reason_class="${arg#--reason-class=}" ;;
2853
- --reason) [ "$#" -gt 1 ] || { echo "aiur: executor-moot --reason requires a value" >&2; exit 64; }; shift; reason="$1" ;;
2854
- --reason=*) reason="${arg#--reason=}" ;;
2855
- --executor-id) [ "$#" -gt 1 ] || { echo "aiur: executor-moot --executor-id requires a value" >&2; exit 64; }; shift; executor_id="$1" ;;
2856
- --executor-id=*) executor_id="${arg#--executor-id=}" ;;
2857
- -*) echo "aiur: executor-moot received an unknown option: $arg" >&2; exit 64 ;;
2858
- *) echo "aiur: executor-moot expects exactly one decision ID" >&2; exit 64 ;;
2859
- esac
2860
- shift
2861
- done
2862
-
2863
- [[ "$expected_version" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-moot --expected-version expects a positive integer" >&2; exit 64; }
2864
- [ -n "$reason_class" ] || { echo "aiur: executor-moot --reason-class is required" >&2; exit 64; }
2865
- [ -n "$executor_id" ] || { echo "aiur: executor-moot --executor-id must not be empty" >&2; exit 64; }
2866
-
2867
- local opts="decision_id: Base.decode64!(\"$(encode_control_value "$decision_id")\"), expected_version: $expected_version"
2868
- opts="$opts, reason_class: Base.decode64!(\"$(encode_control_value "$reason_class")\")"
2869
- if [ -n "$reason" ]; then
2870
- opts="$opts, reason: Base.decode64!(\"$(encode_control_value "$reason")\")"
2871
- fi
2872
- opts="$opts, executor_id: Base.decode64!(\"$(encode_control_value "$executor_id")\")"
2873
- local AIUR_CONTROL_ATTEMPT_CONTEXT="decision ID ${decision_id} with expected version ${expected_version}"
2874
- run_control_rpc "Aiur.AgentControlCLI.executor_moot([$opts])"
2875
- }
2876
-
2877
- # `aiur units` — read the dashboard Units catalog through its own projection,
2878
- # including non-running tickets in current-run membership.
2879
- cmd_units() {
2880
- local scope="live" format="" json=0 arg condition condition_value condition_encoded conditions_literal=""
2881
- local -a conditions=() condition_values=()
2882
- while [ "$#" -gt 0 ]; do
2883
- arg="$1"
2884
- case "$arg" in
2885
- --scope) [ "$#" -gt 1 ] || { echo "aiur: units --scope requires a value" >&2; exit 64; }; shift; scope="$1" ;;
2886
- --scope=*) scope="${arg#--scope=}" ;;
2887
- --condition) [ "$#" -gt 1 ] || { echo "aiur: units --condition requires a value" >&2; exit 64; }; shift; conditions+=("$1") ;;
2888
- --condition=*) conditions+=("${arg#--condition=}") ;;
2889
- --format) [ "$#" -gt 1 ] || { echo "aiur: units --format requires a value" >&2; exit 64; }; shift; format="$1" ;;
2890
- --format=*) format="${arg#--format=}" ;;
2891
- --json) json=1 ;;
2892
- -*) echo "aiur: units received an unknown option: $arg" >&2; exit 64 ;;
2893
- *) echo "aiur: units does not accept positional arguments" >&2; exit 64 ;;
2894
- esac
2895
- shift
2896
- done
2897
-
2898
- for condition in "${conditions[@]+"${conditions[@]}"}"; do
2899
- IFS=',' read -r -a condition_values <<< "$condition"
2900
- for condition_value in "${condition_values[@]+"${condition_values[@]}"}"; do
2901
- if [ -n "$conditions_literal" ]; then conditions_literal="$conditions_literal, "; fi
2902
- condition_encoded="$(printf '%s' "$condition_value" | base64 | tr -d '\n')"
2903
- conditions_literal="${conditions_literal}Base.decode64!(\"${condition_encoded}\")"
2904
- done
2905
- done
2906
-
2907
- local scope_encoded
2908
- scope_encoded="$(printf '%s' "$scope" | base64 | tr -d '\n')"
2909
- local opts="scope: Base.decode64!(\"$scope_encoded\")"
2910
- [ -z "$conditions_literal" ] || opts="$opts, conditions: [$conditions_literal]"
2911
-
2912
- if [ -n "$format" ]; then
2913
- local format_encoded
2914
- format_encoded="$(printf '%s' "$format" | base64 | tr -d '\n')"
2915
- opts="$opts, format: Base.decode64!(\"$format_encoded\")"
2916
- fi
2917
-
2918
- [ "$json" -eq 1 ] && opts="$opts, json: true"
2919
- run_control_rpc "Aiur.AgentControlCLI.units([$opts])"
2920
- }
2921
-
2922
- # `aiur build-orders` — read the dashboard Build Order projection without a
2923
- # second GitHub or API derivation. A root selector switches from catalog to the
2924
- # selected-root graph view.
2925
- cmd_build_orders() {
2926
- local json=0 root="" arg
2927
-
2928
- while [ "$#" -gt 0 ]; do
2929
- arg="$1"
2930
- case "$arg" in
2931
- --json) json=1 ;;
2932
- -*) echo "aiur: build-orders received an unknown option: $arg" >&2; exit 64 ;;
2933
- *)
2934
- if [ -n "$root" ]; then
2935
- echo "aiur: build-orders accepts at most one root" >&2
2936
- exit 64
2937
- fi
2938
- root="$arg"
2939
- ;;
2940
- esac
2941
- shift
2942
- done
2943
-
2944
- local opts=""
2945
- [ "$json" -eq 1 ] && opts="json: true"
2946
-
2947
- if [ -n "$root" ]; then
2948
- local encoded
2949
- encoded="$(printf '%s' "$root" | base64 | tr -d '\n')"
2950
- [ -n "$opts" ] && opts="$opts, "
2951
- opts="${opts}root: Base.decode64!(\"$encoded\")"
2952
- fi
2953
-
2954
- run_control_rpc "Aiur.AgentControlCLI.build_orders([$opts])"
2955
- }
2956
-
2957
- # `aiur analytics` — render the dashboard analytics projection for an explicit
2958
- # time window. This is read-only and obtains the same durable telemetry snapshot
2959
- # the page uses through the running node.
2960
- cmd_analytics() {
2961
- local range="run" json=0 since="" until="" build_order="" has_build_order=0 arg
2962
-
2963
- while [ "$#" -gt 0 ]; do
2964
- arg="$1"
2965
- case "$arg" in
2966
- --range) [ "$#" -gt 1 ] || { echo "aiur: analytics --range requires a value" >&2; exit 64; }; shift; range="$1" ;;
2967
- --range=*) range="${arg#--range=}" ;;
2968
- --since) [ "$#" -gt 1 ] || { echo "aiur: analytics --since requires a value" >&2; exit 64; }; shift; since="$1" ;;
2969
- --since=*) since="${arg#--since=}" ;;
2970
- --until) [ "$#" -gt 1 ] || { echo "aiur: analytics --until requires a value" >&2; exit 64; }; shift; until="$1" ;;
2971
- --until=*) until="${arg#--until=}" ;;
2972
- --build-order) [ "$#" -gt 1 ] || { echo "aiur: analytics --build-order requires a value" >&2; exit 64; }; shift; build_order="$1"; has_build_order=1 ;;
2973
- --build-order=*) build_order="${arg#--build-order=}"; has_build_order=1 ;;
2974
- --json) json=1 ;;
2975
- -*) echo "aiur: analytics received an unknown option: $arg" >&2; exit 64 ;;
2976
- *) echo "aiur: analytics does not accept positional arguments" >&2; exit 64 ;;
2977
- esac
2978
- shift
2979
- done
2980
-
2981
- case "$range" in run|full) ;; *) echo "aiur: analytics --range accepts run or full" >&2; exit 64 ;; esac
2982
- [ "$has_build_order" -eq 0 ] || [[ "$build_order" =~ ^[0-9]+$ ]] || { echo "aiur: analytics --build-order expects a numeric ticket ID" >&2; exit 64; }
2983
-
2984
- local opts="range: :$range" key raw encoded
2985
- [ "$json" -eq 1 ] && opts="$opts, json: true"
2986
- for key in since until build_order; do
2987
- raw="${!key}"
2988
- [ -n "$raw" ] || continue
2989
- encoded="$(printf '%s' "$raw" | base64 | tr -d '\n')"
2990
- opts="$opts, $key: Base.decode64!(\"$encoded\")"
2991
- done
2992
-
2993
- run_control_rpc "Aiur.AgentControlCLI.analytics([$opts])"
2994
- }
2995
-
2996
- # `aiur github-cost` — GitHub API spend ranked by the call site that caused it,
2997
- # in points per hour, with the reconciliation against the credential's own
2998
- # `used` figure printed beside it. Read-only; it reads the meter the daemon
2999
- # already keeps and issues no GitHub request of its own.
3000
- cmd_github_cost() {
3001
- local budget="graphql" format="" json=0 arg
3002
-
3003
- while [ "$#" -gt 0 ]; do
3004
- arg="$1"
3005
- case "$arg" in
3006
- --budget) [ "$#" -gt 1 ] || { echo "aiur: github-cost --budget requires a value" >&2; exit 64; }; shift; budget="$1" ;;
3007
- --budget=*) budget="${arg#--budget=}" ;;
3008
- --format) [ "$#" -gt 1 ] || { echo "aiur: github-cost --format requires a value" >&2; exit 64; }; shift; format="$1" ;;
3009
- --format=*) format="${arg#--format=}" ;;
3010
- --json) json=1 ;;
3011
- -*) echo "aiur: github-cost received an unknown option: $arg" >&2; exit 64 ;;
3012
- *) echo "aiur: github-cost does not accept positional arguments" >&2; exit 64 ;;
3013
- esac
3014
- shift
3015
- done
3016
-
3017
- case "$budget" in graphql|core|all) ;; *) echo "aiur: github-cost --budget accepts graphql, core or all" >&2; exit 64 ;; esac
3018
- case "$format" in ""|auto|table|records) ;; *) echo "aiur: github-cost --format accepts auto, table or records" >&2; exit 64 ;; esac
3019
-
3020
- # Both values are whitelisted above, so neither reaches the control RPC as
3021
- # anything other than one of the literals named here.
3022
- local opts="budget: \"$budget\""
3023
- [ -z "$format" ] || opts="$opts, format: :$format"
3024
- [ "$json" -eq 1 ] && opts="$opts, json: true"
3025
-
3026
- run_control_rpc "Aiur.AgentControlCLI.github_cost([$opts])"
3027
- }
3028
-
3029
- # `aiur github-usage` — per-actor (daemon vs each agent workspace) Core/GraphQL
3030
- # usage and ceilings from the shared admission broker. Read-only; it reads the
3031
- # broker database and issues no GitHub request of its own.
3032
- cmd_github_usage() {
3033
- local json=0 arg
3034
-
3035
- while [ "$#" -gt 0 ]; do
3036
- arg="$1"
3037
- case "$arg" in
3038
- --json) json=1 ;;
3039
- -*) echo "aiur: github-usage received an unknown option: $arg" >&2; exit 64 ;;
3040
- *) echo "aiur: github-usage does not accept positional arguments" >&2; exit 64 ;;
3041
- esac
3042
- shift
3043
- done
3044
-
3045
- local opts=""
3046
- [ "$json" -eq 1 ] && opts="json: true"
3047
-
3048
- run_control_rpc "Aiur.AgentControlCLI.github_usage([$opts])"
3049
- }
3050
-
3051
- # `aiur alerts` — newline-delimited structured alert feed from persisted
3052
- # per-agent logs. `--needs-attention` filters to Executor-actionable alerts.
3053
- cmd_alerts() {
3054
- local needs_attention=0 arg
3055
- for arg in "$@"; do
3056
- case "$arg" in
3057
- --needs-attention) needs_attention=1 ;;
3058
- *)
3059
- echo "aiur: alerts only accepts --needs-attention" >&2
3060
- exit 64
3061
- ;;
3062
- esac
3063
- done
3064
-
3065
- if [ "$needs_attention" -eq 1 ]; then
3066
- run_control_rpc "Aiur.AgentControlCLI.alerts(needs_attention: true)"
3067
- else
3068
- run_control_rpc "Aiur.AgentControlCLI.alerts()"
3069
- fi
3070
- }
3071
-
3072
- # `aiur watch` — the server-side status board. Compiles one row per active
3073
- # agent (state · complexity · activity-age · doing) plus an actionable section
3074
- # from aiur's own state, with no GitHub round-trip. `--changes` (default) prints
3075
- # only state-level deltas since the last call; `--full` prints every row;
3076
- # `--interval N` re-renders every N seconds as a foreground watcher (the Elixir
3077
- # call stays one-shot — the loop lives here).
3078
- cmd_watch() {
3079
- local mode="changes" interval="" arg
3080
-
3081
- while [ "$#" -gt 0 ]; do
3082
- arg="$1"
3083
- case "$arg" in
3084
- --full) mode="full" ;;
3085
- --changes) mode="changes" ;;
3086
- --once) : ;;
3087
- --interval)
3088
- shift
3089
- interval="${1:-}"
3090
- watch_validate_interval "$interval"
3091
- ;;
3092
- --interval=*)
3093
- interval="${arg#--interval=}"
3094
- watch_validate_interval "$interval"
3095
- ;;
3096
- *)
3097
- echo "aiur: watch accepts --full, --changes, --once, --interval <secs>" >&2
3098
- exit 64
3099
- ;;
3100
- esac
3101
- shift
3102
- done
3103
-
3104
- local expression="Aiur.AgentControlCLI.watch(mode: :${mode})"
3105
-
3106
- if [ -n "$interval" ]; then
3107
- while true; do
3108
- run_control_rpc "$expression" || true
3109
- sleep "$interval"
3110
- done
3111
- else
3112
- run_control_rpc "$expression"
3113
- fi
3114
- }
3115
-
3116
- cmd_executor_listen() {
3117
- local topic="executor.#" arg
3118
- while [ "$#" -gt 0 ]; do
3119
- arg="$1"
3120
- case "$arg" in
3121
- --topic) shift; topic="${1:-}" ;;
3122
- --topic=*) topic="${arg#--topic=}" ;;
3123
- *) echo "aiur: executor-listen accepts --topic <pattern>" >&2; exit 64 ;;
3124
- esac
3125
- [ -n "$topic" ] || { echo "aiur: executor-listen requires a topic" >&2; exit 64; }
3126
- shift
3127
- done
3128
- local encoded
3129
- encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
3130
- run_control_stream "Aiur.AgentControlCLI.executor_listen(topic: Base.decode64!(\"$encoded\"))"
3131
- }
3132
-
3133
- cmd_executor_wait() {
3134
- local timeout=300 json=0 as="" arg
3135
- while [ "$#" -gt 0 ]; do
3136
- arg="$1"
3137
- case "$arg" in
3138
- --timeout) shift; timeout="${1:-}" ;;
3139
- --timeout=*) timeout="${arg#--timeout=}" ;;
3140
- --json) json=1 ;;
3141
- --as) shift; as="${1:-}" ;;
3142
- --as=*) as="${arg#--as=}" ;;
3143
- *) echo "aiur: executor-wait accepts --timeout <seconds>, --as <id> and --json" >&2; exit 64 ;;
3144
- esac
3145
- shift
3146
- done
3147
- [[ "$timeout" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-wait --timeout expects a positive integer" >&2; exit 64; }
3148
- executor_validate_consumer_id "$as" "executor-wait"
3149
- local json_arg=false
3150
- if [ "$json" -eq 1 ]; then json_arg=true; fi
3151
- AIUR_CONTROL_COMMAND="executor-wait"
3152
- AIUR_CONTROL_RPC_TIMEOUT_SECONDS=$((timeout + 10)) run_control_rpc "Aiur.AgentControlCLI.executor_wait(timeout_ms: $((timeout * 1000)), json: $json_arg$(executor_as_argument "$as"))"
3153
- }
3154
-
3155
- # The consumer id is an explicit identity, never inferred from the environment.
3156
- executor_validate_consumer_id() {
3157
- local value="$1" command="$2"
3158
- [ -n "$value" ] || return 0
3159
- [[ "$value" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "aiur: $command --as expects [A-Za-z0-9._-]+" >&2; exit 64; }
3160
- }
3161
-
3162
- executor_as_argument() {
3163
- [ -n "$1" ] || return 0
3164
- printf ', as: "%s"' "$1"
3165
- }
3166
-
3167
- executor_as_keyword() {
3168
- [ -n "$1" ] || return 0
3169
- printf 'as: "%s"' "$1"
3170
- }
3171
-
3172
- cmd_executor_roster() {
3173
- local json=0 arg
3174
- while [ "$#" -gt 0 ]; do
3175
- arg="$1"
3176
- case "$arg" in
3177
- --json) json=1 ;;
3178
- *) echo "aiur: executor-roster accepts only --json" >&2; exit 64 ;;
3179
- esac
3180
- shift
3181
- done
3182
- local json_arg=false
3183
- if [ "$json" -eq 1 ]; then json_arg=true; fi
3184
- run_control_rpc "Aiur.AgentControlCLI.executor_roster(json: $json_arg)"
3185
- }
3186
-
3187
- cmd_executor_fast_forward() {
3188
- local wake_id="${1:-}" as="" arg
3189
- [ -n "$wake_id" ] || { echo "aiur: executor-fast-forward expects a positive wake id" >&2; exit 64; }
3190
- shift
3191
- [[ "$wake_id" =~ ^[1-9][0-9]*$ ]] || { echo "aiur: executor-fast-forward expects a positive wake id" >&2; exit 64; }
3192
-
3193
- while [ "$#" -gt 0 ]; do
3194
- arg="$1"
3195
- case "$arg" in
3196
- --as)
3197
- shift
3198
- [ "$#" -gt 0 ] && [ -n "${1:-}" ] || { echo "aiur: executor-fast-forward --as requires a consumer id" >&2; exit 64; }
3199
- as="$1"
3200
- ;;
3201
- --as=*)
3202
- as="${arg#--as=}"
3203
- [ -n "$as" ] || { echo "aiur: executor-fast-forward --as requires a consumer id" >&2; exit 64; }
3204
- ;;
3205
- *) echo "aiur: executor-fast-forward accepts <wake-id> and --as <id>" >&2; exit 64 ;;
3206
- esac
3207
- shift
3208
- done
3209
-
3210
- executor_validate_consumer_id "$as" "executor-fast-forward"
3211
- run_control_rpc "Aiur.AgentControlCLI.executor_fast_forward($wake_id, [$(executor_as_keyword "$as")])"
3212
- }
3213
-
3214
- cmd_executor_claim() {
3215
- local as="" arg
3216
- while [ "$#" -gt 0 ]; do
3217
- arg="$1"
3218
- case "$arg" in
3219
- --as) shift; as="${1:-}" ;;
3220
- --as=*) as="${arg#--as=}" ;;
3221
- *) echo "aiur: executor-claim accepts only --as <id>" >&2; exit 64 ;;
3222
- esac
3223
- shift
3224
- done
3225
- executor_validate_consumer_id "$as" "executor-claim"
3226
- run_control_rpc "Aiur.AgentControlCLI.executor_claim([$(executor_as_keyword "$as")])"
3227
- }
3228
-
3229
- cmd_executor_release() {
3230
- local as="" arg
3231
- while [ "$#" -gt 0 ]; do
3232
- arg="$1"
3233
- case "$arg" in
3234
- --as) shift; as="${1:-}" ;;
3235
- --as=*) as="${arg#--as=}" ;;
3236
- *) echo "aiur: executor-release accepts only --as <id>" >&2; exit 64 ;;
3237
- esac
3238
- shift
3239
- done
3240
- executor_validate_consumer_id "$as" "executor-release"
3241
- run_control_rpc "Aiur.AgentControlCLI.executor_release([$(executor_as_keyword "$as")])"
3242
- }
3243
-
3244
- cmd_executor_revoke() {
3245
- local owner="${1:-}"
3246
- [ -n "$owner" ] || { echo "aiur: executor-revoke requires the current owner's consumer id" >&2; exit 64; }
3247
- shift
3248
- [ "$#" -eq 0 ] || { echo "aiur: executor-revoke accepts only <consumer-id>" >&2; exit 64; }
3249
- executor_validate_consumer_id "$owner" "executor-revoke"
3250
- run_control_rpc "Aiur.AgentControlCLI.executor_revoke(\"$owner\")"
3251
- }
3252
-
3253
- cmd_executor_emit() {
3254
- local topic="${1:-}" payload="" arg
3255
- shift 2>/dev/null || true
3256
- [ -n "$topic" ] || { echo "aiur: executor-emit requires a topic" >&2; exit 64; }
3257
- while [ "$#" -gt 0 ]; do
3258
- arg="$1"
3259
- case "$arg" in
3260
- --payload) shift; payload="${1:-}" ;;
3261
- --payload=*) payload="${arg#--payload=}" ;;
3262
- *) echo "aiur: executor-emit accepts only --payload <json>" >&2; exit 64 ;;
3263
- esac
3264
- shift
3265
- done
3266
- [ -n "$payload" ] || { echo "aiur: executor-emit requires --payload <json>" >&2; exit 64; }
3267
- local topic_encoded payload_encoded
3268
- topic_encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
3269
- payload_encoded="$(printf '%s' "$payload" | base64 | tr -d '\n')"
3270
- run_control_rpc "Aiur.AgentControlCLI.executor_emit(Base.decode64!(\"$topic_encoded\"), Base.decode64!(\"$payload_encoded\"))"
3271
- }
3272
-
3273
- cmd_executor_subscription() {
3274
- local action="$1" topic="${2:-}"
3275
- [ -n "$topic" ] && [ "$#" -eq 2 ] || { echo "aiur: $action requires one topic pattern" >&2; exit 64; }
3276
- local encoded
3277
- encoded="$(printf '%s' "$topic" | base64 | tr -d '\n')"
3278
- case "$action" in
3279
- executor-subscribe) run_control_rpc "Aiur.AgentControlCLI.executor_subscribe(Base.decode64!(\"$encoded\"))" ;;
3280
- executor-unsubscribe) run_control_rpc "Aiur.AgentControlCLI.executor_unsubscribe(Base.decode64!(\"$encoded\"))" ;;
3281
- esac
3282
- }
3283
-
3284
- cmd_executor_subscriptions() {
3285
- [ "$#" -eq 0 ] || { echo "aiur: executor-subscriptions does not accept arguments" >&2; exit 64; }
3286
- run_control_rpc "Aiur.AgentControlCLI.executor_subscriptions()"
3287
- }
3288
-
3289
- watch_validate_interval() {
3290
- if ! [[ "$1" =~ ^[0-9]+$ ]] || [ "$1" -le 0 ]; then
3291
- echo "aiur: watch --interval expects a positive integer (seconds)" >&2
3292
- exit 64
3293
- fi
3294
- }
3295
-
3296
- cmd_cleanup_stale() {
3297
- local dry_run=0 arg
3298
- for arg in "$@"; do
3299
- case "$arg" in
3300
- --dry-run) dry_run=1 ;;
3301
- *)
3302
- echo "aiur: cleanup-stale only accepts --dry-run" >&2
3303
- exit 64
3304
- ;;
3305
- esac
3306
- done
3307
-
3308
- aiur_resolve_identity
3309
- if [ "$dry_run" -eq 1 ]; then
3310
- report_stale_manual_smoke || true
3311
- else
3312
- report_stale_manual_smoke || true
3313
- reap_stale_manual_smoke 1
3314
- fi
3315
- }
3316
-
3317
- # `aiur set <key> <value>` — runtime config overrides without editing
3318
- # `.aiur/config`. Currently: `aiur set max-agents N`.
3319
- cmd_set() {
3320
- local key="${1:-}"
3321
- shift 2>/dev/null || true
3322
-
3323
- case "$key" in
3324
- max-agents)
3325
- local n="${1:-}"
3326
- if [ -z "$n" ] || [[ ! "$n" =~ ^[0-9]+$ ]] || [ "$n" -lt 1 ]; then
3327
- echo "aiur: set max-agents expects a positive integer (e.g. aiur set max-agents 5)" >&2
3328
- exit 64
3329
- fi
3330
- run_control_rpc "Aiur.AgentControlCLI.set_max_agents($n)"
3331
- ;;
3332
- *)
3333
- echo "aiur: unknown setting: ${key:-(none)} (supported: max-agents)" >&2
3334
- exit 64
3335
- ;;
3336
- esac
3337
- }
3338
-
3339
- sweep_dead_tmux_sockets() {
3340
- local tmux_bin
3341
- tmux_bin="$(command -v tmux || true)"
3342
- [ -n "$tmux_bin" ] || return 0
3343
-
3344
- local sockdir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
3345
- [ -d "$sockdir" ] || return 0
3346
-
3347
- local removed=0 name path
3348
- for path in "$sockdir"/*; do
3349
- [ -S "$path" ] || continue
3350
- name="${path##*/}"
3351
- case "$name" in
3352
- aiur* | *-driver | *-driver-*) ;;
3353
- *) continue ;;
3354
- esac
3355
- if ! "$tmux_bin" -L "$name" list-sessions >/dev/null 2>&1; then
3356
- rm -f "$path" 2>/dev/null && removed=$((removed + 1))
3357
- fi
3358
- done
3359
-
3360
- [ "$removed" -gt 0 ] && echo "🧹 swept $removed dead aiur tmux socket(s)" >&2
3361
- return 0
3362
- }
3363
-
3364
- # Reap stale aiur /tmp debris so a tmpfs /tmp can't fill from accumulated per-run
3365
- # tempfiles and leaked test artifacts. Runs alongside sweep_dead_tmux_sockets on
3366
- # foreground teardown (session_cleanup) and on `aiur stop`. Bounded and safe:
3367
- #
3368
- # * Only exact top-level Aiur artifact families under the temp roots the engine
3369
- # and BEAM write to. Arbitrary Executor worktrees/checkouts like
3370
- # `/tmp/aiur-pr490` are not candidates.
3371
- # * Age-gated by AIUR_TMP_REAP_MINUTES (default 1440 = 24h). An entry is removed
3372
- # only when NOTHING in its subtree was modified within the window, so a live
3373
- # run's shared debug dir (aiur-rc / aiur-claude-hooks / aiur-debug) holding a
3374
- # fresh file is spared even when the dir's own mtime is stale. A non-numeric or
3375
- # 0 value disables the sweep.
3376
- # * Ownership-gated: if anything in the tree is not owned by the effective user,
3377
- # the whole tree is spared.
3378
- # * A live run's pid-named session, agent, crash-ledger, and dump-baseline
3379
- # handoffs are kept while its launcher pid is alive, regardless of age.
3380
- is_aiur_tmp_artifact_candidate() {
3381
- local base="$1" pid
3382
- case "$base" in
3383
- aiur-argv.* | aiur-startup.* | aiur-pane.* | aiur-launcher.* | aiur-capture.* | \
3384
- aiur-trap.*.log | aiur-tree.*.json | aiur-wrapper.pid | aiur-*-frames.bin | \
3385
- aiur-rc | aiur-claude-hooks | aiur-debug)
3386
- return 0
3387
- ;;
3388
- aiur-*-sessions | aiur-*-agents | aiur-*-alert-ledger | aiur-*-crash-dump-baseline)
3389
- pid="${base#aiur-}"
3390
- pid="${pid%-sessions}"
3391
- pid="${pid%-agents}"
3392
- pid="${pid%-alert-ledger}"
3393
- pid="${pid%-crash-dump-baseline}"
3394
- [ -n "$pid" ] && [ -z "${pid//[0-9]/}" ]
3395
- return
3396
- ;;
3397
- *)
3398
- return 1
3399
- ;;
3400
- esac
3401
- }
3402
-
3403
- sweep_stale_tmp_artifacts() {
3404
- local minutes="${AIUR_TMP_REAP_MINUTES:-1440}"
3405
- case "$minutes" in '' | *[!0-9]*) return 0 ;; esac
3406
- [ "$minutes" -gt 0 ] || return 0
3407
-
3408
- # The dirs the engine + BEAM target: ${TMPDIR:-/tmp} (argv/startup/pane tempfiles
3409
- # plus the BEAM's System.tmp_dir debug dirs) and ${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}
3410
- # (the session/agent pidfiles' session_root). Deduped — a typical box scans one dir.
3411
- local roots=() d seen
3412
- for d in "${TMPDIR:-/tmp}" "${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"; do
3413
- [ -d "$d" ] || continue
3414
- seen=0
3415
- local root
3416
- if [ "${#roots[@]}" -gt 0 ]; then
3417
- for root in "${roots[@]}"; do
3418
- [ "$root" = "$d" ] && { seen=1; break; }
3419
- done
3420
- fi
3421
- [ "$seen" -eq 1 ] || roots+=("$d")
3422
- done
3423
- [ "${#roots[@]}" -gt 0 ] || return 0
3424
-
3425
- local removed=0 path base pid uid found
3426
- uid="$(id -u)"
3427
- for d in "${roots[@]}"; do
3428
- while IFS= read -r -d '' path; do
3429
- base="${path##*/}"
3430
- is_aiur_tmp_artifact_candidate "$base" || continue
3431
-
3432
- # Spare mixed-ownership trees; cleanup should never cross user boundaries.
3433
- if ! found="$(find "$path" ! -user "$uid" -print -quit 2>/dev/null)"; then
3434
- continue
3435
- fi
3436
- if [ -n "$found" ]; then
3437
- continue
3438
- fi
3439
- # Spare anything modified within the window anywhere in its subtree.
3440
- if ! found="$(find "$path" -mmin "-$minutes" -print -quit 2>/dev/null)"; then
3441
- continue
3442
- fi
3443
- if [ -n "$found" ]; then
3444
- continue
3445
- fi
3446
- # Spare a live run's pid-named bookkeeping and crash handoffs.
3447
- case "$base" in
3448
- aiur-*-sessions | aiur-*-agents | aiur-*-alert-ledger | aiur-*-crash-dump-baseline)
3449
- pid="${base#aiur-}"
3450
- pid="${pid%-sessions}"
3451
- pid="${pid%-agents}"
3452
- pid="${pid%-alert-ledger}"
3453
- pid="${pid%-crash-dump-baseline}"
3454
- if [ -n "$pid" ] && [ -z "${pid//[0-9]/}" ] && kill -0 "$pid" 2>/dev/null; then
3455
- continue
3456
- fi
3457
- ;;
3458
- esac
3459
- if rm -rf -- "$path" 2>/dev/null; then
3460
- removed=$((removed + 1))
3461
- fi
3462
- done < <(find "$d" -maxdepth 1 -mindepth 1 -name 'aiur-*' -print0 2>/dev/null)
3463
- done
3464
-
3465
- [ "$removed" -gt 0 ] && echo "🧹 swept $removed stale aiur tmp artifact(s)" >&2
3466
- return 0
3467
- }
3468
-
3469
- # A stop only reaps the daemon whose node name matches this project root's
3470
- # instance key (keys are sha256(project_root), so instances can't reap each
3471
- # other). A daemon launched from a different directory therefore survives a stop
3472
- # invoked elsewhere — the silent orphan that keeps holding the dashboard port and
3473
- # serving stale code/credentials. Surface it loudly (we warn, not reap, to
3474
- # respect the deliberate isolation) so the operator can stop it explicitly.
3475
- warn_other_aiur_daemons() {
3476
- local self_node="$1" me pids pid cmd node port found=0
3477
- me="${USER:-$(id -un 2>/dev/null)}"
3478
- pids="$(pgrep -u "$me" -f -- "-name aiur-${me}" 2>/dev/null || true)"
3479
- for pid in $pids; do
3480
- cmd="$(pid_command "$pid")"
3481
- case "$cmd" in *beam.smp*) : ;; *) continue ;; esac
3482
- node="$(printf '%s' "$cmd" | grep -oE -- '-name [^ ]+' | awk '{print $2}' | head -1)"
3483
- [ -z "$node" ] && continue
3484
- [ "$node" = "$self_node" ] && continue
3485
- port="$(ss -tlnpH 2>/dev/null | awk -v p="pid=$pid," '$0 ~ p {print $4}' \
3486
- | grep -oE '[0-9]+$' | head -1 || true)"
3487
- if [ "$found" -eq 0 ]; then
3488
- echo "aiur: heads up — another aiur daemon is still running that this stop did not touch" >&2
3489
- echo " (launched from a different directory, so it has a different instance key):" >&2
3490
- found=1
3491
- fi
3492
- echo " pid=$pid node=$node${port:+ dashboard-port=$port} — stop it with: kill $pid" >&2
3493
- done
3494
- }
3495
-
3496
- # Set when cmd_stop returns nonzero only because there was nothing to stop. It
3497
- # lets a caller (cmd_restart) tell that benign outcome apart from a stop that
3498
- # failed for a real reason, without changing `aiur stop`'s own exit code.
3499
- AIUR_STOP_FOUND_NOTHING=0
3500
-
3501
- # Stop the running session: kill this instance's tmux + BEAM, then sweep.
3502
- cmd_stop() {
3503
- AIUR_STOP_FOUND_NOTHING=0
3504
- resolve_release
3505
- aiur_resolve_identity
3506
- RELEASE_NODE="$AIUR_RELEASE_NODE"
3507
- ERL_EPMD_ADDRESS="${ERL_EPMD_ADDRESS:-127.0.0.1}"
3508
- export RELEASE_NODE ERL_EPMD_ADDRESS
3509
- resolve_control_identity_from_records
3510
-
3511
- local workspace_root_file agent_pidfile
3512
- workspace_root_file="$(workspace_root_file_from_instance_record 2>/dev/null || true)"
3513
- agent_pidfile="$(agent_pidfile_from_instance_record 2>/dev/null || true)"
3514
-
3515
- local tmux_bin
3516
- tmux_bin="$(command -v tmux || true)"
3517
- local session="${AIUR_ADOPTED_TMUX_SESSION:-${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}-default}"
3518
- local socket="${AIUR_ADOPTED_TMUX_SOCKET:-${AIUR_SESSION_PREFIX}-${USER:-user}${AIUR_INSTANCE_KEY:+-$AIUR_INSTANCE_KEY}}"
3519
-
3520
- local has_session=0
3521
- if [ -n "$tmux_bin" ] && "$tmux_bin" -L "$socket" has-session -t "$session" 2>/dev/null; then
3522
- has_session=1
3523
- fi
3524
-
3525
- if [ "${AIUR_CONTROL_ADOPTED_RECORD:-0}" -ne 1 ] && \
3526
- [ "${AIUR_CONTROL_CURRENT_NODE_STATE:-}" = "down" ] && \
3527
- [ "${AIUR_PROJECT_ROOT_SOURCE:-}" = "cwd" ] && \
3528
- [ "$has_session" -eq 0 ] && \
3529
- [ ! -f "$(aiur_crash_marker_path)" ]; then
3530
- echo "aiur: no running aiur node at ${AIUR_RELEASE_NODE}; nothing stopped" >&2
3531
- warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
3532
- print_global_config_control_hint
3533
- AIUR_STOP_FOUND_NOTHING=1
3534
- return 1
3535
- fi
3536
-
3537
- # Tell the background BEAM-death watchdog this exit is intentional before we
3538
- # kill the BEAM, so it consumes the sentinel instead of recording a crash. The
3539
- # watchdog removes the sentinel when it fires; a fresh start also clears it.
3540
- # Clear any prior crash marker too — `status` should report a clean stop, not
3541
- # a stale orphan from an earlier dead run.
3542
- mkdir -p "$AIUR_BG_STATE_DIR" 2>/dev/null || true
3543
- : >"$(aiur_stop_sentinel_path)" 2>/dev/null || true
3544
- rm -f "$(aiur_crash_marker_path)" 2>/dev/null || true
3545
-
3546
- if [ -n "$tmux_bin" ]; then
3547
- "$tmux_bin" -L "$socket" kill-session -t "$session" 2>/dev/null || true
3548
- fi
3549
-
3550
- # Reap any BEAM holding our node name regardless of which release dir launched
3551
- # it. Do not sweep by release dir: sibling instances share dev releases. Use
3552
- # the generous stop TERM grace: the BEAM, once TERM'd, runs its own graceful
3553
- # shutdown (ProcessReaper reaping the agent tree, opencode session deletes,
3554
- # then the BEAM-side workspace sweep) and the short 3s startup default would
3555
- # SIGKILL it mid-cleanup, orphaning exactly the agents this stop must reap.
3556
- # The loop exits the moment the BEAM is gone, so a fast stop costs nothing.
3557
- kill_beams_matching "-name ${AIUR_RELEASE_NODE}" 300
3558
-
3559
- # Final cwd-sweep backstop after the BEAM is dead. The launcher's instance
3560
- # record points to the BEAM-written root handoff, so degraded stop never has
3561
- # to wait for an overloaded daemon before it starts tearing workers down.
3562
- reap_workspace_cwd_from_file "$workspace_root_file"
3563
-
3564
- # The BEAM (alive until the TERM above) reaped its own headless agents through
3565
- # ProcessReaper on Application.stop. kill-server is the guarantee the earlier
3566
- # kill-session can't give for mid-turn agents: every pane agent across all
3567
- # windows dies and no live aiur tmux server is left behind.
3568
- if [ -n "$tmux_bin" ]; then
3569
- "$tmux_bin" -L "$socket" kill-server 2>/dev/null || true
3570
- fi
3571
-
3572
- # Belt-and-suspenders for this run's headless agents after the BEAM exits.
3573
- # The instance record owns its pidfile; a global sweep can kill live agents
3574
- # belonging to another instance that shares the runtime directory.
3575
- if [ -n "$agent_pidfile" ] && [ -e "$agent_pidfile" ]; then
3576
- reap_aiur_agents "" "$agent_pidfile"
3577
- rm -f "$agent_pidfile" 2>/dev/null || true
3578
- fi
3579
-
3580
- reap_stale_manual_smoke 0
3581
- sweep_dead_tmux_sockets
3582
- sweep_stale_tmp_artifacts
3583
- rm -f "$(aiur_instance_record_path)" 2>/dev/null || true
3584
- warn_other_aiur_daemons "$AIUR_RELEASE_NODE"
3585
- }
3586
-
3587
- # Stop, refresh the release, start again — `aiur restart`.
3588
- #
3589
- # The refresh deliberately runs BETWEEN the stop and the start. A dev rebuild
3590
- # rewrites the release directory in place, and a BEAM booted from that directory
3591
- # is exactly what the control-retry path (EX_TEMPFAIL) exists to survive; doing
3592
- # it while the daemon is down means the new daemon boots from a release nothing
3593
- # else is holding half-written.
3594
- #
3595
- # The engine itself never knows how to build: the installed CLI runs a pinned
3596
- # platform release with no source tree beside it, so its restart is a plain
3597
- # bounce. The dev shim supplies its build-if-stale step through
3598
- # AIUR_RESTART_BUILD_CMD, a shell command line run via `bash -c` so the wrapper
3599
- # (not this parser) owns quoting.
3600
- aiur_restart_daemon_state="untouched"
3601
-
3602
- # Everything between the stop and a confirmed start runs with this armed, so the
3603
- # operator hears about a fleet that is down no matter which step failed — a
3604
- # rebuild, a missing tmux, a BEAM that would not boot, or a Ctrl-C during the
3605
- # minutes-long rebuild. Naming only the step that failed would let "aiur failed
3606
- # to start" read as "nothing changed", which is the silently stopped fleet.
3607
- aiur_restart_report_state() {
3608
- [ "$aiur_restart_daemon_state" = "stopped" ] || return 0
3609
-
3610
- # release_dir is resolved by the stop; fall back to the caller's declared dir
3611
- # for the paths that fail before that.
3612
- local dir="${release_dir:-${AIUR_RELEASE_DIR:-}}"
3613
-
3614
- echo "aiur: the daemon is STOPPED and was NOT restarted" >&2
3615
- if [ -n "$dir" ] && [ -x "$dir/bin/aiur" ]; then
3616
- echo "aiur: fix the cause and run 'aiur restart' again, or 'aiur restart --no-build' to start" >&2
3617
- echo " on the release already on disk" >&2
23
+ set -euo pipefail
24
+ # Raise the soft open-file limit toward the hard maximum. High agent concurrency
25
+ # spawns many tmux/opencode/git subprocesses + sockets; on hosts with a low
26
+ # default (macOS ships 256) that exhausts file descriptors (:emfile) and crashes
27
+ # the node. Soft<=hard needs no privilege; best-effort, never fatal.
28
+ __aiur_hard_nofile="$(ulimit -Hn 2>/dev/null || echo)"
29
+ if [ "${__aiur_hard_nofile}" = "unlimited" ]; then
30
+ ulimit -Sn 65536 2>/dev/null || true
31
+ elif [ -n "${__aiur_hard_nofile}" ]; then
32
+ ulimit -Sn "${__aiur_hard_nofile}" 2>/dev/null || true
33
+ fi
34
+ unset __aiur_hard_nofile
35
+ # Export the effective soft limit after the best-effort raise. The BEAM uses
36
+ # this inherited value for FD-headroom admission on hosts without procfs,
37
+ # avoiding a runtime `ulimit` subprocess precisely when descriptors are scarce.
38
+ __aiur_soft_nofile="$(ulimit -Sn 2>/dev/null || echo)"
39
+ if [[ "$__aiur_soft_nofile" =~ ^[0-9]+$ ]]; then
40
+ export AIUR_NOFILE_SOFT_LIMIT="$__aiur_soft_nofile"
41
+ else
42
+ unset AIUR_NOFILE_SOFT_LIMIT
43
+ fi
44
+ unset __aiur_soft_nofile
45
+ # Preserve the shell that initiated the run as a best-effort Executor root.
46
+ # An explicit positive override wins (service managers may know a better root);
47
+ # otherwise the engine's parent is the nearest identity available before tmux
48
+ # hands daemon ownership to its pane launcher.
49
+ if ! [[ "${AIUR_OPERATOR_PID:-}" =~ ^[1-9][0-9]*$ ]]; then
50
+ if [[ "${PPID:-}" =~ ^[1-9][0-9]*$ ]]; then
51
+ export AIUR_OPERATOR_PID="$PPID"
3618
52
  else
3619
- # The dev builder removes an incomplete release, so after a failed rebuild
3620
- # there is nothing for --no-build to start. Advertising it anyway would cost
3621
- # the operator a second failed cycle while the fleet stays down.
3622
- echo "aiur: no complete release is left on disk, so --no-build has nothing to start —" >&2
3623
- echo " fix the build and run 'aiur restart' again" >&2
3624
- fi
3625
- }
3626
-
3627
- # The rebuild runs in a process this command cannot see into, so "the builder
3628
- # exited 0" is not the same claim as "the release I am about to boot is the one
3629
- # it just built" — a wrapper pointed at another checkout satisfies the first and
3630
- # not the second, which is exactly how a bounce ships a stale release while every
3631
- # step reports success. The builder leaves a receipt naming the release dir it
3632
- # vouches for and the commit it built from; this checks both against the release
3633
- # on disk. Anything unverifiable is reported as unverifiable, never as success.
3634
- verify_restart_build() {
3635
- local receipt="$1"
3636
- local built_dir built_sha stamped_sha stamp target_dir
3637
-
3638
- if [ ! -s "$receipt" ]; then
3639
- echo "aiur: the rebuild left no build receipt, so restart cannot confirm which" >&2
3640
- echo " release it produced" >&2
3641
- return 1
3642
- fi
3643
-
3644
- built_dir="$(sed -n 's/^release_dir=//p' "$receipt" | head -n 1)"
3645
- built_sha="$(sed -n 's/^source_sha=//p' "$receipt" | head -n 1)"
3646
-
3647
- # Fail on the missing field rather than on whatever an empty one happens to
3648
- # compare equal to further down.
3649
- if [ -z "$built_dir" ] || [ -z "$built_sha" ]; then
3650
- echo "aiur: the build receipt is malformed; restart cannot confirm what was built" >&2
3651
- return 1
3652
- fi
3653
-
3654
- # Compare canonical paths: the builder and this engine can reach the same
3655
- # release through different symlinked parents.
3656
- built_dir="$(cd -P "$built_dir" 2>/dev/null && pwd || printf '%s' "$built_dir")"
3657
- target_dir="$(cd -P "${AIUR_RELEASE_DIR:-}" 2>/dev/null && pwd || printf '%s' "${AIUR_RELEASE_DIR:-}")"
3658
-
3659
- if [ "$built_dir" != "$target_dir" ]; then
3660
- echo "aiur: the rebuild targeted a different release than the one about to boot" >&2
3661
- echo " rebuilt : $built_dir" >&2
3662
- echo " booting : $target_dir" >&2
3663
- return 1
3664
- fi
3665
-
3666
- stamp="$target_dir/AIUR_BUILD_STAMP"
3667
- if [ ! -r "$stamp" ]; then
3668
- echo "aiur: the release carries no build stamp, so restart cannot confirm it is" >&2
3669
- echo " the one just built ($stamp)" >&2
3670
- return 1
3671
- fi
3672
-
3673
- stamped_sha="$(sed -n 's/^source_sha=//p' "$stamp" | head -n 1)"
3674
- if [ "$stamped_sha" != "$built_sha" ]; then
3675
- echo "aiur: the release on disk was not built from the commit the rebuild reported" >&2
3676
- echo " rebuild reported : $built_sha" >&2
3677
- echo " release stamped : $stamped_sha" >&2
3678
- return 1
3679
- fi
3680
-
3681
- # Say what was actually established. "verified ... built from unknown" would be
3682
- # a silent-pass guard reporting success over a release it cannot identify, and
3683
- # a dirty tree means the SHA is a floor rather than an identity.
3684
- if [ "$stamped_sha" = "unknown" ]; then
3685
- echo "aiur: release $target_dir matches the rebuild, but its source commit is" >&2
3686
- echo " unknown (built outside a git work tree) — provenance unverified" >&2
3687
- return 0
3688
- fi
3689
-
3690
- if [ "$(sed -n 's/^dirty=//p' "$stamp" | head -n 1)" = "yes" ]; then
3691
- echo "aiur: release $target_dir built from $stamped_sha with uncommitted changes" >&2
3692
- return 0
3693
- fi
3694
-
3695
- echo "aiur: verified release $target_dir built from $stamped_sha" >&2
3696
- }
3697
-
3698
- cmd_restart() {
3699
- local skip_build=0 arg
3700
- local run_args=()
3701
-
3702
- # Refuse a stale config before stopping a healthy daemon. dispatch_run also
3703
- # checks this for ordinary launches, but restart mutates state before it gets
3704
- # that far.
3705
- reject_legacy_config "$@"
3706
-
3707
- for arg in "$@"; do
3708
- case "$arg" in
3709
- --no-build) skip_build=1 ;;
3710
- *) run_args+=("$arg") ;;
3711
- esac
3712
- done
3713
-
3714
- # Restarting something that is already down is a start, not an error: the
3715
- # operator asked for a running daemon on current code, and that is what they
3716
- # get. Any OTHER stop failure is fatal — building and starting on top of a
3717
- # half-stopped instance is the in-place rewrite this ordering exists to avoid.
3718
- local stop_status=0
3719
- cmd_stop || stop_status=$?
3720
- if [ "$stop_status" -ne 0 ]; then
3721
- if [ "${AIUR_STOP_FOUND_NOTHING:-0}" -eq 1 ]; then
3722
- echo "aiur: nothing was running; restart will start a fresh session" >&2
3723
- else
3724
- die "restart aborted: stop failed (exit $stop_status); the daemon may still be running"
3725
- fi
3726
- fi
3727
-
3728
- # cmd_stop's tmux teardown is best-effort (`|| true`), so a stop can report
3729
- # success while the session survives. Starting from there would rebuild the
3730
- # release under a live BEAM and then hit run_session's idempotent "already
3731
- # running" return — exit 0, stale release, no bounce. Refuse instead.
3732
- # release_bin is unset only when the stop was stubbed out, i.e. in tests.
3733
- if [ -n "${release_bin:-}" ] && [ "$(probe_control_liveness)" = "up" ]; then
3734
- die "restart aborted: the previous session still answers after the stop; nothing was rebuilt or restarted"
3735
- fi
3736
-
3737
- aiur_restart_daemon_state="stopped"
3738
- trap aiur_restart_report_state EXIT INT TERM
3739
-
3740
- if [ -n "${AIUR_RESTART_BUILD_CMD:-}" ] && [ "$skip_build" -eq 0 ]; then
3741
- local build_status=0 receipt
3742
- receipt="$(mktemp "${TMPDIR:-/tmp}/aiur-restart-receipt.XXXXXX")"
3743
- AIUR_RESTART_BUILD_RECEIPT="$receipt" bash -c "$AIUR_RESTART_BUILD_CMD" || build_status=$?
3744
- if [ "$build_status" -ne 0 ]; then
3745
- # Exit with the builder's own status rather than a flat 1, so a caller can
3746
- # still tell a failed rebuild from any other abort.
3747
- rm -f "$receipt"
3748
- echo "❌ restart aborted before start: the rebuild failed" >&2
3749
- aiur_restart_report_state
3750
- trap - EXIT INT TERM
3751
- exit "$build_status"
3752
- fi
3753
-
3754
- # A builder that declares AIUR_RESTART_BUILD_VERIFIES has promised a receipt,
3755
- # so a missing or contradicted one is a real failure and an unverifiable
3756
- # rebuild is not a lesser success — starting anyway would hand back the
3757
- # stale-release outcome this ordering exists to prevent, with a "restarted"
3758
- # line on top of it. A builder that makes no such promise cannot be held to
3759
- # it; say plainly that the guarantee did not apply rather than convert an
3760
- # unknown wrapper into a stopped fleet.
3761
- if [ "${AIUR_RESTART_BUILD_VERIFIES:-0}" = "1" ]; then
3762
- local verify_status=0
3763
- verify_restart_build "$receipt" || verify_status=$?
3764
- rm -f "$receipt"
3765
- if [ "$verify_status" -ne 0 ]; then
3766
- echo "❌ restart aborted before start: the rebuild could not be verified" >&2
3767
- # Naming the builder makes an inherited AIUR_RESTART_BUILD_CMD — a dev
3768
- # shim leaking into another shell — diagnosable from this output alone,
3769
- # instead of an identical refusal on every retry.
3770
- echo " rebuild command: ${AIUR_RESTART_BUILD_CMD}" >&2
3771
- echo " Rebuild from the checkout you mean and retry, or 'aiur restart --no-build'" >&2
3772
- echo " to start the release on disk without the guarantee." >&2
3773
- aiur_restart_report_state
3774
- trap - EXIT INT TERM
3775
- exit 70
3776
- fi
3777
- else
3778
- rm -f "$receipt"
3779
- echo "aiur: UNVERIFIED rebuild — this builder reports no receipt, so restart" >&2
3780
- echo " cannot confirm the release it is about to boot is the one just built" >&2
3781
- fi
3782
- elif [ "$skip_build" -eq 1 ]; then
3783
- echo "aiur: --no-build — restarting on the release already on disk" >&2
3784
- fi
3785
-
3786
- # Always detached: the daemon this stopped was itself detached, and a restart
3787
- # that captured the terminal would be a surprise. `--interactive` still opts
3788
- # into the attachable session (`aiur --bg --interactive`), and a purely
3789
- # foreground restart remains `aiur stop && aiur run`.
3790
- dispatch_run --bg "${run_args[@]+"${run_args[@]}"}"
3791
-
3792
- aiur_restart_daemon_state="running"
3793
- trap - EXIT INT TERM
3794
- }
3795
-
3796
- # --- upgrade version notice + aiur upgrade -----------------------------------
3797
-
3798
- # Extract a top-level string field from a JSON document. The notice keys we
3799
- # read (`text`, `available`, `channel`, `channel_gone`) are unique to the
3800
- # notice, and the dist-tags keys we read are the tag names themselves, so a
3801
- # plain sed over the document is unambiguous. Handles both quoted strings
3802
- # (`"channel":"nightly"`) and unquoted scalars (`"channel_gone":true`,
3803
- # `"available":null`) — the state file serializes booleans/null without quotes.
3804
- upgrade_state_string() {
3805
- local file="$1" key="$2"
3806
- sed -n "s/.*\"$key\": *\"\([^\"]*\)\".*/\1/p; s/.*\"$key\": *\([^,}]*\).*/\1/p" "$file" | head -n 1
3807
- }
3808
-
3809
- # Atomically write the "last told" marker (a single line). The engine owns this
3810
- # file; the daemon only reads it for its no-repeat decision. Best-effort and
3811
- # always succeeds (returns 0) so a read-only/unwritable state dir can never
3812
- # abort the caller — the notice display and `aiur upgrade` must not fail
3813
- # because a marker could not be written.
3814
- upgrade_state_mark_notified() {
3815
- local value="$1" file="$AIUR_BG_STATE_DIR/upgrade-notified.txt" tmp
3816
- mkdir -p "$AIUR_BG_STATE_DIR" 2>/dev/null || return 0
3817
- tmp="$(mktemp "$AIUR_BG_STATE_DIR/upgrade-notified.XXXXXX" 2>/dev/null)" || return 0
3818
- printf '%s\n' "$value" >"$tmp" 2>/dev/null
3819
- mv -f "$tmp" "$file" 2>/dev/null
3820
- rm -f "$tmp" 2>/dev/null
3821
- return 0
3822
- }
3823
-
3824
- # Surface the pending upgrade notice, if any, to the operator's terminal. This
3825
- # is a cheap local state read (the daemon's `Aiur.Upgrade` check writes it
3826
- # during boot) — never the registry — so it can never delay startup. It honors
3827
- # the same opt-outs as the check, prints only once per version (the `notified`
3828
- # marker), and is completely silent under a development launcher.
3829
- maybe_surface_upgrade_notice() {
3830
- aiur_resolve_identity
3831
-
3832
- # A development launcher (aiurdev) must never suggest an upgrade: the notice
3833
- # would be wrong (the local tree is routinely ahead of every published
3834
- # version) and acting on it would overwrite a working tree. Detect the
3835
- # launcher, never the directory — an installed aiur can be run from inside a
3836
- # clone and still deserves its notice.
3837
- case "${AIUR_RELEASE_DIR:-}" in
3838
- */src/_build/dev/rel/aiur) return 0 ;;
3839
- esac
3840
-
3841
- # Env opt-outs, mirroring Aiur.Upgrade.disabled?/0.
3842
- case "${AIUR_UPGRADE_CHECK_DISABLED:-}" in 1 | true) return 0 ;; esac
3843
- case "${AIUR_NO_UPDATE_NOTIFIER:-}" in 1 | true) return 0 ;; esac
3844
- case "${CI:-}" in "" | false | 0) ;; *) return 0 ;; esac
3845
-
3846
- # Interactive gate: never print into a piped or non-TTY stream.
3847
- [ -t 2 ] || return 0
3848
-
3849
- maybe_print_upgrade_notice
3850
- }
3851
-
3852
- # The display half of the notice: read the daemon-written state, decide whether
3853
- # the operator has already been told this version, print it to stderr, and mark
3854
- # it told. Split from `maybe_surface_upgrade_notice` (the env/CI/dev/TTY gates)
3855
- # so the print/decide logic is testable without a pty.
3856
- maybe_print_upgrade_notice() {
3857
- local state_file="$AIUR_BG_STATE_DIR/upgrade.json"
3858
- [ -r "$state_file" ] || return 0
3859
-
3860
- local text available channel channel_gone notified installed
3861
- text="$(upgrade_state_string "$state_file" text)"
3862
- [ -n "$text" ] || return 0
3863
- available="$(upgrade_state_string "$state_file" available)"
3864
- channel="$(upgrade_state_string "$state_file" channel)"
3865
- channel_gone="$(upgrade_state_string "$state_file" channel_gone)"
3866
- notified="$(cat "$AIUR_BG_STATE_DIR/upgrade-notified.txt" 2>/dev/null || true)"
3867
- installed="$(cli_package_version || true)"
3868
-
3869
- if [ "$channel_gone" = "true" ]; then
3870
- # A "channel no longer publishes" notice: say it once, never nag.
3871
- [ -n "$channel" ] && [ "$notified" = "gone:${channel}" ] && return 0
3872
- printf '%s\n' "$text" >&2
3873
- upgrade_state_mark_notified "gone:${channel}"
3874
- return 0
3875
- fi
3876
-
3877
- # A normal notice: only when it is genuinely newer than what is installed
3878
- # (never a downgrade) and the operator has not already been told this exact
3879
- # version.
3880
- [ -n "$available" ] || return 0
3881
- [ -n "$installed" ] || return 0
3882
- version_is_older "$installed" "$available" || return 0
3883
- if [ -n "$notified" ] && ! version_is_older "$notified" "$available"; then
3884
- return 0
3885
- fi
3886
-
3887
- printf '%s\n' "$text" >&2
3888
- upgrade_state_mark_notified "$available"
3889
- }
3890
-
3891
- # Detect how this `aiur` was installed from the launcher-resolved release dir
3892
- # and the package layout — never from the working directory, so an installed
3893
- # aiur run from inside a clone is still recognized as an npm install.
3894
- aiur_install_method() {
3895
- case "${AIUR_RELEASE_DIR:-}" in
3896
- */src/_build/dev/rel/aiur)
3897
- printf 'dev'
3898
- return
3899
- ;;
3900
- esac
3901
- if command -v brew >/dev/null 2>&1 && brew list aiur >/dev/null 2>&1; then
3902
- printf 'brew'
3903
- return
3904
- fi
3905
- if [ -r "$engine_dir/../package.json" ]; then
3906
- printf 'npm'
3907
- return
53
+ unset AIUR_OPERATOR_PID
3908
54
  fi
3909
- printf 'unknown'
55
+ fi
56
+ die() {
57
+ echo "❌ $*" >&2
58
+ exit 1
3910
59
  }
3911
60
 
3912
- # `aiur upgrade` — installs the newer aiur-cli on the user's channel and
3913
- # reports what it did. Refuses in a development checkout (nothing to install,
3914
- # tree must stay untouched), refuses Homebrew/unknown installs (aiur releases
3915
- # are npm-only right now), refuses while a daemon is running unless --force,
3916
- # never downgrades across channels, and reports before/after versions with the
3917
- # restart step needed to actually pick the new code up.
3918
- cmd_upgrade() {
3919
- local force=0 arg
3920
- for arg in "$@"; do
3921
- case "$arg" in
3922
- --force) force=1 ;;
3923
- *) echo "aiur: upgrade accepts only --force" >&2; exit 64 ;;
3924
- esac
3925
- done
3926
-
3927
- aiur_resolve_identity
3928
-
3929
- case "$(aiur_install_method)" in
3930
- dev)
3931
- die "aiur upgrade cannot run from a development checkout (aiurdev): the local tree is not a published install, so upgrade would not touch this working tree and nothing has been changed. Install aiur from npm and run \`aiur upgrade\` from the installed CLI."
3932
- ;;
3933
- brew)
3934
- die "aiur upgrade does not support Homebrew installs: aiur releases are npm-only right now (the Homebrew tap is not fed by releases). Reinstall from npm (\`npm install -g aiur-cli\`) and use \`aiur upgrade\` there."
3935
- ;;
3936
- unknown)
3937
- die "aiur could not determine how it was installed, so upgrade refused to guess. Reinstall from npm (\`npm install -g aiur-cli\`) and retry."
3938
- ;;
3939
- esac
3940
-
3941
- resolve_release
3942
- prepare_distribution || die "distribution setup failed; cannot check for a running daemon"
3943
-
3944
- # Do not swap the binary underneath a running daemon or a live fleet.
3945
- if [ "$(probe_control_liveness)" = "up" ]; then
3946
- if [ "$force" -ne 1 ]; then
3947
- echo "❌ aiur is running. \`aiur upgrade\` refuses to replace the binary under a live daemon." >&2
3948
- echo " Stop it first (\`aiur stop\`), upgrade, then start again; or pass --force to upgrade" >&2
3949
- echo " anyway. With --force the running daemon keeps the old code until restarted, and" >&2
3950
- echo " in-flight agents are unaffected until then." >&2
3951
- exit 64
3952
- fi
3953
- echo "⚠️ aiur is running; --force upgrades anyway. The running daemon keeps the old" >&2
3954
- echo " code until you restart it — in-flight agents are unaffected until then." >&2
3955
- fi
3956
-
3957
- local before after channel available tags latest next nightly
3958
- before="$(cli_package_version || true)"
3959
- [ -n "$before" ] || die "aiur could not read the installed version; upgrade aborted"
3960
-
3961
- if ! tags="$(npm view aiur-cli dist-tags --json 2>/dev/null)"; then
3962
- die "aiur could not reach the npm registry to determine available versions (is npm installed and online?); upgrade aborted"
3963
- fi
3964
-
3965
- # Resolve the user's channel from the installed version AND the observed
3966
- # dist-tags, mirroring Aiur.Upgrade.channel/2. A `next` user must never be
3967
- # measured against (or "upgraded" to) `latest` when that is lower — today
3968
- # latest=0.0.3, next=0.0.4, nightly=0.0.5-nightly.<sha>.
3969
- case "$before" in
3970
- *-nightly*)
3971
- channel="nightly"
3972
- ;;
3973
- *)
3974
- next="$(upgrade_state_string <(printf '%s' "$tags") next || true)"
3975
- latest="$(upgrade_state_string <(printf '%s' "$tags") latest || true)"
3976
- if [ -n "$next" ] &&
3977
- { [ "$before" = "$next" ] ||
3978
- { [ -n "$latest" ] && version_is_older "$before" "$next" && version_is_older "$latest" "$before"; }; }; then
3979
- channel="next"
3980
- else
3981
- channel="latest"
3982
- fi
3983
- ;;
3984
- esac
3985
-
3986
- available="$(upgrade_state_string <(printf '%s' "$tags") "$channel" || true)"
3987
- if [ -z "$available" ]; then
3988
- echo "❌ the ${channel} channel no longer publishes new versions (installed ${before})." >&2
3989
- echo " aiur upgrade will not silently fall back to another channel." >&2
3990
- exit 64
3991
- fi
3992
-
3993
- if ! version_is_older "$before" "$available"; then
3994
- echo "aiur is already up to date on the ${channel} channel (${before})." >&2
3995
- return 0
3996
- fi
3997
-
3998
- echo "aiur: upgrading aiur-cli from ${before} to ${available} on the ${channel} channel..." >&2
3999
- if ! npm install -g "aiur-cli@${channel}" --no-offline --no-prefer-offline; then
4000
- die "aiur upgrade failed during \`npm install -g aiur-cli@${channel}\`; the installed version is unchanged"
4001
- fi
4002
-
4003
- after="$(cli_package_version || true)"
4004
- if [ -n "$after" ] && [ "$after" != "$before" ]; then
4005
- echo "aiur upgraded from ${before} to ${after}." >&2
4006
- else
4007
- echo "aiur upgraded on the ${channel} channel (was ${before})." >&2
4008
- fi
61
+ engine_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
62
+ engine_source="${BASH_SOURCE[0]}"
63
+
64
+ # Every other function lives in a sibling module. A missing one fails here, by
65
+ # path, instead of as "command not found" halfway through a run.
66
+ AIUR_ENGINE_MODULES="identity oneshot dotenv run processes reap instance session control commands executor_commands report_commands lifecycle upgrade"
67
+ for __aiur_engine_module in $AIUR_ENGINE_MODULES; do
68
+ [ -f "$engine_dir/engine/$__aiur_engine_module.sh" ] ||
69
+ die "missing engine module: $engine_dir/engine/$__aiur_engine_module.sh"
70
+ source "$engine_dir/engine/$__aiur_engine_module.sh"
71
+ done
72
+ unset __aiur_engine_module
4009
73
 
4010
- # Never re-offer the version just installed.
4011
- upgrade_state_mark_notified "$after"
4012
- echo "Restart any running aiur to pick up the new version." >&2
74
+ usage() {
75
+ cat <<'EOF'
76
+ Usage: aiur [--interactive] [--no-dashboard] [--executor] [--pause] [--max-agents <n>] [--logs-root <path>] [--port <port>] [--host <host>] [config-path]
77
+ aiur run [--bg] [--no-dashboard] [--executor] [--debug] explicit launch form (foreground unless --bg)
78
+ aiur init [--force] scaffold .aiur/config (interactive setup wizard)
79
+ aiur login <harness> <name> [--dir <path>] sign in to a supported backend account
80
+ aiur accounts [<harness>] [--json] list registered accounts
81
+ aiur logout <harness> <name> [--purge] remove a backend account
82
+ aiur --bg [--no-dashboard] [--executor] [--debug] start detached; dashboard on unless suppressed
83
+ aiur stop stop the running session
84
+ aiur restart [--no-build] [run flags] stop, refresh the build, start again (detached)
85
+ aiur status show agent status
86
+ aiur agents show each agent's state + current activity
87
+ aiur commands [<decision-id>] [--filter all|open|blocking|resolved] [--blocking] [--ticket <id>] [--search <text>] [--cursor <cursor>] [--limit <n>] [--json]
88
+ aiur executor-answer <decision-id> --expected-version <n> (--option <id>|--custom-response <text>) --rationale <text> --idempotency-key <key> [--supersede] [--executor-id <id>]
89
+ aiur executor-escalate <decision-id> --expected-version <n> --reason <text> [--executor-id <id>]
90
+ aiur executor-moot <decision-id> --expected-version <n> --reason-class <class> [--reason <text>] [--executor-id <id>]
91
+ aiur units [--scope live|unfinished|all|none] [--condition active|alert|paused|queued|finished]... [--format auto|table|records] [--json]
92
+ aiur queue show [--queue NAME] [--json] read queue; add/remove/reorder/hold/release/recover/clear steer it
93
+ aiur build-orders [<root>] [--json] show the Build Order catalog or one root
94
+ aiur epic set <epic> <ids...> [--as <who>] [--source cli|backfill-agent] [--json]
95
+ aiur epic clear <ids...> [--as <who>] [--json]
96
+ aiur epic show [<ids...>] [--json]
97
+ aiur epic list [--json]
98
+ aiur analytics [--range run|full] [--since <ISO-8601>] [--until <ISO-8601>] [--build-order <id>] [--json]
99
+ aiur github-cost [--budget graphql|core|all] [--format auto|table|records] [--json] rank GitHub API spend by call site
100
+ aiur capabilities [--json] read-only instance capability report
101
+ aiur github-usage [--json] per-actor (daemon vs agent) GitHub usage and ceilings
102
+ aiur alerts [--needs-attention] [--limit <n> | --all] show structured alert feed
103
+ aiur watch [--full|--changes] [--interval <secs>] server-side status board
104
+ aiur listen [--topic <pattern> | --ticket <id>] stream events as JSON lines
105
+ aiur executor-listen [--topic <pattern>] deprecated alias for listen
106
+ aiur executor-wait [--timeout <seconds>] [--json] block until Executor work arrives
107
+ aiur executor-fast-forward <wake-id> [--as <id>] acknowledge an externally covered wake prefix
108
+ aiur executor-emit <topic> --payload <json> publish an Executor event
109
+ aiur executor-subscribe|executor-unsubscribe <pattern>
110
+ aiur executor-subscriptions list persistent Executor bindings
111
+ aiur workspace-recover <ticket-identifier> <generation> release a held workspace after verified provider exit
112
+ aiur executor-roster [--json] list Executor consumers with their liveness evidence
113
+ aiur executor-claim [--as <id>] claim the wake stream, or refuse and name the live owner
114
+ aiur executor-release [--as <id>] give up this consumer's claim
115
+ aiur executor-revoke <consumer-id> operator-only revoke of a live owner's claim
116
+ aiur set max-agents <n> change the concurrent-agent cap at runtime
117
+ aiur upgrade [--force] install the newer aiur-cli on your channel
118
+ aiur pause | resume flip the global pause switch (whole daemon)
119
+ aiur pause <ids|--all> | resume <ids|--all> per-agent pause/resume
120
+ aiur message <id> [--message-id ID] <text> send Executor text to a running agent
121
+ aiur --todo <ids...> [--only] queue tickets; optionally dequeue all other pending tickets
122
+ aiur findings [--unfiled] [--slugs] [--scope aiur|repo] inspect host-local findings
123
+ aiur findings --record <json> --repo <owner/repo> append one validated finding
124
+ aiur findings --digest [--scope aiur|repo] generate the promoted Markdown digest
125
+ aiur ask <title> [--body <text>|--body-file <path>] [--urgency low|normal|high] [--blocking]
126
+ aiur ask --done <id> [--note <text>] create or resolve an operator request
127
+ aiur asks [--open|--all] [--json] inspect current-repository operator requests
128
+ aiur doctor [--repair] check mise shims; repair only with consent
129
+ aiur cleanup-stale [--dry-run] list/reap stale manual-smoke leftovers
130
+ aiur --version
131
+ Bare aiur: start or attach to this directory's interactive session.
132
+ EOF
4013
133
  }
4014
134
 
4015
135
  # --- dispatch ----------------------------------------------------------------
4016
-
4017
136
  dispatch_run() {
137
+ [ ! -f "$engine_dir/aiur-mise-doctor" ] || bash "$engine_dir/aiur-mise-doctor" --check || true
4018
138
  local mode="foreground" arg
4019
139
  local args=()
4020
140
 
@@ -4032,7 +152,6 @@ dispatch_run() {
4032
152
  # `set -u` — happens for a bare `--bg` run. Guard the expansion.
4033
153
  run_session "$mode" "${args[@]+"${args[@]}"}"
4034
154
  }
4035
-
4036
155
  aiur_engine_main() {
4037
156
  local cmd="${1:-}"
4038
157
  # Names the running subcommand in control-RPC diagnostics so a failure says
@@ -4058,6 +177,16 @@ aiur_engine_main() {
4058
177
  init)
4059
178
  run_init "$@"
4060
179
  ;;
180
+ login)
181
+ run_account_login "$@"
182
+ ;;
183
+ accounts)
184
+ shift
185
+ cmd_accounts "$@"
186
+ ;;
187
+ logout)
188
+ run_local_cli "$@"
189
+ ;;
4061
190
  findings)
4062
191
  run_findings "$@"
4063
192
  ;;
@@ -4071,6 +200,7 @@ aiur_engine_main() {
4071
200
  shift
4072
201
  dispatch_run "$@"
4073
202
  ;;
203
+ doctor) shift; exec bash "$engine_dir/aiur-mise-doctor" "$@" ;;
4074
204
  status)
4075
205
  shift
4076
206
  cmd_status "$@"
@@ -4103,6 +233,11 @@ aiur_engine_main() {
4103
233
  shift
4104
234
  cmd_units "$@"
4105
235
  ;;
236
+ queue)
237
+ shift
238
+ cmd_queue "$@"
239
+ ;;
240
+ epic) shift; cmd_epic "$@" ;;
4106
241
  build-orders)
4107
242
  shift
4108
243
  cmd_build_orders "$@"
@@ -4115,6 +250,8 @@ aiur_engine_main() {
4115
250
  shift
4116
251
  cmd_github_cost "$@"
4117
252
  ;;
253
+ capabilities)
254
+ shift; cmd_capabilities "$@" ;;
4118
255
  github-usage)
4119
256
  shift
4120
257
  cmd_github_usage "$@"
@@ -4127,9 +264,9 @@ aiur_engine_main() {
4127
264
  shift
4128
265
  cmd_watch "$@"
4129
266
  ;;
4130
- executor-listen)
267
+ listen|executor-listen)
4131
268
  shift
4132
- cmd_executor_listen "$@"
269
+ cmd_listen "$@"
4133
270
  ;;
4134
271
  executor-wait)
4135
272
  shift
@@ -4183,6 +320,10 @@ aiur_engine_main() {
4183
320
  shift
4184
321
  cmd_reset_budget "$@"
4185
322
  ;;
323
+ workspace-recover)
324
+ shift
325
+ cmd_workspace_recover "$@"
326
+ ;;
4186
327
  message)
4187
328
  shift
4188
329
  cmd_message "$@"
@@ -4218,7 +359,10 @@ aiur_engine_main() {
4218
359
  ;;
4219
360
  esac
4220
361
  }
4221
-
362
+ source "$(dirname "${BASH_SOURCE[0]}")/aiur-daemon-supervisor.sh"
363
+ source "$(dirname "${BASH_SOURCE[0]}")/aiur-queue.sh"
364
+ source "$(dirname "${BASH_SOURCE[0]}")/aiur-epic.sh"
365
+ source "$(dirname "${BASH_SOURCE[0]}")/aiur-capabilities.sh"
4222
366
  if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
4223
367
  aiur_engine_main "$@"
4224
368
  fi