omnilane 0.32.0 → 0.33.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.
@@ -3,7 +3,7 @@ set -euo pipefail
3
3
  # omnilane dispatch — one routing table, any harness.
4
4
  #
5
5
  # Usage:
6
- # dispatch.sh [--background] [--live|--single-shot] [--dry-run]
6
+ # dispatch.sh [--background] [--live|--single-shot] [--dry-run] [--thread NAME]
7
7
  # [--mode advise|work|sysops] [--workdir DIR]
8
8
  # [--vendor V] [--model M] [--effort E] [--timeout SECONDS]
9
9
  # [--job-timeout SECONDS] [--idle-timeout SECONDS] LANE "TASK TEXT"
@@ -34,9 +34,10 @@ source "$OMNILANE_REPO/scripts/lib/live-protocol.sh"
34
34
  MODE="advise"; WORKDIR="$PWD"; BACKGROUND=0; DRY_RUN=0
35
35
  OVERRIDE_VENDOR=""; OVERRIDE_MODEL=""; OVERRIDE_EFFORT=""; OVERRIDE_TIMEOUT=""
36
36
  OVERRIDE_JOB_TIMEOUT=""; OVERRIDE_IDLE_TIMEOUT=""; SESSION_REQUEST="auto"
37
+ THREAD_NAME=""; THREAD_MODE=""; THREAD_ID=""; THREAD_TURN=""; THREAD_CREATED=""
37
38
 
38
39
  usage_error() {
39
- echo 'usage: dispatch.sh [--background] [--dry-run] [flags] LANE "TASK" | [--json] --list|--validate [--json] | [--json] --explain LANE [--json] | --help' >&2
40
+ echo 'usage: dispatch.sh [--background] [--dry-run] [--thread NAME] [flags] LANE "TASK" | [--json] --list|--validate [--json] | [--json] --explain LANE [--json] | --help' >&2
40
41
  exit 2
41
42
  }
42
43
 
@@ -50,6 +51,7 @@ Dispatch one task to the first available vendor CLI in LANE's fallback chain.
50
51
  A TASK of "-" reads the task text from stdin.
51
52
 
52
53
  flags:
54
+ --thread NAME continue a named thread (claude/codex/grok/gemini; single-shot)
53
55
  --live require a resident session (background only)
54
56
  --single-shot force one-shot dispatch (background only)
55
57
  --idle-timeout SECONDS close an idle live mailbox (default 900; 0 disables)
@@ -76,33 +78,6 @@ read-only queries (no provider call, no job state; --json for one envelope):
76
78
  EOF
77
79
  }
78
80
 
79
- json_escape() {
80
- local s="$1" out="" ch escaped code i
81
- for ((i = 0; i < ${#s}; i++)); do
82
- ch="${s:i:1}"
83
- case "$ch" in
84
- '"') out="$out\\\"" ;;
85
- '\\') out="$out\\\\" ;;
86
- $'\b') out="$out\\b" ;;
87
- $'\f') out="$out\\f" ;;
88
- $'\n') out="$out\\n" ;;
89
- $'\r') out="$out\\r" ;;
90
- $'\t') out="$out\\t" ;;
91
- *)
92
- LC_CTYPE=C printf -v code '%d' "'$ch"
93
- # Bash 3.2 on macOS reports UTF-8 bytes above 0x7f as signed values.
94
- # Only non-negative C0 bytes are JSON control characters.
95
- if [[ "$code" -ge 0 && "$code" -lt 32 ]]; then
96
- printf -v escaped '\\u%04x' "$code"
97
- out="$out$escaped"
98
- else
99
- out="$out$ch"
100
- fi
101
- ;;
102
- esac
103
- done
104
- printf '%s' "$out"
105
- }
106
81
 
107
82
  # Run one read-only inspection command once, preserving its human output and
108
83
  # exit status inside a stable JSON envelope. This avoids a second routing
@@ -125,6 +100,172 @@ emit_json_inspection() {
125
100
  exit "$rc"
126
101
  }
127
102
 
103
+ thread_refuse() {
104
+ local notice="$1"
105
+ printf '%s\n' "$notice"
106
+ printf '%s\n' "$notice" >&2
107
+ exit 2
108
+ }
109
+
110
+ generate_thread_id() {
111
+ local value=""
112
+ if command -v uuidgen >/dev/null 2>&1; then
113
+ value="$(uuidgen)" || return 1
114
+ elif command -v python3 >/dev/null 2>&1; then
115
+ value="$(python3 -c 'import uuid; print(uuid.uuid4())')" || return 1
116
+ else
117
+ return 1
118
+ fi
119
+ [[ "$value" =~ ^[A-Za-z0-9._:-]+$ && "${#value}" -le 256 ]] || return 1
120
+ printf '%s\n' "$value"
121
+ }
122
+
123
+ extract_claude_result_session_id() {
124
+ local events_path="$1"
125
+ [[ -f "$events_path" && ! -L "$events_path" ]] || return 1
126
+ perl -MJSON::PP -e '
127
+ use strict;
128
+ use warnings;
129
+ my ($path) = @ARGV;
130
+ open my $fh, "<", $path or die $!;
131
+ my $last = "";
132
+ while (my $line = <$fh>) {
133
+ my $event = eval { decode_json($line) };
134
+ next unless ref($event) eq "HASH" && ($event->{type} // "") eq "result";
135
+ my $id = $event->{session_id};
136
+ next if !defined($id) || ref($id) || $id !~ /\A[A-Za-z0-9._:-]{1,256}\z/;
137
+ $last = $id;
138
+ }
139
+ exit 1 unless length($last);
140
+ print $last;
141
+ ' "$events_path" 2>/dev/null
142
+ }
143
+
144
+ extract_codex_result_session_id() {
145
+ local events_path="$1"
146
+ [[ -f "$events_path" && ! -L "$events_path" ]] || return 1
147
+ perl -MJSON::PP -e '
148
+ use strict;
149
+ use warnings;
150
+ my ($path) = @ARGV;
151
+ open my $fh, "<", $path or die $!;
152
+ my $last = "";
153
+ while (my $line = <$fh>) {
154
+ my $event = eval { decode_json($line) };
155
+ next unless ref($event) eq "HASH";
156
+ my $id = $event->{thread_id};
157
+ next if !defined($id) || ref($id) || $id !~ /\A[A-Za-z0-9._:-]{1,256}\z/;
158
+ $last = $id;
159
+ }
160
+ exit 1 unless length($last);
161
+ print $last;
162
+ ' "$events_path" 2>/dev/null
163
+ }
164
+
165
+ extract_grok_result_session_id() {
166
+ local stderr_path="$1" fallback="$2" forked_id=""
167
+ if [[ -f "$stderr_path" && ! -L "$stderr_path" ]] && grep -Eiq 'fork' "$stderr_path"; then
168
+ forked_id="$(perl -ne '
169
+ while (/([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})/g) {
170
+ $last = $1;
171
+ }
172
+ END { exit 1 unless defined($last); print $last }
173
+ ' "$stderr_path" 2>/dev/null)" || return 1
174
+ printf '%s\n' "$forked_id"
175
+ return 0
176
+ fi
177
+ [[ "$fallback" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] || return 1
178
+ printf '%s\n' "$fallback"
179
+ }
180
+
181
+ extract_gemini_result_session_id() {
182
+ local result_path="$1"
183
+ [[ -f "$result_path" && ! -L "$result_path" ]] || return 1
184
+ perl -MJSON::PP -e '
185
+ use strict;
186
+ use warnings;
187
+ my ($path) = @ARGV;
188
+ open my $fh, "<", $path or die $!;
189
+ local $/;
190
+ my $result = decode_json(<$fh>);
191
+ my $id = ref($result) eq "HASH" ? $result->{conversation_id} : undef;
192
+ exit 1 if !defined($id) || ref($id) || $id !~ /\A[A-Za-z0-9._:-]{1,256}\z/;
193
+ print $id;
194
+ ' "$result_path" 2>/dev/null
195
+ }
196
+
197
+ append_thread_notice() {
198
+ local notice="$1"
199
+ printf '%s\n' "$notice" >&2
200
+ printf '\n%s\n' "$notice" >> "$JOB_DIR/out.txt"
201
+ }
202
+
203
+ write_thread_state() {
204
+ local returned_id="$1" final="$OMNILANE_HOME/threads/$THREAD_NAME.json"
205
+ local tmp="$OMNILANE_HOME/threads/.$THREAD_NAME.tmp.$$-$RANDOM"
206
+ local updated old_umask write_rc=0
207
+ prepare_threads_store || return 1
208
+ updated="$(date -u +%FT%TZ)" || return 1
209
+ [[ -n "$THREAD_CREATED" ]] || THREAD_CREATED="$updated"
210
+ old_umask="$(umask)"
211
+ umask 077
212
+ printf '{"name":"%s","vendor":"%s","model":"%s","effort":"%s","workdir":"%s","session_id":"%s","turns":%s,"last_job_id":"%s","created":"%s","updated":"%s"}\n' \
213
+ "$(json_escape "$THREAD_NAME")" "$(json_escape "$VENDOR")" \
214
+ "$(json_escape "$MODEL")" "$(json_escape "$EFFORT")" \
215
+ "$(json_escape "$WORKDIR")" "$(json_escape "$returned_id")" \
216
+ "$THREAD_TURN" "$(json_escape "$JOB_ID")" \
217
+ "$(json_escape "$THREAD_CREATED")" "$(json_escape "$updated")" \
218
+ > "$tmp" || write_rc=$?
219
+ if [[ "$write_rc" -eq 0 ]]; then
220
+ chmod 600 "$tmp" || write_rc=$?
221
+ fi
222
+ if [[ "$write_rc" -eq 0 ]]; then
223
+ mv "$tmp" "$final" || write_rc=$?
224
+ fi
225
+ umask "$old_umask"
226
+ rm "$tmp" 2>/dev/null || true
227
+ return "$write_rc"
228
+ }
229
+
230
+ update_thread_state() {
231
+ local returned_id notice original_id="$THREAD_ID"
232
+ case "$VENDOR" in
233
+ claude)
234
+ returned_id="$(extract_claude_result_session_id "$JOB_DIR/out.txt.events.jsonl")"
235
+ ;;
236
+ codex)
237
+ returned_id="$(extract_codex_result_session_id "$JOB_DIR/out.txt.progress.log")"
238
+ ;;
239
+ grok)
240
+ returned_id="$(extract_grok_result_session_id "$JOB_DIR/out.txt.stderr.log" "$THREAD_ID")"
241
+ ;;
242
+ gemini)
243
+ returned_id="$(extract_gemini_result_session_id "$JOB_DIR/out.txt.result.json")"
244
+ ;;
245
+ *)
246
+ return 1
247
+ ;;
248
+ esac || {
249
+ append_thread_notice "omnilane: thread $THREAD_NAME did not return a resumable $VENDOR session id"
250
+ return 1
251
+ }
252
+ if [[ "$returned_id" != "$THREAD_ID" && "$THREAD_ID" != "pending" ]]; then
253
+ notice="omnilane: thread $THREAD_NAME: $VENDOR returned a different session id ($THREAD_ID -> $returned_id)"
254
+ append_thread_notice "$notice"
255
+ fi
256
+ write_thread_state "$returned_id" || {
257
+ append_thread_notice "omnilane: thread $THREAD_NAME state could not be written"
258
+ return 1
259
+ }
260
+ THREAD_ID="$returned_id"
261
+ # Completes the pre-run "session pending" line on stdout; it is visibility,
262
+ # not a notice, so it stays out of out.txt (which foreground mode cats).
263
+ if [[ "$original_id" == "pending" ]]; then
264
+ printf 'omnilane: thread %s turn %s (%s session %s, %s)\n' \
265
+ "$THREAD_NAME" "$THREAD_TURN" "$VENDOR" "$THREAD_ID" "$THREAD_MODE"
266
+ fi
267
+ }
268
+
128
269
  print_dry_run_value() {
129
270
  printf '%s=' "$1"
130
271
  printf '%q\n' "$2"
@@ -148,6 +289,12 @@ print_dry_run_plan() {
148
289
  printf 'idle_timeout=%s\n' "$IDLE_TIMEOUT"
149
290
  print_dry_run_value session_mode "$SESSION_MODE"
150
291
  printf 'candidate=%s/%s\n' "$RESOLVED_IDX" "$RESOLVED_TOTAL"
292
+ if [[ -n "$THREAD_NAME" ]]; then
293
+ print_dry_run_value thread "$THREAD_NAME"
294
+ print_dry_run_value thread_mode "$THREAD_MODE"
295
+ print_dry_run_value thread_session "$THREAD_ID"
296
+ print_dry_run_value thread_turn "$THREAD_TURN"
297
+ fi
151
298
  printf 'background=%s\n' "$background"
152
299
  printf 'task_source=%s\n' "$task_source"
153
300
  printf 'provider_invoked=no\n'
@@ -480,7 +627,7 @@ while [[ $# -gt 0 ]]; do
480
627
  }
481
628
  SESSION_REQUEST="single-shot"; shift ;;
482
629
  --dry-run) DRY_RUN=1; shift ;;
483
- --mode|--workdir|--vendor|--model|--effort|--timeout|--job-timeout|--idle-timeout)
630
+ --mode|--workdir|--vendor|--model|--effort|--timeout|--job-timeout|--idle-timeout|--thread)
484
631
  # Value-taking flags: a missing value must be a clean usage error (exit 2),
485
632
  # not a `set -u` "unbound variable" crash on $2.
486
633
  [[ $# -ge 2 ]] || { echo "omnilane: $1 needs a value" >&2; exit 2; }
@@ -493,6 +640,7 @@ while [[ $# -gt 0 ]]; do
493
640
  --timeout) OVERRIDE_TIMEOUT="$2" ;;
494
641
  --job-timeout) OVERRIDE_JOB_TIMEOUT="$2" ;;
495
642
  --idle-timeout) OVERRIDE_IDLE_TIMEOUT="$2" ;;
643
+ --thread) THREAD_NAME="$2" ;;
496
644
  esac
497
645
  shift 2 ;;
498
646
  -*) echo "omnilane: unknown flag" >&2; exit 2 ;;
@@ -506,7 +654,22 @@ done
506
654
  echo 'omnilane: unexpected extra arguments; quote a multiword task' >&2
507
655
  exit 2
508
656
  }
509
- if [[ "$SESSION_REQUEST" != "auto" && "$BACKGROUND" -ne 1 ]]; then
657
+ if [[ -n "$THREAD_NAME" ]] && ! omnilane_valid_thread_name "$THREAD_NAME"; then
658
+ thread_refuse "omnilane: invalid --thread name '$THREAD_NAME' (want 1-64 characters: A-Z, a-z, 0-9, dot, underscore, hyphen)"
659
+ fi
660
+ if [[ -n "$THREAD_NAME" && "$SESSION_REQUEST" == "live" ]]; then
661
+ thread_refuse "omnilane: --thread cannot be combined with --live; threads run single-shot and a live session is already a conversation"
662
+ fi
663
+ if [[ -n "$THREAD_NAME" && -n "$OVERRIDE_VENDOR" ]]; then
664
+ case "$OVERRIDE_VENDOR" in
665
+ claude|codex|grok|gemini) ;;
666
+ *)
667
+ thread_refuse "omnilane: --thread is supported for claude, codex, grok and gemini only; resolved vendor '$OVERRIDE_VENDOR' cannot continue a thread"
668
+ ;;
669
+ esac
670
+ fi
671
+ if [[ "$SESSION_REQUEST" != "auto" && "$BACKGROUND" -ne 1 &&
672
+ ! ( -n "$THREAD_NAME" && "$SESSION_REQUEST" == "single-shot" ) ]]; then
510
673
  echo "omnilane: --${SESSION_REQUEST} requires --background" >&2
511
674
  exit 2
512
675
  fi
@@ -559,6 +722,63 @@ VENDOR="${FIELDS[0]}"; MODEL="${FIELDS[1]:-}"; EFFORT="${FIELDS[2]:-}"
559
722
  [[ -n "$OVERRIDE_MODEL" ]] && MODEL="$OVERRIDE_MODEL"
560
723
  [[ -n "$OVERRIDE_EFFORT" ]] && EFFORT="$OVERRIDE_EFFORT"
561
724
 
725
+ if [[ -n "$THREAD_NAME" ]]; then
726
+ case "$VENDOR" in
727
+ claude|codex|grok|gemini) ;;
728
+ *)
729
+ thread_refuse "omnilane: --thread is supported for claude, codex, grok and gemini only; resolved vendor '$VENDOR' cannot continue a thread"
730
+ ;;
731
+ esac
732
+ THREAD_WORKDIR="$(cd -- "$WORKDIR" 2>/dev/null && pwd -P)" || {
733
+ thread_refuse "omnilane: thread $THREAD_NAME workdir is not accessible: $WORKDIR"
734
+ }
735
+ WORKDIR="$THREAD_WORKDIR"
736
+ THREADS_ROOT="$OMNILANE_HOME/threads"
737
+ if [[ -L "$THREADS_ROOT" || ( -e "$THREADS_ROOT" && ! -d "$THREADS_ROOT" ) ]]; then
738
+ thread_refuse "omnilane: unsafe thread store path (want real directory): $THREADS_ROOT"
739
+ fi
740
+ if [[ "$DRY_RUN" -ne 1 ]]; then
741
+ prepare_threads_store || thread_refuse "omnilane: thread store could not be prepared"
742
+ fi
743
+ THREAD_STATE_FILE="$THREADS_ROOT/$THREAD_NAME.json"
744
+ if [[ -e "$THREAD_STATE_FILE" || -L "$THREAD_STATE_FILE" ]]; then
745
+ read_thread_state "$THREAD_STATE_FILE" "$THREAD_NAME" || {
746
+ thread_refuse "omnilane: thread $THREAD_NAME state is not safely readable"
747
+ }
748
+ if [[ "$THREAD_STATE_VENDOR" != "$VENDOR" ]]; then
749
+ thread_refuse "omnilane: thread $THREAD_NAME pinned vendor '$THREAD_STATE_VENDOR' but resolved vendor is '$VENDOR'"
750
+ fi
751
+ if [[ "$THREAD_STATE_MODEL" != "$MODEL" ]]; then
752
+ thread_refuse "omnilane: thread $THREAD_NAME pinned model '$THREAD_STATE_MODEL' but resolved model is '$MODEL'"
753
+ fi
754
+ if [[ "$THREAD_STATE_EFFORT" != "$EFFORT" ]]; then
755
+ thread_refuse "omnilane: thread $THREAD_NAME pinned effort '$THREAD_STATE_EFFORT' but resolved effort is '$EFFORT'"
756
+ fi
757
+ if [[ "$THREAD_STATE_WORKDIR" != "$WORKDIR" ]]; then
758
+ thread_refuse "omnilane: thread $THREAD_NAME pinned workdir '$THREAD_STATE_WORKDIR' but effective workdir is '$WORKDIR'"
759
+ fi
760
+ THREAD_MODE="resume"
761
+ THREAD_ID="$THREAD_STATE_SESSION_ID"
762
+ THREAD_TURN=$((THREAD_STATE_TURNS + 1))
763
+ THREAD_CREATED="$THREAD_STATE_CREATED"
764
+ else
765
+ THREAD_MODE="new"
766
+ case "$VENDOR" in
767
+ codex|gemini) THREAD_ID="pending" ;;
768
+ *)
769
+ THREAD_ID="$(generate_thread_id)" || {
770
+ thread_refuse "omnilane: thread $THREAD_NAME could not generate a session id"
771
+ }
772
+ ;;
773
+ esac
774
+ THREAD_TURN=1
775
+ THREAD_CREATED=""
776
+ fi
777
+ export OMNILANE_THREAD_MODE="$THREAD_MODE"
778
+ export OMNILANE_THREAD_ID="$THREAD_ID"
779
+ export OMNILANE_THREAD_NAME="$THREAD_NAME"
780
+ fi
781
+
562
782
  if [[ "$VENDOR" == "off" ]]; then
563
783
  echo "omnilane: lane '$LANE' is disabled in routing config" >&2; exit 3
564
784
  fi
@@ -566,7 +786,7 @@ RUNNER="$OMNILANE_REPO/scripts/runners/run-$VENDOR.sh"
566
786
  [[ -x "$RUNNER" ]] || { echo "omnilane: no runner for vendor '$VENDOR'" >&2; exit 2; }
567
787
 
568
788
  SESSION_MODE="single-shot"
569
- if [[ "$SESSION_REQUEST" != "single-shot" ]] && live_vendor_capable "$VENDOR"; then
789
+ if [[ -z "$THREAD_NAME" && "$SESSION_REQUEST" != "single-shot" ]] && live_vendor_capable "$VENDOR"; then
570
790
  SESSION_MODE="live"
571
791
  fi
572
792
  if [[ "$SESSION_REQUEST" == "live" ]] && ! live_vendor_capable "$VENDOR"; then
@@ -685,7 +905,12 @@ JOB_ID="$(date +%Y%m%d-%H%M%S)-$$-$RANDOM"
685
905
  JOB_DIR="$JOBS_ROOT/$JOB_ID"
686
906
  mkdir -m 700 "$JOB_DIR"
687
907
  FOREMAN_SESSION=""
688
- if resolved_foreman_session="$(find_foreman_session "$$" 2>/dev/null)"; then
908
+ if [[ -n "${CLAUDE_CODE_SESSION_ID+x}" ]]; then
909
+ if [[ "$CLAUDE_CODE_SESSION_ID" =~ ^[A-Za-z0-9._:-]+$ &&
910
+ "${#CLAUDE_CODE_SESSION_ID}" -le 256 ]]; then
911
+ FOREMAN_SESSION="$CLAUDE_CODE_SESSION_ID"
912
+ fi
913
+ elif resolved_foreman_session="$(find_foreman_session "$$" 2>/dev/null)"; then
689
914
  FOREMAN_SESSION="$resolved_foreman_session"
690
915
  fi
691
916
 
@@ -696,18 +921,56 @@ else
696
921
  fi
697
922
 
698
923
  # meta "timeout" is the resolved per-CLI-call watchdog cap, not a whole-job total.
699
- (umask 077; printf '{"lane":"%s","vendor":"%s","session_mode":"%s","idle_timeout":%s,"model":"%s","effort":"%s","timeout":%s,"job_timeout":%s,"mode":"%s","workdir":"%s","foreman_session":"%s","candidate":"%s/%s","started":"%s"}\n' \
700
- "$(json_escape "$LANE")" "$(json_escape "$VENDOR")" "$(json_escape "$SESSION_MODE")" \
701
- "$IDLE_TIMEOUT" "$(json_escape "$MODEL")" "$(json_escape "$EFFORT")" \
702
- "$TIMEOUT" "$JOB_TIMEOUT_JSON" "$(json_escape "$MODE")" \
703
- "$(json_escape "$WORKDIR")" "$(json_escape "$FOREMAN_SESSION")" \
704
- "$RESOLVED_IDX" "$RESOLVED_TOTAL" \
705
- "$(date -u +%FT%TZ)" > "$JOB_DIR/meta.json")
924
+ THREAD_TURN_JSON="${THREAD_TURN:-null}"
925
+ if [[ -n "$THREAD_NAME" ]]; then
926
+ (umask 077; printf '{"lane":"%s","vendor":"%s","session_mode":"%s","idle_timeout":%s,"model":"%s","effort":"%s","timeout":%s,"job_timeout":%s,"mode":"%s","workdir":"%s","foreman_session":"%s","thread":"%s","thread_turn":%s,"candidate":"%s/%s","started":"%s"}\n' \
927
+ "$(json_escape "$LANE")" "$(json_escape "$VENDOR")" "$(json_escape "$SESSION_MODE")" \
928
+ "$IDLE_TIMEOUT" "$(json_escape "$MODEL")" "$(json_escape "$EFFORT")" \
929
+ "$TIMEOUT" "$JOB_TIMEOUT_JSON" "$(json_escape "$MODE")" \
930
+ "$(json_escape "$WORKDIR")" "$(json_escape "$FOREMAN_SESSION")" \
931
+ "$(json_escape "$THREAD_NAME")" "$THREAD_TURN_JSON" \
932
+ "$RESOLVED_IDX" "$RESOLVED_TOTAL" \
933
+ "$(date -u +%FT%TZ)" > "$JOB_DIR/meta.json")
934
+ else
935
+ (umask 077; printf '{"lane":"%s","vendor":"%s","session_mode":"%s","idle_timeout":%s,"model":"%s","effort":"%s","timeout":%s,"job_timeout":%s,"mode":"%s","workdir":"%s","foreman_session":"%s","candidate":"%s/%s","started":"%s"}\n' \
936
+ "$(json_escape "$LANE")" "$(json_escape "$VENDOR")" "$(json_escape "$SESSION_MODE")" \
937
+ "$IDLE_TIMEOUT" "$(json_escape "$MODEL")" "$(json_escape "$EFFORT")" \
938
+ "$TIMEOUT" "$JOB_TIMEOUT_JSON" "$(json_escape "$MODE")" \
939
+ "$(json_escape "$WORKDIR")" "$(json_escape "$FOREMAN_SESSION")" \
940
+ "$RESOLVED_IDX" "$RESOLVED_TOTAL" \
941
+ "$(date -u +%FT%TZ)" > "$JOB_DIR/meta.json")
942
+ fi
943
+
944
+ if [[ -n "$THREAD_NAME" ]]; then
945
+ printf 'omnilane: thread %s turn %s (%s session %s, %s)\n' \
946
+ "$THREAD_NAME" "$THREAD_TURN" "$VENDOR" "$THREAD_ID" "$THREAD_MODE"
947
+ fi
706
948
 
707
949
  secure_job_files() {
708
950
  find "$JOB_DIR" -type f -exec chmod 600 {} +
709
951
  }
710
952
 
953
+ sanitize_utf8() {
954
+ local max="$1" drop_leading="${2:-0}"
955
+ perl -MEncode -e '
956
+ use strict;
957
+ use warnings;
958
+ binmode STDIN, ":raw";
959
+ binmode STDOUT, ":raw";
960
+ local $/;
961
+ my $bytes = <STDIN> // "";
962
+ my ($max, $drop_leading) = @ARGV;
963
+ $bytes =~ s/\A[\x80-\xBF]+// if $drop_leading;
964
+ my $text = Encode::decode("UTF-8", $bytes, Encode::FB_DEFAULT);
965
+ my $encoded = Encode::encode("UTF-8", $text);
966
+ while (length($encoded) > $max && length($text)) {
967
+ $text =~ s/\A.//s;
968
+ $encoded = Encode::encode("UTF-8", $text);
969
+ }
970
+ print $encoded;
971
+ ' "$max" "$drop_leading"
972
+ }
973
+
711
974
  completion_tail() {
712
975
  local path="$1" size=0 keep value="" sentinel=$'\001'
713
976
  local note=$'[truncated: leading output omitted]\n'
@@ -718,9 +981,16 @@ completion_tail() {
718
981
  [[ "$size" =~ ^[0-9]+$ ]] || return 1
719
982
  if [[ "$size" -gt 2000 ]]; then
720
983
  keep=$((2000 - ${#note}))
721
- value="$(printf '%s' "$note"; tail -c "$keep" "$path"; printf '%s' "$sentinel")" || return 1
984
+ value="$({
985
+ printf '%s' "$note"
986
+ tail -c "$keep" "$path" | sanitize_utf8 "$keep" 1
987
+ printf '%s' "$sentinel"
988
+ })" || return 1
722
989
  else
723
- value="$(cat "$path"; printf '%s' "$sentinel")" || return 1
990
+ value="$({
991
+ sanitize_utf8 2000 0 < "$path"
992
+ printf '%s' "$sentinel"
993
+ })" || return 1
724
994
  fi
725
995
  value="${value%"$sentinel"}"
726
996
  fi
@@ -738,11 +1008,21 @@ write_completion_record() {
738
1008
  finished="$(date -u +%FT%TZ)" || return 1
739
1009
  old_umask="$(umask)"
740
1010
  umask 077
741
- printf '{"job_id":"%s","lane":"%s","vendor":"%s","model":"%s","mode":"%s","workdir":"%s","foreman_session":"%s","exit":%s,"finished":"%s","tail":"%s"}\n' \
742
- "$(json_escape "$JOB_ID")" "$(json_escape "$LANE")" "$(json_escape "$VENDOR")" \
743
- "$(json_escape "$MODEL")" "$(json_escape "$MODE")" "$(json_escape "$WORKDIR")" \
744
- "$(json_escape "$FOREMAN_SESSION")" \
745
- "$rc" "$(json_escape "$finished")" "$(json_escape "$tail_value")" > "$tmp" || write_rc=$?
1011
+ if [[ -n "$THREAD_NAME" ]]; then
1012
+ printf '{"job_id":"%s","lane":"%s","vendor":"%s","model":"%s","mode":"%s","workdir":"%s","foreman_session":"%s","thread":"%s","thread_turn":%s,"exit":%s,"finished":"%s","tail":"%s"}\n' \
1013
+ "$(json_escape "$JOB_ID")" "$(json_escape "$LANE")" "$(json_escape "$VENDOR")" \
1014
+ "$(json_escape "$MODEL")" "$(json_escape "$MODE")" "$(json_escape "$WORKDIR")" \
1015
+ "$(json_escape "$FOREMAN_SESSION")" "$(json_escape "$THREAD_NAME")" \
1016
+ "$THREAD_TURN_JSON" "$rc" "$(json_escape "$finished")" \
1017
+ "$(json_escape "$tail_value")" > "$tmp" || write_rc=$?
1018
+ else
1019
+ printf '{"job_id":"%s","lane":"%s","vendor":"%s","model":"%s","mode":"%s","workdir":"%s","foreman_session":"%s","exit":%s,"finished":"%s","tail":"%s"}\n' \
1020
+ "$(json_escape "$JOB_ID")" "$(json_escape "$LANE")" "$(json_escape "$VENDOR")" \
1021
+ "$(json_escape "$MODEL")" "$(json_escape "$MODE")" "$(json_escape "$WORKDIR")" \
1022
+ "$(json_escape "$FOREMAN_SESSION")" \
1023
+ "$rc" "$(json_escape "$finished")" "$(json_escape "$tail_value")" \
1024
+ > "$tmp" || write_rc=$?
1025
+ fi
746
1026
  if [[ "$write_rc" -eq 0 ]]; then
747
1027
  chmod 600 "$tmp" || write_rc=$?
748
1028
  fi
@@ -757,11 +1037,19 @@ write_completion_record() {
757
1037
 
758
1038
  finish_job() {
759
1039
  local rc="$1"
1040
+ if [[ -n "$THREAD_NAME" ]]; then
1041
+ if [[ "$rc" -eq 0 ]]; then
1042
+ update_thread_state || rc=1
1043
+ elif [[ "$THREAD_MODE" == "resume" ]]; then
1044
+ append_thread_notice "omnilane: thread $THREAD_NAME could not be continued; stored $VENDOR session $THREAD_ID was preserved"
1045
+ fi
1046
+ fi
760
1047
  secure_job_files
761
1048
  (umask 077; printf '%s\n' "$rc" > "$JOB_DIR/exit")
762
1049
  if [[ "${OMNILANE_INBOX:-1}" != "0" ]]; then
763
1050
  write_completion_record "$rc" >/dev/null 2>&1 || true
764
1051
  fi
1052
+ FINISHED_RC="$rc"
765
1053
  }
766
1054
 
767
1055
  run_job() {
@@ -787,7 +1075,7 @@ run_job() {
787
1075
  rc=124
788
1076
  fi
789
1077
  finish_job "$rc"
790
- return "$rc"
1078
+ return "$FINISHED_RC"
791
1079
  }
792
1080
 
793
1081
  if [[ "$BACKGROUND" == "1" ]]; then
package/scripts/doctor.sh CHANGED
@@ -44,7 +44,7 @@ json_escape() {
44
44
  ch="${s:i:1}"
45
45
  case "$ch" in
46
46
  '"') out="$out\\\"" ;;
47
- '\\') out="$out\\\\" ;;
47
+ \\) out="$out\\\\" ;;
48
48
  $'\b') out="$out\\b" ;;
49
49
  $'\f') out="$out\\f" ;;
50
50
  $'\n') out="$out\\n" ;;
package/scripts/jobs.sh CHANGED
@@ -13,38 +13,11 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
13
13
  # shellcheck disable=SC1091
14
14
  source "$OMNILANE_REPO/scripts/lib/live-protocol.sh"
15
15
  JOBS="$OMNILANE_HOME/jobs"
16
+ THREADS="$OMNILANE_HOME/threads"
16
17
  JOB_ID_PATTERN='^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$'
17
18
  JSON_MODE=0
18
19
  COMMAND="unknown"
19
20
 
20
- # The backslash case pattern is intentional.
21
- # shellcheck disable=SC1003
22
- json_escape() {
23
- local s="$1" out="" ch escaped code i
24
- for ((i = 0; i < ${#s}; i++)); do
25
- ch="${s:i:1}"
26
- case "$ch" in
27
- '"') out="$out\\\"" ;;
28
- '\\') out="$out\\\\" ;;
29
- $'\b') out="$out\\b" ;;
30
- $'\f') out="$out\\f" ;;
31
- $'\n') out="$out\\n" ;;
32
- $'\r') out="$out\\r" ;;
33
- $'\t') out="$out\\t" ;;
34
- *)
35
- LC_CTYPE=C printf -v code '%d' "'$ch"
36
- if [[ "$code" -ge 0 && "$code" -lt 32 ]]; then
37
- printf -v escaped '\\u%04x' "$code"
38
- out="$out$escaped"
39
- else
40
- out="$out$ch"
41
- fi
42
- ;;
43
- esac
44
- done
45
- printf '%s' "$out"
46
- }
47
-
48
21
  die() {
49
22
  local rc="$1" message="$2"
50
23
  if [[ "$JSON_MODE" -eq 1 ]]; then
@@ -56,7 +29,7 @@ die() {
56
29
  exit "$rc"
57
30
  }
58
31
 
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"
32
+ 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|threads [list] [--json]|threads show NAME [--json]|threads rm NAME|audit [--last N]|prune [--keep N] [--older-than DAYS] [--apply]|help"
60
33
 
61
34
  usage() {
62
35
  die 2 "$USAGE_TEXT"
@@ -347,7 +320,7 @@ set -- ${args[@]+"${args[@]}"}
347
320
  COMMAND="${1:-unknown}"
348
321
  if [[ "$JSON_MODE" -eq 1 ]]; then
349
322
  case "$COMMAND" in
350
- list|status|result|stats|recommend|audit) ;;
323
+ list|status|result|stats|recommend|audit|threads) ;;
351
324
  *) usage ;;
352
325
  esac
353
326
  fi
@@ -358,6 +331,77 @@ case "${1:-}" in
358
331
  help|--help|-h)
359
332
  [[ "$JSON_MODE" -eq 0 && $# -eq 1 ]] || usage
360
333
  echo "$USAGE_TEXT" ;;
334
+ threads)
335
+ thread_action="${2:-list}"
336
+ case "$thread_action" in
337
+ list)
338
+ [[ $# -eq 1 || ( $# -eq 2 && "$2" == "list" ) ]] || usage
339
+ if [[ -L "$THREADS" || ( -e "$THREADS" && ! -d "$THREADS" ) ]]; then
340
+ die 1 "unsafe thread store path"
341
+ fi
342
+ thread_names=()
343
+ if [[ -d "$THREADS" ]]; then
344
+ for thread_path in "$THREADS"/*.json; do
345
+ [[ -e "$thread_path" || -L "$thread_path" ]] || continue
346
+ thread_name="${thread_path##*/}"
347
+ thread_name="${thread_name%.json}"
348
+ omnilane_valid_thread_name "$thread_name" || die 1 "invalid thread state filename"
349
+ read_thread_state "$thread_path" "$thread_name" || die 1 "thread $thread_name state is not safely readable"
350
+ thread_names+=("$thread_name")
351
+ done
352
+ fi
353
+ if [[ "$JSON_MODE" -eq 1 ]]; then
354
+ printf '{"schema_version":1,"command":"threads","ok":true,"threads":['
355
+ thread_first=1
356
+ if [[ "${#thread_names[@]}" -gt 0 ]]; then
357
+ while IFS= read -r thread_name; do
358
+ read_thread_state "$THREADS/$thread_name.json" "$thread_name" \
359
+ || die 1 "thread $thread_name state is not safely readable"
360
+ [[ "$thread_first" -eq 1 ]] || printf ','
361
+ thread_first=0
362
+ cat "$THREADS/$thread_name.json"
363
+ done < <(printf '%s\n' "${thread_names[@]}" | LC_ALL=C sort)
364
+ fi
365
+ printf ']}\n'
366
+ else
367
+ printf 'name\tvendor\tmodel\tturns\tupdated\tlast_job\n'
368
+ if [[ "${#thread_names[@]}" -gt 0 ]]; then
369
+ while IFS= read -r thread_name; do
370
+ read_thread_state "$THREADS/$thread_name.json" "$thread_name" \
371
+ || die 1 "thread $thread_name state is not safely readable"
372
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
373
+ "$THREAD_STATE_NAME" "$THREAD_STATE_VENDOR" "$THREAD_STATE_MODEL" \
374
+ "$THREAD_STATE_TURNS" "$THREAD_STATE_UPDATED" "$THREAD_STATE_LAST_JOB_ID"
375
+ done < <(printf '%s\n' "${thread_names[@]}" | LC_ALL=C sort)
376
+ fi
377
+ fi
378
+ ;;
379
+ show)
380
+ [[ $# -eq 3 ]] || usage
381
+ thread_name="$3"
382
+ omnilane_valid_thread_name "$thread_name" || die 2 "invalid thread name"
383
+ thread_path="$THREADS/$thread_name.json"
384
+ read_thread_state "$thread_path" "$thread_name" || die 1 "no safely readable thread '$thread_name'"
385
+ if [[ "$JSON_MODE" -eq 1 ]]; then
386
+ printf '{"schema_version":1,"command":"threads show","ok":true,"thread":'
387
+ cat "$thread_path"
388
+ printf '}\n'
389
+ else
390
+ cat "$thread_path"
391
+ fi
392
+ ;;
393
+ rm)
394
+ [[ "$JSON_MODE" -eq 0 && $# -eq 3 ]] || usage
395
+ thread_name="$3"
396
+ omnilane_valid_thread_name "$thread_name" || die 2 "invalid thread name"
397
+ thread_path="$THREADS/$thread_name.json"
398
+ read_thread_state "$thread_path" "$thread_name" || die 1 "no safely readable thread '$thread_name'"
399
+ rm "$thread_path"
400
+ echo "removed thread $thread_name (vendor session preserved)"
401
+ ;;
402
+ *) usage ;;
403
+ esac
404
+ ;;
361
405
  send)
362
406
  [[ "$JSON_MODE" -eq 0 && $# -eq 3 ]] || usage
363
407
  select_job "$2"