omnilane 0.15.0 → 0.21.0

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.
package/scripts/jobs.sh CHANGED
@@ -4,14 +4,21 @@ set -euo pipefail
4
4
  # Usage: jobs.sh [--json] list | status JOB_ID | result JOB_ID | stats [--last N]
5
5
  # jobs.sh [--json] recommend [--last N] [--lane L] [--min-samples N]
6
6
  # jobs.sh wait JOB_ID [--timeout N]
7
+ # jobs.sh send JOB_ID TEXT | watch JOB_ID | close JOB_ID
7
8
  # jobs.sh audit [--last N] [--json] | prune [--keep N] [--apply]
8
9
 
10
+ # Runtime-relative shared library.
11
+ # shellcheck disable=SC1091
9
12
  source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
13
+ # shellcheck disable=SC1091
14
+ source "$OMNILANE_REPO/scripts/lib/live-protocol.sh"
10
15
  JOBS="$OMNILANE_HOME/jobs"
11
16
  JOB_ID_PATTERN='^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$'
12
17
  JSON_MODE=0
13
18
  COMMAND="unknown"
14
19
 
20
+ # The backslash case pattern is intentional.
21
+ # shellcheck disable=SC1003
15
22
  json_escape() {
16
23
  local s="$1" out="" ch escaped code i
17
24
  for ((i = 0; i < ${#s}; i++)); do
@@ -49,7 +56,7 @@ die() {
49
56
  exit "$rc"
50
57
  }
51
58
 
52
- USAGE_TEXT="usage: jobs.sh [--json] list [--lane L] [--vendor V] [--status running|done]|status ID|result ID|tail ID [--lines N]|retry ID [--background]|stats [--last N] [--lane L] [--vendor V]|recommend [--last N] [--lane L] [--min-samples N]|wait ID [--timeout N]|cancel ID|rm ID|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
59
+ USAGE_TEXT="usage: jobs.sh [--json] list [--lane L] [--vendor V] [--status running|done]|status ID|result ID|tail ID [--lines N]|send ID TEXT|watch ID|close ID|retry ID [--background]|stats [--last N] [--lane L] [--vendor V]|recommend [--last N] [--lane L] [--min-samples N]|wait ID [--timeout N]|cancel ID|rm ID|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
53
60
 
54
61
  usage() {
55
62
  die 2 "$USAGE_TEXT"
@@ -108,6 +115,14 @@ read_public_metadata() {
108
115
  PUBLIC_METADATA="$value"
109
116
  }
110
117
 
118
+ print_mode_notice() {
119
+ local path="$JOB_DIR/mode-notice.txt"
120
+ [[ -e "$path" || -L "$path" ]] || return 0
121
+ if read_public_metadata "$path"; then
122
+ printf '%s\n' "$PUBLIC_METADATA"
123
+ fi
124
+ }
125
+
111
126
  valid_utf8() {
112
127
  command -v iconv >/dev/null 2>&1 || return 1
113
128
  printf '%s' "$1" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1
@@ -131,6 +146,68 @@ read_job_pid() {
131
146
  RECORDED_PID="$value"
132
147
  }
133
148
 
149
+ load_job_vendor() {
150
+ local session_mode_re='"session_mode":"(live|single-shot)"'
151
+ if ! read_public_metadata "$JOB_DIR/meta.json" || ! parse_stats_metadata "$PUBLIC_METADATA"; then
152
+ die 1 "job metadata is not safely readable"
153
+ fi
154
+ JOB_VENDOR="$STATS_VENDOR"
155
+ JOB_SESSION_MODE=""
156
+ if [[ "$PUBLIC_METADATA" =~ $session_mode_re ]]; then
157
+ JOB_SESSION_MODE="${BASH_REMATCH[1]}"
158
+ elif [[ "$JOB_VENDOR" == "claude" ]]; then
159
+ # Compatibility with live jobs created before session_mode metadata.
160
+ JOB_SESSION_MODE="live"
161
+ else
162
+ JOB_SESSION_MODE="single-shot"
163
+ fi
164
+ }
165
+
166
+ require_live_job() {
167
+ load_job_vendor
168
+ live_vendor_capable "$JOB_VENDOR" ||
169
+ die 1 "job vendor '$JOB_VENDOR' is not live-capable (live vendors: $(live_capable_vendors)); it runs in single-shot mode"
170
+ [[ "$JOB_SESSION_MODE" == "live" ]] ||
171
+ die 1 "job vendor '$JOB_VENDOR' was dispatched in single-shot mode"
172
+ }
173
+
174
+ wait_for_live_ready() {
175
+ local deadline=$((SECONDS + 5))
176
+ while [[ ! -f "$JOB_DIR/inbox.ready" ]]; do
177
+ if [[ -e "$JOB_DIR/exit" || -L "$JOB_DIR/exit" ]]; then
178
+ die 1 "job is no longer accepting live messages"
179
+ fi
180
+ if [[ "$SECONDS" -ge "$deadline" ]]; then
181
+ die 124 "live inbox did not become ready within 5s"
182
+ fi
183
+ sleep 0.1
184
+ done
185
+ [[ ! -L "$JOB_DIR/inbox.ready" ]] || die 1 "unsafe live inbox readiness path"
186
+ [[ -p "$JOB_DIR/inbox.fifo" && ! -L "$JOB_DIR/inbox.fifo" ]] || die 1 "live inbox FIFO is unavailable"
187
+ }
188
+
189
+ write_fifo_with_timeout() {
190
+ local fifo="$1" payload="$2" writer_pid deadline rc
191
+ (
192
+ printf '%s\n' "$payload" > "$fifo"
193
+ ) &
194
+ writer_pid=$!
195
+ deadline=$((SECONDS + 5))
196
+ while kill -0 "$writer_pid" 2>/dev/null; do
197
+ if [[ "$SECONDS" -ge "$deadline" ]]; then
198
+ kill -TERM "$writer_pid" 2>/dev/null || true
199
+ wait "$writer_pid" 2>/dev/null || true
200
+ return 124
201
+ fi
202
+ sleep 0.1
203
+ done
204
+ set +e
205
+ wait "$writer_pid"
206
+ rc=$?
207
+ set -e
208
+ return "$rc"
209
+ }
210
+
134
211
  parse_stats_metadata() {
135
212
  local value="$1"
136
213
  local metadata_re='^\{"lane":"([a-z][a-z0-9-]*)","vendor":"('"$OMNILANE_VENDOR_ALT"')"(,|})'
@@ -142,10 +219,11 @@ parse_stats_metadata() {
142
219
  }
143
220
 
144
221
  parse_audit_metadata() {
145
- local value="$1" metadata_re
222
+ local value="$1" current_re legacy_re
146
223
  local json_string='([^"\\]|\\["\\/bfnrt]|\\u[0-9a-fA-F]{4})*'
147
- metadata_re="^\\{\"lane\":\"[a-z][a-z0-9-]*\",\"vendor\":\"(${OMNILANE_VENDOR_ALT})\",\"model\":\"${json_string}\",\"effort\":\"${json_string}\",\"timeout\":(0|[1-9][0-9]*),\"job_timeout\":(null|0|[1-9][0-9]*),\"mode\":\"(advise|work)\",\"workdir\":\"${json_string}\",\"candidate\":\"[1-9][0-9]*/[1-9][0-9]*\",\"started\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\"\\}$"
148
- [[ "$value" =~ $metadata_re ]]
224
+ current_re="^\\{\"lane\":\"[a-z][a-z0-9-]*\",\"vendor\":\"(${OMNILANE_VENDOR_ALT})\",\"session_mode\":\"(live|single-shot)\",\"idle_timeout\":(0|[1-9][0-9]*),\"model\":\"${json_string}\",\"effort\":\"${json_string}\",\"timeout\":(0|[1-9][0-9]*),\"job_timeout\":(null|0|[1-9][0-9]*),\"mode\":\"(advise|work|sysops)\",\"workdir\":\"${json_string}\",\"foreman_session\":\"${json_string}\",\"candidate\":\"[1-9][0-9]*/[1-9][0-9]*\",\"started\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\"\\}$"
225
+ legacy_re="^\\{\"lane\":\"[a-z][a-z0-9-]*\",\"vendor\":\"(${OMNILANE_VENDOR_ALT})\",\"model\":\"${json_string}\",\"effort\":\"${json_string}\",\"timeout\":(0|[1-9][0-9]*),\"job_timeout\":(null|0|[1-9][0-9]*),\"mode\":\"(advise|work|sysops)\",\"workdir\":\"${json_string}\",(\"foreman_session\":\"${json_string}\",)?\"candidate\":\"[1-9][0-9]*/[1-9][0-9]*\",\"started\":\"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\"\\}$"
226
+ [[ "$value" =~ $current_re || "$value" =~ $legacy_re ]]
149
227
  }
150
228
 
151
229
  print_stat_counts() {
@@ -280,6 +358,71 @@ case "${1:-}" in
280
358
  help|--help|-h)
281
359
  [[ "$JSON_MODE" -eq 0 && $# -eq 1 ]] || usage
282
360
  echo "$USAGE_TEXT" ;;
361
+ send)
362
+ [[ "$JSON_MODE" -eq 0 && $# -eq 3 ]] || usage
363
+ select_job "$2"
364
+ require_live_job
365
+ wait_for_live_ready
366
+ if [[ "${FOREMAN_SESSION+x}" == "x" ]]; then
367
+ foreman_value="$FOREMAN_SESSION"
368
+ else
369
+ foreman_value="${foreman_session-}"
370
+ fi
371
+ payload="$(live_encode_message "$JOB_VENDOR" "$2" "$foreman_value" "$3")"
372
+ set +e
373
+ write_fifo_with_timeout "$JOB_DIR/inbox.fifo" "$payload"
374
+ send_rc=$?
375
+ set -e
376
+ [[ "$send_rc" -eq 0 ]] || die "$send_rc" "send timed out or failed; the live worker may be gone"
377
+ echo "sent $2"
378
+ ;;
379
+ watch)
380
+ [[ "$JSON_MODE" -eq 0 && $# -eq 2 ]] || usage
381
+ select_job "$2"
382
+ require_live_job
383
+ events_path="$JOB_DIR/events.jsonl"
384
+ watch_deadline=$((SECONDS + 5))
385
+ while [[ ! -f "$events_path" ]]; do
386
+ if [[ -e "$JOB_DIR/exit" || -L "$JOB_DIR/exit" ]]; then
387
+ die 1 "job has no live event stream"
388
+ fi
389
+ [[ "$SECONDS" -lt "$watch_deadline" ]] || die 124 "live event stream did not appear within 5s"
390
+ sleep 0.1
391
+ done
392
+ [[ ! -L "$events_path" ]] || die 1 "unsafe live event path"
393
+ if [[ -e "$JOB_DIR/exit" || -L "$JOB_DIR/exit" ]]; then
394
+ cat "$events_path"
395
+ else
396
+ tail -n +1 -f -- "$events_path"
397
+ fi
398
+ ;;
399
+ close)
400
+ [[ "$JSON_MODE" -eq 0 && $# -eq 2 ]] || usage
401
+ select_job "$2"
402
+ require_live_job
403
+ if [[ -e "$JOB_DIR/exit" || -L "$JOB_DIR/exit" ]]; then
404
+ read_exit_code "$JOB_DIR/exit" || die 1 "invalid recorded exit status"
405
+ echo "already closed (exit $RECORDED_EXIT)"
406
+ exit "$RECORDED_EXIT"
407
+ fi
408
+ holder_path="$JOB_DIR/inbox.holder.pid"
409
+ read_job_pid "$holder_path" || die 1 "live inbox holder is unavailable"
410
+ holder_pid="$RECORDED_PID"
411
+ kill -0 "$holder_pid" 2>/dev/null || die 1 "live inbox holder is gone"
412
+ kill -USR1 "$holder_pid" 2>/dev/null || die 1 "could not signal live inbox holder"
413
+ close_deadline=$((SECONDS + 10))
414
+ while [[ ! -e "$JOB_DIR/exit" && ! -L "$JOB_DIR/exit" ]]; do
415
+ if [[ "$SECONDS" -ge "$close_deadline" ]]; then
416
+ die 124 "close signal sent, but the live worker did not terminate within 10s"
417
+ fi
418
+ sleep 0.1
419
+ done
420
+ read_exit_code "$JOB_DIR/exit" || die 1 "invalid recorded exit status"
421
+ events_path="$JOB_DIR/events.jsonl"
422
+ result_count="$(live_count_result_events "$JOB_VENDOR" "$events_path")"
423
+ echo "closed $2 (result_events=$result_count exit=$RECORDED_EXIT)"
424
+ exit "$RECORDED_EXIT"
425
+ ;;
283
426
  tail)
284
427
  # Peek at the live public output stream of one job (running or done)
285
428
  # without touching its exit contract. Only out.txt is shown; stderr and
@@ -427,13 +570,20 @@ case "${1:-}" in
427
570
  fi
428
571
  for artifact in "$job_dir"/*; do
429
572
  [[ -e "$artifact" || -L "$artifact" ]] || continue
430
- if [[ -L "$artifact" ]]; then
431
- audit_emit "$id" symlink-artifact
432
- findings=$((findings + 1)); job_failed=1
433
- elif [[ -d "$artifact" ]]; then
434
- audit_emit "$id" nested-directory
435
- findings=$((findings + 1)); job_failed=1
436
- elif [[ -f "$artifact" ]]; then
573
+ if [[ -L "$artifact" ]]; then
574
+ audit_emit "$id" symlink-artifact
575
+ findings=$((findings + 1)); job_failed=1
576
+ elif [[ -d "$artifact" ]]; then
577
+ audit_emit "$id" nested-directory
578
+ findings=$((findings + 1)); job_failed=1
579
+ elif [[ -p "$artifact" && ( "${artifact##*/}" == "inbox.fifo" ||
580
+ "${artifact##*/}" == "runner-inbox.fifo" ) ]]; then
581
+ mode="$(path_mode "$artifact" 2>/dev/null || true)"
582
+ if [[ "$mode" != "600" ]]; then
583
+ audit_emit "$id" unsafe-fifo-mode
584
+ findings=$((findings + 1)); job_failed=1
585
+ fi
586
+ elif [[ -f "$artifact" ]]; then
437
587
  mode="$(path_mode "$artifact" 2>/dev/null || true)"
438
588
  if [[ "$mode" != "600" ]]; then
439
589
  audit_emit "$id" unsafe-file-mode
@@ -821,6 +971,9 @@ case "${1:-}" in
821
971
  else
822
972
  echo "running"
823
973
  fi
974
+ fi
975
+ if [[ "$JSON_MODE" -eq 0 ]]; then
976
+ print_mode_notice
824
977
  fi ;;
825
978
  wait)
826
979
  [[ "$JSON_MODE" -eq 0 && $# -ge 2 ]] || usage
@@ -64,6 +64,94 @@ write_current_pid_file() {
64
64
  mv "$tmp" "$path"
65
65
  }
66
66
 
67
+ process_start_time() { # pid -> stable ps(1) start timestamp
68
+ local pid="$1" value
69
+ [[ "$pid" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
70
+ value="$(LC_ALL=C ps -o lstart= -p "$pid" 2>/dev/null)" || return 1
71
+ value="${value#"${value%%[![:space:]]*}"}"
72
+ value="${value%"${value##*[![:space:]]}"}"
73
+ [[ -n "$value" ]] || return 1
74
+ printf '%s\n' "$value"
75
+ }
76
+
77
+ process_parent_pid() {
78
+ local pid="$1" value
79
+ [[ "$pid" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
80
+ value="$(LC_ALL=C ps -o ppid= -p "$pid" 2>/dev/null)" || return 1
81
+ value="${value//[[:space:]]/}"
82
+ [[ "$value" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
83
+ printf '%s\n' "$value"
84
+ }
85
+
86
+ read_session_entry() { # path -> SESSION_ENTRY_{PID,START,ID}
87
+ local path="$1" fields rest
88
+ SESSION_ENTRY_PID=""
89
+ SESSION_ENTRY_START=""
90
+ SESSION_ENTRY_ID=""
91
+ [[ -f "$path" && ! -L "$path" ]] || return 1
92
+ fields="$(perl -MJSON::PP -e '
93
+ use strict;
94
+ use warnings;
95
+ my ($path) = @ARGV;
96
+ my $size = -s $path;
97
+ die "invalid size\n" unless defined($size) && $size <= 4096;
98
+ open my $fh, "<", $path or die $!;
99
+ local $/;
100
+ my $entry = decode_json(<$fh>);
101
+ close $fh or die $!;
102
+ die "invalid entry\n" unless ref($entry) eq "HASH";
103
+ my ($pid, $start, $id) = @{$entry}{qw(pid start_time session_id)};
104
+ die "invalid pid\n" if ref($pid) || !defined($pid) || $pid !~ /\A[1-9][0-9]{0,9}\z/;
105
+ die "invalid start\n" if ref($start) || !defined($start) ||
106
+ $start !~ /\A[ -~]{1,128}\z/;
107
+ die "invalid session\n" if ref($id) || !defined($id) ||
108
+ $id !~ /\A[A-Za-z0-9._:-]{1,256}\z/;
109
+ print "$pid\t$start\t$id";
110
+ ' "$path" 2>/dev/null)" || return 1
111
+ SESSION_ENTRY_PID="${fields%%$'\t'*}"
112
+ rest="${fields#*$'\t'}"
113
+ [[ "$rest" != "$fields" ]] || return 1
114
+ SESSION_ENTRY_START="${rest%%$'\t'*}"
115
+ SESSION_ENTRY_ID="${rest#*$'\t'}"
116
+ [[ "$SESSION_ENTRY_ID" != "$rest" ]] || return 1
117
+ }
118
+
119
+ prune_stale_session_entries() {
120
+ local sessions="$OMNILANE_HOME/sessions" entry live_start
121
+ [[ -d "$sessions" && ! -L "$sessions" ]] || return 0
122
+ for entry in "$sessions"/*.json; do
123
+ [[ -f "$entry" && ! -L "$entry" ]] || continue
124
+ read_session_entry "$entry" || continue
125
+ if ! live_start="$(process_start_time "$SESSION_ENTRY_PID")" ||
126
+ [[ "$live_start" != "$SESSION_ENTRY_START" ]]; then
127
+ rm "$entry" 2>/dev/null || true
128
+ fi
129
+ done
130
+ }
131
+
132
+ find_foreman_session() { # dispatch pid -> nearest live Claude foreman session
133
+ local pid="$1" sessions="$OMNILANE_HOME/sessions"
134
+ local parent start entry hops=0
135
+ [[ "$pid" =~ ^[1-9][0-9]{0,9}$ ]] || return 1
136
+ [[ -d "$sessions" && ! -L "$sessions" ]] || return 1
137
+ prune_stale_session_entries
138
+
139
+ while [[ "$hops" -lt 128 ]]; do
140
+ parent="$(process_parent_pid "$pid")" || return 1
141
+ [[ "$parent" =~ ^[1-9][0-9]{0,9}$ && "$parent" != "$pid" ]] || return 1
142
+ start="$(process_start_time "$parent")" || return 1
143
+ entry="$sessions/$parent.json"
144
+ if read_session_entry "$entry" &&
145
+ [[ "$SESSION_ENTRY_PID" == "$parent" && "$SESSION_ENTRY_START" == "$start" ]]; then
146
+ printf '%s\n' "$SESSION_ENTRY_ID"
147
+ return 0
148
+ fi
149
+ pid="$parent"
150
+ hops=$((hops + 1))
151
+ done
152
+ return 1
153
+ }
154
+
67
155
  # ---- Vendor registry (single source of truth) ---------------------------
68
156
  # Two vendor kinds. CLI-agent vendors run a local binary (see vendor_bin).
69
157
  # Direct-API vendors are OpenAI-compatible /chat/completions endpoints driven