omnilane 0.7.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.
@@ -0,0 +1,705 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ # omnilane dispatch — one routing table, any harness.
4
+ #
5
+ # Usage:
6
+ # dispatch.sh [--background] [--dry-run] [--mode advise|work] [--workdir DIR]
7
+ # [--vendor V] [--model M] [--effort E] [--timeout SECONDS]
8
+ # [--job-timeout SECONDS] LANE "TASK TEXT"
9
+ # dispatch.sh [--json] --list [--json]
10
+ # dispatch.sh [--json] --explain LANE [--json]
11
+ # dispatch.sh [--json] --validate [--json]
12
+ #
13
+ # TASK TEXT of "-" reads the task from stdin.
14
+ #
15
+ # A lane line may hold a fallback chain:
16
+ # lane: vendor model effort | vendor model effort | off
17
+ # The first candidate whose vendor CLI exists on this machine wins, so the same
18
+ # table degrades gracefully for people with fewer subscriptions.
19
+ #
20
+ # The watchdog caps EACH CLI invocation, highest priority first:
21
+ # --timeout SECONDS > OMNILANE_TIMEOUT_<LANE> > OMNILANE_TIMEOUT > 600
22
+ # The per-lane knob is the lane upper-cased with "-" turned into "_"
23
+ # (hard-judgment -> OMNILANE_TIMEOUT_HARD_JUDGMENT), so it can live in local.sh.
24
+ # This is a per-call hang-guard, NOT a whole-job budget: a retrying vendor
25
+ # (grok, up to OMNILANE_GROK_MAX_ATTEMPTS) or the vote panel (voters x rounds)
26
+ # spawns several CLI calls, so total wall-clock can be a multiple of this value.
27
+ # A separate --job-timeout can cap lock wait plus all calls in this dispatch.
28
+
29
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
30
+
31
+ MODE="advise"; WORKDIR="$PWD"; BACKGROUND=0; DRY_RUN=0
32
+ OVERRIDE_VENDOR=""; OVERRIDE_MODEL=""; OVERRIDE_EFFORT=""; OVERRIDE_TIMEOUT=""
33
+ OVERRIDE_JOB_TIMEOUT=""
34
+
35
+ usage_error() {
36
+ echo 'usage: dispatch.sh [--background] [--dry-run] [flags] LANE "TASK" | [--json] --list|--validate [--json] | [--json] --explain LANE [--json] | --help' >&2
37
+ exit 2
38
+ }
39
+
40
+ print_usage() {
41
+ cat <<'EOF'
42
+ usage: dispatch.sh [flags] LANE "TASK"
43
+ dispatch.sh [--json] --list | --explain LANE | --validate
44
+ dispatch.sh --help
45
+
46
+ Dispatch one task to the first available vendor CLI in LANE's fallback chain.
47
+ A TASK of "-" reads the task text from stdin.
48
+
49
+ flags:
50
+ --background run in the background and print the JOB_ID
51
+ --dry-run print the fully resolved dispatch plan and stop
52
+ before any provider call or job state
53
+ --mode advise|work advise (read-only, default) or work (may edit files)
54
+ --workdir DIR working directory handed to the vendor CLI
55
+ --vendor V pin one configured vendor (codex|claude|grok|gemini)
56
+ --model M override the routed model
57
+ --effort E override the routed effort
58
+ --timeout SECONDS cap each CLI call (default 600)
59
+ --job-timeout SECONDS cap the whole dispatch (lock wait plus all calls)
60
+
61
+ read-only queries (no provider call, no job state; --json for one envelope):
62
+ --list effective routing table (local overrides win)
63
+ --explain LANE candidate availability for one lane
64
+ --validate lint the effective routing table
65
+ --help, -h this help
66
+ EOF
67
+ }
68
+
69
+ json_escape() {
70
+ local s="$1" out="" ch escaped code i
71
+ for ((i = 0; i < ${#s}; i++)); do
72
+ ch="${s:i:1}"
73
+ case "$ch" in
74
+ '"') out="$out\\\"" ;;
75
+ '\\') out="$out\\\\" ;;
76
+ $'\b') out="$out\\b" ;;
77
+ $'\f') out="$out\\f" ;;
78
+ $'\n') out="$out\\n" ;;
79
+ $'\r') out="$out\\r" ;;
80
+ $'\t') out="$out\\t" ;;
81
+ *)
82
+ LC_CTYPE=C printf -v code '%d' "'$ch"
83
+ # Bash 3.2 on macOS reports UTF-8 bytes above 0x7f as signed values.
84
+ # Only non-negative C0 bytes are JSON control characters.
85
+ if [[ "$code" -ge 0 && "$code" -lt 32 ]]; then
86
+ printf -v escaped '\\u%04x' "$code"
87
+ out="$out$escaped"
88
+ else
89
+ out="$out$ch"
90
+ fi
91
+ ;;
92
+ esac
93
+ done
94
+ printf '%s' "$out"
95
+ }
96
+
97
+ # Run one read-only inspection command once, preserving its human output and
98
+ # exit status inside a stable JSON envelope. This avoids a second routing
99
+ # implementation drifting from the human CLI contract.
100
+ emit_json_inspection() {
101
+ local command="$1" output rc ok=false first=1 line
102
+ shift
103
+ if output="$("$@" 2>&1)"; then rc=0; else rc=$?; fi
104
+ [[ "$rc" -eq 0 ]] && ok=true
105
+ printf '{"schema_version":1,"command":"%s","ok":%s,"exit_code":%d,"lines":[' \
106
+ "$(json_escape "$command")" "$ok" "$rc"
107
+ if [[ -n "$output" ]]; then
108
+ while IFS= read -r line || [[ -n "$line" ]]; do
109
+ [[ "$first" -eq 1 ]] || printf ','
110
+ first=0
111
+ printf '"%s"' "$(json_escape "$line")"
112
+ done <<< "$output"
113
+ fi
114
+ printf ']}\n'
115
+ exit "$rc"
116
+ }
117
+
118
+ print_dry_run_value() {
119
+ printf '%s=' "$1"
120
+ printf '%q\n' "$2"
121
+ }
122
+
123
+ print_dry_run_plan() {
124
+ local background=no task_source=argument write_worktree=no job_timeout=disabled
125
+ [[ "$BACKGROUND" -eq 1 ]] && background=yes
126
+ [[ "$TASK" == "-" ]] && task_source=stdin
127
+ [[ "$MODE" == "work" ]] && write_worktree=yes
128
+ [[ -n "$JOB_TIMEOUT" ]] && job_timeout="$JOB_TIMEOUT"
129
+ printf 'dry_run=yes\n'
130
+ print_dry_run_value lane "$LANE"
131
+ print_dry_run_value vendor "$VENDOR"
132
+ print_dry_run_value model "$MODEL"
133
+ print_dry_run_value effort "$EFFORT"
134
+ print_dry_run_value mode "$MODE"
135
+ print_dry_run_value workdir "$WORKDIR"
136
+ printf 'timeout=%s\n' "$TIMEOUT"
137
+ printf 'job_timeout=%s\n' "$job_timeout"
138
+ printf 'candidate=%s/%s\n' "$RESOLVED_IDX" "$RESOLVED_TOTAL"
139
+ printf 'background=%s\n' "$background"
140
+ printf 'task_source=%s\n' "$task_source"
141
+ printf 'provider_invoked=no\n'
142
+ printf 'job_state_created=no\n'
143
+ printf 'would_invoke_provider=yes\n'
144
+ printf 'would_create_job=yes\n'
145
+ printf 'would_write_worktree=%s\n' "$write_worktree"
146
+ }
147
+
148
+ raw_lane_line() { # LANE -> chain text (comments stripped); local file wins
149
+ local lane="$1" f line
150
+ for f in "$OMNILANE_HOME/routing.local.yaml" "$OMNILANE_REPO/routing.yaml"; do
151
+ [[ -f "$f" ]] || continue
152
+ line="$(grep -E "^${lane}:" "$f" | head -1 | sed 's/#.*$//' | cut -d: -f2-)" || true
153
+ [[ -n "${line// /}" ]] && { printf '%s\n' "$line"; return 0; }
154
+ done
155
+ return 1
156
+ }
157
+
158
+ # Split one routing segment without invoking the shell. Double quotes group a
159
+ # model containing spaces; every other character is literal data.
160
+ parse_lane_segment() {
161
+ local input="$1" token="" ch i in_quote=0 have_token=0
162
+ PARSED_FIELDS=()
163
+ for ((i = 0; i < ${#input}; i++)); do
164
+ ch="${input:i:1}"
165
+ case "$ch" in
166
+ '"')
167
+ if [[ "$in_quote" -eq 1 ]]; then in_quote=0; else in_quote=1; fi
168
+ have_token=1
169
+ ;;
170
+ ' '|$'\t')
171
+ if [[ "$in_quote" -eq 1 ]]; then
172
+ token="$token$ch"
173
+ elif [[ "$have_token" -eq 1 ]]; then
174
+ PARSED_FIELDS+=("$token"); token=""; have_token=0
175
+ fi
176
+ ;;
177
+ *) token="$token$ch"; have_token=1 ;;
178
+ esac
179
+ done
180
+ [[ "$in_quote" -eq 0 ]] || return 1
181
+ [[ "$have_token" -eq 1 ]] && PARSED_FIELDS+=("$token")
182
+ [[ ${#PARSED_FIELDS[@]} -gt 0 ]]
183
+ }
184
+
185
+ routing_candidate_available() {
186
+ local vendor="$1" model="${2:-}" script
187
+ if [[ "$vendor" == "exec" ]]; then
188
+ script="$(expand_home_path "$model")"
189
+ [[ -n "$script" && -f "$script" && -x "$script" ]]
190
+ else
191
+ vendor_available "$vendor"
192
+ fi
193
+ }
194
+
195
+ # Pick the first candidate whose vendor CLI is present ("off" always matches).
196
+ # Sets RESOLVED_SPEC / RESOLVED_FIELDS / RESOLVED_IDX / RESOLVED_TOTAL.
197
+ resolve_chain() {
198
+ local chain="$1" requested_vendor="${2:-}" seg i=0 vendor
199
+ RESOLVED_SPEC=""; RESOLVED_IDX=0; RESOLVED_TOTAL=0; RESOLVED_FIELDS=()
200
+ local SEGS=() F=()
201
+ IFS='|' read -ra SEGS <<< "$chain"
202
+ RESOLVED_TOTAL="${#SEGS[@]}"
203
+ # ${SEGS[@]} on an empty chain is an unbound-variable error under set -u on
204
+ # Bash 3.2 and would abort --list mid-table; the guard makes it iterate zero
205
+ # times instead.
206
+ for seg in ${SEGS[@]+"${SEGS[@]}"}; do
207
+ i=$((i + 1))
208
+ [[ -n "${seg// /}" ]] || continue
209
+ parse_lane_segment "$seg" || {
210
+ echo "omnilane: malformed quoted routing segment: $seg" >&2
211
+ return 2
212
+ }
213
+ F=("${PARSED_FIELDS[@]}")
214
+ vendor="${F[0]:-}"
215
+ if [[ -n "$requested_vendor" ]]; then
216
+ [[ "$vendor" == "$requested_vendor" ]] || continue
217
+ RESOLVED_SPEC="$seg"; RESOLVED_FIELDS=("${F[@]}"); RESOLVED_IDX="$i"
218
+ if routing_candidate_available "$vendor" "${F[1]:-}"; then return 0; fi
219
+ return 4
220
+ fi
221
+ if [[ "$vendor" == "off" ]] || routing_candidate_available "$vendor" "${F[1]:-}"; then
222
+ RESOLVED_SPEC="$seg"; RESOLVED_FIELDS=("${F[@]}")
223
+ RESOLVED_IDX="$i"; return 0
224
+ fi
225
+ done
226
+ [[ -n "$requested_vendor" ]] && return 5
227
+ return 1
228
+ }
229
+
230
+ print_effective_routing() {
231
+ local seen=" " f line lane chain spec note
232
+ for f in "$OMNILANE_HOME/routing.local.yaml" "$OMNILANE_REPO/routing.yaml"; do
233
+ [[ -f "$f" ]] || continue
234
+ while IFS= read -r line; do
235
+ [[ "$line" =~ ^([a-z][a-z0-9-]*): ]] || continue
236
+ lane="${BASH_REMATCH[1]}"
237
+ [[ "$seen" == *" $lane "* ]] && continue
238
+ seen="$seen$lane "
239
+ chain="${line%%#*}"; chain="${chain#*:}"
240
+ if resolve_chain "$chain"; then
241
+ spec="$(printf '%s' "$RESOLVED_SPEC" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
242
+ note=""
243
+ [[ "$RESOLVED_IDX" -gt 1 ]] && note=" # fallback ($RESOLVED_IDX/$RESOLVED_TOTAL)"
244
+ printf '%-17s%s%s\n' "$lane:" "$spec" "$note"
245
+ else
246
+ printf '%-17s%s\n' "$lane:" "unavailable # no vendor CLI found in chain"
247
+ fi
248
+ done < "$f"
249
+ done
250
+ }
251
+
252
+ explain_lane() {
253
+ local lane="$1" chain seg i=0 total selected=0 vendor model effort status
254
+ local available=0
255
+ local SEGS=() F=()
256
+ [[ "$lane" =~ ^[a-z][a-z0-9-]*$ ]] || {
257
+ echo "omnilane: invalid lane name" >&2
258
+ return 2
259
+ }
260
+ chain="$(raw_lane_line "$lane")" || {
261
+ echo "omnilane: unknown lane '$lane' (try --list)" >&2
262
+ return 2
263
+ }
264
+ IFS='|' read -ra SEGS <<< "$chain"
265
+ total="${#SEGS[@]}"
266
+ printf 'lane: %s\n' "$lane"
267
+ for seg in "${SEGS[@]}"; do
268
+ i=$((i + 1))
269
+ [[ -n "${seg// /}" ]] || continue
270
+ parse_lane_segment "$seg" || {
271
+ echo "omnilane: malformed quoted routing segment: $seg" >&2
272
+ return 2
273
+ }
274
+ F=("${PARSED_FIELDS[@]}")
275
+ vendor="${F[0]:-}"
276
+ model="${F[1]:--}"
277
+ effort="${F[2]:--}"
278
+ available=0
279
+ status="unavailable"
280
+ if [[ "$vendor" == "off" ]]; then
281
+ available=1
282
+ status="available-disabled"
283
+ elif routing_candidate_available "$vendor" "$model"; then
284
+ available=1
285
+ status="available"
286
+ fi
287
+ if [[ "$available" -eq 1 && "$selected" -eq 0 ]]; then
288
+ selected="$i"
289
+ if [[ "$vendor" == "off" ]]; then status="selected-disabled"; else status="selected"; fi
290
+ elif [[ "$available" -eq 1 ]]; then
291
+ status="available-not-selected"
292
+ fi
293
+ printf 'candidate %d: vendor=%s model=%s effort=%s status=%s\n' \
294
+ "$i" "$vendor" "$model" "$effort" "$status"
295
+ done
296
+ if [[ "$selected" -gt 0 ]]; then
297
+ printf 'decision: candidate %d/%d\n' "$selected" "$total"
298
+ return 0
299
+ fi
300
+ printf 'decision: unavailable\n'
301
+ return 4
302
+ }
303
+
304
+ validate_routing() {
305
+ local effective_seen=" " file_seen f line content lane chain seg vendor
306
+ local line_no i total selected lane_invalid invalid=0 unreachable=0
307
+ local SEGS=() F=()
308
+ for f in "$OMNILANE_HOME/routing.local.yaml" "$OMNILANE_REPO/routing.yaml"; do
309
+ [[ -f "$f" ]] || continue
310
+ file_seen=" "
311
+ line_no=0
312
+ while IFS= read -r line || [[ -n "$line" ]]; do
313
+ line_no=$((line_no + 1))
314
+ content="${line%%#*}"
315
+ [[ -n "${content//[[:space:]]/}" ]] || continue
316
+ if ! [[ "$content" =~ ^([a-z][a-z0-9-]*):[[:space:]]*(.*)$ ]]; then
317
+ printf 'FAIL line-%d invalid-line\n' "$line_no"
318
+ invalid=$((invalid + 1))
319
+ continue
320
+ fi
321
+ lane="${BASH_REMATCH[1]}"
322
+ chain="${BASH_REMATCH[2]}"
323
+ if [[ "$file_seen" == *" $lane "* ]]; then
324
+ printf 'FAIL %s duplicate-lane\n' "$lane"
325
+ invalid=$((invalid + 1))
326
+ continue
327
+ fi
328
+ file_seen="$file_seen$lane "
329
+ [[ "$effective_seen" == *" $lane "* ]] && continue
330
+ effective_seen="$effective_seen$lane "
331
+ IFS='|' read -ra SEGS <<< "$chain"
332
+ total="${#SEGS[@]}"
333
+ if [[ "$total" -eq 0 ]]; then
334
+ printf 'FAIL %s empty-chain\n' "$lane"
335
+ invalid=$((invalid + 1))
336
+ continue
337
+ fi
338
+ selected=0
339
+ lane_invalid=0
340
+ i=0
341
+ for seg in "${SEGS[@]}"; do
342
+ i=$((i + 1))
343
+ if [[ -z "${seg//[[:space:]]/}" ]]; then
344
+ printf 'FAIL %s candidate=%d empty-segment\n' "$lane" "$i"
345
+ lane_invalid=1
346
+ break
347
+ fi
348
+ if ! parse_lane_segment "$seg"; then
349
+ printf 'FAIL %s candidate=%d malformed-quotes\n' "$lane" "$i"
350
+ lane_invalid=1
351
+ break
352
+ fi
353
+ F=("${PARSED_FIELDS[@]}")
354
+ vendor="${F[0]:-}"
355
+ if [[ "$vendor" == "off" ]]; then
356
+ if [[ "${#F[@]}" -ne 1 && "${#F[@]}" -ne 3 ]]; then
357
+ printf 'FAIL %s candidate=%d field-count=%d\n' "$lane" "$i" "${#F[@]}"
358
+ lane_invalid=1
359
+ break
360
+ fi
361
+ elif [[ "${#F[@]}" -ne 3 ]]; then
362
+ printf 'FAIL %s candidate=%d field-count=%d\n' "$lane" "$i" "${#F[@]}"
363
+ lane_invalid=1
364
+ break
365
+ fi
366
+ if ! [[ "$vendor" =~ ^(codex|claude|grok|gemini|exec|off)$ ]]; then
367
+ printf 'FAIL %s unknown-vendor=%s\n' "$lane" "$vendor"
368
+ lane_invalid=1
369
+ break
370
+ fi
371
+ if printf '%s%s%s' "${F[0]}" "${F[1]:-}" "${F[2]:-}" | LC_ALL=C grep -q '[[:cntrl:]]'; then
372
+ printf 'FAIL %s candidate=%d control-character\n' "$lane" "$i"
373
+ lane_invalid=1
374
+ break
375
+ fi
376
+ if [[ "$selected" -eq 0 ]] && { [[ "$vendor" == "off" ]] || routing_candidate_available "$vendor" "${F[1]}"; }; then
377
+ selected="$i"
378
+ fi
379
+ done
380
+ if [[ "$lane_invalid" -eq 1 ]]; then
381
+ invalid=$((invalid + 1))
382
+ elif [[ "$selected" -eq 0 ]]; then
383
+ printf 'WARN %s no-candidate-available\n' "$lane"
384
+ unreachable=$((unreachable + 1))
385
+ else
386
+ parse_lane_segment "${SEGS[$((selected - 1))]}"
387
+ printf 'PASS %s selected=%d/%d vendor=%s\n' \
388
+ "$lane" "$selected" "$total" "${PARSED_FIELDS[0]}"
389
+ fi
390
+ done < "$f"
391
+ done
392
+ [[ "$invalid" -eq 0 ]] || return 2
393
+ [[ "$unreachable" -eq 0 ]] || return 4
394
+ return 0
395
+ }
396
+
397
+ # Inspection modes are deliberately parsed before dispatch flags. JSON may be
398
+ # placed before or after the inspection command, but can never decorate a real
399
+ # dispatch and therefore cannot accidentally create job state.
400
+ JSON_INSPECTION=0
401
+ if [[ "${1:-}" == "--json" ]]; then
402
+ JSON_INSPECTION=1
403
+ shift
404
+ [[ $# -gt 0 ]] || usage_error
405
+ fi
406
+ case "${1:-}" in
407
+ --help|-h)
408
+ [[ "$JSON_INSPECTION" -eq 0 && $# -eq 1 ]] || usage_error
409
+ print_usage
410
+ exit 0 ;;
411
+ --list)
412
+ if [[ "${2:-}" == "--json" ]]; then
413
+ [[ "$JSON_INSPECTION" -eq 0 && $# -eq 2 ]] || usage_error
414
+ JSON_INSPECTION=1
415
+ else
416
+ [[ $# -eq 1 ]] || usage_error
417
+ fi
418
+ if [[ "$JSON_INSPECTION" -eq 1 ]]; then
419
+ emit_json_inspection list print_effective_routing
420
+ fi
421
+ print_effective_routing
422
+ exit 0
423
+ ;;
424
+ --explain)
425
+ [[ $# -ge 2 ]] || usage_error
426
+ if [[ "${3:-}" == "--json" ]]; then
427
+ [[ "$JSON_INSPECTION" -eq 0 && $# -eq 3 ]] || usage_error
428
+ JSON_INSPECTION=1
429
+ else
430
+ [[ $# -eq 2 ]] || usage_error
431
+ fi
432
+ if [[ "$JSON_INSPECTION" -eq 1 ]]; then
433
+ emit_json_inspection explain explain_lane "$2"
434
+ fi
435
+ explain_lane "$2"
436
+ exit $?
437
+ ;;
438
+ --validate)
439
+ if [[ "${2:-}" == "--json" ]]; then
440
+ [[ "$JSON_INSPECTION" -eq 0 && $# -eq 2 ]] || usage_error
441
+ JSON_INSPECTION=1
442
+ else
443
+ [[ $# -eq 1 ]] || usage_error
444
+ fi
445
+ if [[ "$JSON_INSPECTION" -eq 1 ]]; then
446
+ emit_json_inspection validate validate_routing
447
+ fi
448
+ validate_routing
449
+ exit $?
450
+ ;;
451
+ *)
452
+ [[ "$JSON_INSPECTION" -eq 0 ]] || usage_error
453
+ ;;
454
+ esac
455
+
456
+ while [[ $# -gt 0 ]]; do
457
+ case "$1" in
458
+ --list|--explain|--validate|--json) usage_error ;;
459
+ --background) BACKGROUND=1; shift ;;
460
+ --dry-run) DRY_RUN=1; shift ;;
461
+ --mode|--workdir|--vendor|--model|--effort|--timeout|--job-timeout)
462
+ # Value-taking flags: a missing value must be a clean usage error (exit 2),
463
+ # not a `set -u` "unbound variable" crash on $2.
464
+ [[ $# -ge 2 ]] || { echo "omnilane: $1 needs a value" >&2; exit 2; }
465
+ case "$1" in
466
+ --mode) MODE="$2" ;;
467
+ --workdir) WORKDIR="$2" ;;
468
+ --vendor) OVERRIDE_VENDOR="$2" ;;
469
+ --model) OVERRIDE_MODEL="$2" ;;
470
+ --effort) OVERRIDE_EFFORT="$2" ;;
471
+ --timeout) OVERRIDE_TIMEOUT="$2" ;;
472
+ --job-timeout) OVERRIDE_JOB_TIMEOUT="$2" ;;
473
+ esac
474
+ shift 2 ;;
475
+ -*) echo "omnilane: unknown flag" >&2; exit 2 ;;
476
+ *) break ;;
477
+ esac
478
+ done
479
+
480
+ [[ $# -ge 1 ]] || usage_error
481
+ [[ $# -ge 2 ]] || { echo "omnilane: missing task text (use - for stdin)" >&2; exit 2; }
482
+ [[ $# -eq 2 ]] || {
483
+ echo 'omnilane: unexpected extra arguments; quote a multiword task' >&2
484
+ exit 2
485
+ }
486
+ LANE="$1"
487
+ TASK="$2"
488
+ [[ "$LANE" =~ ^[a-z][a-z0-9-]*$ ]] || { echo "omnilane: invalid lane name" >&2; exit 2; }
489
+ # A typo like --mode advice must not fall through to the write-enabled branch.
490
+ [[ "$MODE" == "advise" || "$MODE" == "work" ]] || { echo "omnilane: invalid --mode (advise|work)" >&2; exit 2; }
491
+ if [[ -n "$OVERRIDE_VENDOR" ]] &&
492
+ ! [[ "$OVERRIDE_VENDOR" =~ ^(codex|claude|grok|gemini)$ ]]; then
493
+ echo "omnilane: invalid vendor (codex|claude|grok|gemini)" >&2
494
+ exit 2
495
+ fi
496
+
497
+ depth_guard
498
+
499
+ CHAIN="$(raw_lane_line "$LANE")" || { echo "omnilane: unknown lane '$LANE' (try --list)" >&2; exit 2; }
500
+ if [[ -n "$OVERRIDE_VENDOR" ]]; then
501
+ if resolve_chain "$CHAIN" "$OVERRIDE_VENDOR"; then
502
+ :
503
+ else
504
+ resolve_rc=$?
505
+ case "$resolve_rc" in
506
+ 2) exit 2 ;;
507
+ 4)
508
+ echo "omnilane: requested vendor '$OVERRIDE_VENDOR' is configured for lane '$LANE' but its CLI is unavailable" >&2
509
+ exit 4
510
+ ;;
511
+ 5)
512
+ echo "omnilane: requested vendor '$OVERRIDE_VENDOR' is not configured for lane '$LANE'" >&2
513
+ exit 2
514
+ ;;
515
+ *)
516
+ echo "omnilane: could not resolve requested vendor '$OVERRIDE_VENDOR' for lane '$LANE'" >&2
517
+ exit 2
518
+ ;;
519
+ esac
520
+ fi
521
+ else
522
+ resolve_chain "$CHAIN" || {
523
+ echo "omnilane: no vendor CLI available for lane '$LANE' (chain:$CHAIN)." >&2
524
+ echo "omnilane: install a vendor CLI or override the lane in ~/.omnilane/routing.local.yaml" >&2
525
+ exit 4
526
+ }
527
+ fi
528
+
529
+ FIELDS=("${RESOLVED_FIELDS[@]}")
530
+ VENDOR="${FIELDS[0]}"; MODEL="${FIELDS[1]:-}"; EFFORT="${FIELDS[2]:-}"
531
+ [[ -n "$OVERRIDE_MODEL" ]] && MODEL="$OVERRIDE_MODEL"
532
+ [[ -n "$OVERRIDE_EFFORT" ]] && EFFORT="$OVERRIDE_EFFORT"
533
+
534
+ if [[ "$VENDOR" == "off" ]]; then
535
+ echo "omnilane: lane '$LANE' is disabled in routing config" >&2; exit 3
536
+ fi
537
+ RUNNER="$OMNILANE_REPO/scripts/runners/run-$VENDOR.sh"
538
+ [[ -x "$RUNNER" ]] || { echo "omnilane: no runner for vendor '$VENDOR'" >&2; exit 2; }
539
+
540
+ # Watchdog seconds: --timeout > per-lane OMNILANE_TIMEOUT_<LANE> > OMNILANE_TIMEOUT > 600.
541
+ # Resolve here and export so every runner (and vote's sub-runners) inherits the
542
+ # same value without a per-runner code change; they already read OMNILANE_TIMEOUT.
543
+ # This bounds each runner CLI call, not the whole dispatch (see header note).
544
+ TIMEOUT="$OVERRIDE_TIMEOUT"
545
+ if [[ -z "$TIMEOUT" ]]; then
546
+ LANE_TIMEOUT_VAR="OMNILANE_TIMEOUT_$(printf '%s' "${LANE//-/_}" | tr '[:lower:]' '[:upper:]')"
547
+ TIMEOUT="${!LANE_TIMEOUT_VAR:-}"
548
+ fi
549
+ [[ -n "$TIMEOUT" ]] || TIMEOUT="${OMNILANE_TIMEOUT:-600}"
550
+ [[ "$TIMEOUT" =~ ^[1-9][0-9]*$ ]] || {
551
+ echo "omnilane: invalid timeout (want a positive integer of seconds)" >&2; exit 2
552
+ }
553
+ export OMNILANE_TIMEOUT="$TIMEOUT"
554
+
555
+ JOB_SUPERVISOR="$OMNILANE_REPO/scripts/lib/job-timeout.pl"
556
+ JOB_WORKER="$OMNILANE_REPO/scripts/lib/job-worker.sh"
557
+
558
+ # Optional whole-job seconds: flag > per-lane env > global env > automatic
559
+ # non-Git Codex work guard > disabled.
560
+ # Validate lexically only. Bash arithmetic recursively evaluates variable text,
561
+ # so untrusted timeout text must never enter [[ -gt ]] or $((...)).
562
+ JOB_TIMEOUT="$OVERRIDE_JOB_TIMEOUT"
563
+ if [[ -z "$JOB_TIMEOUT" ]]; then
564
+ LANE_JOB_TIMEOUT_VAR="OMNILANE_JOB_TIMEOUT_$(printf '%s' "${LANE//-/_}" | tr '[:lower:]' '[:upper:]')"
565
+ JOB_TIMEOUT="${!LANE_JOB_TIMEOUT_VAR:-}"
566
+ fi
567
+ [[ -n "$JOB_TIMEOUT" ]] || JOB_TIMEOUT="${OMNILANE_JOB_TIMEOUT:-}"
568
+ CODEX_NONGIT_WORK=0
569
+ CODEX_NONGIT_AUTO_JOB_TIMEOUT=0
570
+ if [[ "$VENDOR" == "codex" && "$MODE" == "work" ]]; then
571
+ # The target directory is authoritative. Caller-supplied GIT_* state must not
572
+ # redirect or corrupt discovery, so probe with a minimal clean environment.
573
+ GIT_WORKTREE_STATE="$(env -i PATH="$PATH" HOME="${HOME:-}" \
574
+ git -C "$WORKDIR" rev-parse --is-inside-work-tree 2>/dev/null || true)"
575
+ if [[ "$GIT_WORKTREE_STATE" != "true" ]]; then
576
+ CODEX_NONGIT_WORK=1
577
+ if [[ -z "$JOB_TIMEOUT" ]]; then
578
+ if [[ -f "$JOB_SUPERVISOR" ]] && command -v perl &>/dev/null &&
579
+ perl -MPOSIX=setsid -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC -e 'exit 0' \
580
+ >/dev/null 2>&1; then
581
+ CODEX_NONGIT_AUTO_JOB_TIMEOUT=1
582
+ # Preserve the per-call budget while adding process-group cleanup. The
583
+ # supervisor accepts at most nine digits, so larger values receive its
584
+ # effective ceiling rather than making non-Git work unavailable.
585
+ if [[ "$TIMEOUT" =~ ^[1-9][0-9]{0,8}$ ]]; then
586
+ JOB_TIMEOUT="$TIMEOUT"
587
+ else
588
+ JOB_TIMEOUT=999999999
589
+ fi
590
+ else
591
+ echo "omnilane: automatic non-Git Codex job guard is unavailable; continuing without a whole-job fuse (the per-call watchdog path remains)" >&2
592
+ fi
593
+ fi
594
+ fi
595
+ fi
596
+ if [[ -n "$JOB_TIMEOUT" && ! "$JOB_TIMEOUT" =~ ^[1-9][0-9]{0,8}$ ]]; then
597
+ echo "omnilane: invalid job timeout (want 1..999999999 seconds)" >&2
598
+ exit 2
599
+ fi
600
+ JOB_TIMEOUT_JSON="${JOB_TIMEOUT:-null}"
601
+ unset OMNILANE_JOB_SUPERVISED
602
+ [[ -x "$JOB_WORKER" ]] || { echo "omnilane: internal job worker is unavailable" >&2; exit 2; }
603
+ if [[ -n "$JOB_TIMEOUT" ]]; then
604
+ [[ -f "$JOB_SUPERVISOR" ]] || { echo "omnilane: whole-job timeout supervisor is unavailable" >&2; exit 2; }
605
+ command -v perl &>/dev/null || { echo "omnilane: --job-timeout requires perl" >&2; exit 2; }
606
+ perl -MPOSIX=setsid -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC -e 'exit 0' \
607
+ >/dev/null 2>&1 || { echo "omnilane: perl lacks whole-job timeout support" >&2; exit 2; }
608
+ fi
609
+
610
+ JOBS_ROOT="$OMNILANE_HOME/jobs"
611
+ if [[ -L "$JOBS_ROOT" || ( -e "$JOBS_ROOT" && ! -d "$JOBS_ROOT" ) ]]; then
612
+ echo "omnilane: unsafe jobs store path (want a real directory): $JOBS_ROOT" >&2
613
+ exit 1
614
+ fi
615
+
616
+ if [[ "$DRY_RUN" -eq 1 ]]; then
617
+ print_dry_run_plan
618
+ exit 0
619
+ fi
620
+
621
+ mkdir -p "$OMNILANE_HOME"
622
+ if [[ ! -d "$JOBS_ROOT" ]]; then
623
+ mkdir -m 700 "$JOBS_ROOT"
624
+ fi
625
+ [[ -d "$JOBS_ROOT" && ! -L "$JOBS_ROOT" ]] || {
626
+ echo "omnilane: jobs store changed while preparing it" >&2
627
+ exit 1
628
+ }
629
+ # Prompts and model answers may contain private source code or credentials.
630
+ # Keep the privacy boundary on Omnilane's own job store instead of changing the
631
+ # runner umask, which would also affect files a model creates in --mode work.
632
+ chmod 700 "$JOBS_ROOT"
633
+ JOB_ID="$(date +%Y%m%d-%H%M%S)-$$-$RANDOM"
634
+ JOB_DIR="$JOBS_ROOT/$JOB_ID"
635
+ mkdir -m 700 "$JOB_DIR"
636
+
637
+ if [[ "$TASK" == "-" ]]; then
638
+ (umask 077; cat > "$JOB_DIR/task.txt")
639
+ else
640
+ (umask 077; printf '%s\n' "$TASK" > "$JOB_DIR/task.txt")
641
+ fi
642
+
643
+ # meta "timeout" is the resolved per-CLI-call watchdog cap, not a whole-job total.
644
+ (umask 077; printf '{"lane":"%s","vendor":"%s","model":"%s","effort":"%s","timeout":%s,"job_timeout":%s,"mode":"%s","workdir":"%s","candidate":"%s/%s","started":"%s"}\n' \
645
+ "$(json_escape "$LANE")" "$(json_escape "$VENDOR")" "$(json_escape "$MODEL")" \
646
+ "$(json_escape "$EFFORT")" "$TIMEOUT" "$JOB_TIMEOUT_JSON" "$(json_escape "$MODE")" \
647
+ "$(json_escape "$WORKDIR")" "$RESOLVED_IDX" "$RESOLVED_TOTAL" \
648
+ "$(date -u +%FT%TZ)" > "$JOB_DIR/meta.json")
649
+
650
+ secure_job_files() {
651
+ find "$JOB_DIR" -type f -exec chmod 600 {} +
652
+ }
653
+
654
+ finish_job() {
655
+ local rc="$1"
656
+ secure_job_files
657
+ (umask 077; printf '%s\n' "$rc" > "$JOB_DIR/exit")
658
+ }
659
+
660
+ run_job() {
661
+ local rc=0
662
+ write_current_pid_file "$JOB_DIR/pid"
663
+ set +e
664
+ if [[ -n "$JOB_TIMEOUT" ]]; then
665
+ OMNILANE_JOB_SUPERVISED=1 perl "$JOB_SUPERVISOR" "$JOB_TIMEOUT" \
666
+ "$JOB_WORKER" "$VENDOR" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" \
667
+ "$JOB_DIR/task.txt" "$JOB_DIR/out.txt"
668
+ else
669
+ "$JOB_WORKER" "$VENDOR" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" \
670
+ "$JOB_DIR/task.txt" "$JOB_DIR/out.txt"
671
+ fi
672
+ rc=$?
673
+ set -e
674
+ if [[ "$CODEX_NONGIT_WORK" -eq 1 && "$rc" -eq 124 ]]; then
675
+ echo "omnilane: Codex work in a non-Git directory timed out after ${JOB_TIMEOUT}s; the supervised process group was terminated" >&2
676
+ elif [[ "$CODEX_NONGIT_AUTO_JOB_TIMEOUT" -eq 1 && "$rc" -eq 142 ]]; then
677
+ # Equal implicit inner/outer deadlines can race. Only the automatic case is
678
+ # normalized; an explicitly longer whole-job fuse must preserve status 142.
679
+ echo "omnilane: Codex work in a non-Git directory timed out after ${TIMEOUT}s; the supervised process group was terminated" >&2
680
+ rc=124
681
+ fi
682
+ finish_job "$rc"
683
+ return "$rc"
684
+ }
685
+
686
+ if [[ "$BACKGROUND" == "1" ]]; then
687
+ # set -m gives the worker its own process group so it survives the caller's
688
+ # exit and group-wide signals; traps persist a best-effort exit code so
689
+ # jobs.sh never reports a killed worker as still running.
690
+ set -m
691
+ (umask 077; : > "$JOB_DIR/worker.log")
692
+ (
693
+ trap 'finish_job 129; exit 129' HUP
694
+ trap 'finish_job 143; exit 143' TERM
695
+ run_job
696
+ ) < /dev/null > "$JOB_DIR/worker.log" 2>&1 &
697
+ disown
698
+ set +m
699
+ echo "$JOB_ID"
700
+ exit 0
701
+ fi
702
+
703
+ set +e; run_job; RC=$?; set -e
704
+ [[ -f "$JOB_DIR/out.txt" ]] && cat "$JOB_DIR/out.txt"
705
+ exit "$RC"