omnilane 0.34.0 → 0.42.1
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/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +71 -1
- package/README.ja.md +63 -33
- package/README.ko.md +63 -32
- package/README.md +147 -86
- package/README.zh-CN.md +61 -30
- package/README.zh-TW.md +124 -75
- package/VERSION +1 -1
- package/config/aa-model-policy.json +3046 -0
- package/docs/aa-model-coverage-2026-09-05.json +29204 -0
- package/docs/completion-wakeup.md +126 -0
- package/docs/model-capabilities-2026-09.md +380 -0
- package/docs/native-executor.md +264 -0
- package/docs/release-notes-0.42.1.md +32 -0
- package/hooks/routing-instruction.md +101 -40
- package/package.json +8 -2
- package/plugin.json +2 -2
- package/routing.local.yaml.example +8 -3
- package/routing.yaml +16 -16
- package/scripts/completion-wakeup.py +390 -0
- package/scripts/configure.sh +4 -4
- package/scripts/dispatch.sh +323 -32
- package/scripts/doctor.sh +55 -1
- package/scripts/jobs.sh +64 -17
- package/scripts/lib/aa_policy.py +473 -0
- package/scripts/lib/aa_retry.py +77 -0
- package/scripts/lib/common.sh +106 -1
- package/scripts/lib/job-worker.sh +314 -20
- package/scripts/lib/live-protocol.sh +147 -2
- package/scripts/lib/native.py +507 -0
- package/scripts/lib/normalize-claude-stream.py +72 -0
- package/scripts/lib/prepare-agy-mode.py +374 -0
- package/scripts/release-audit.sh +103 -0
- package/scripts/runners/run-claude.sh +81 -47
- package/scripts/runners/run-codex-live.py +462 -0
- package/scripts/runners/run-codex.sh +62 -3
- package/scripts/runners/run-gemini.sh +85 -10
- package/scripts/runners/run-grok-live.py +426 -0
- package/scripts/runners/run-grok.sh +117 -6
- package/scripts/runners/run-vote.sh +6 -3
- package/skills/omnilane/SKILL.md +217 -81
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reconstruct only a validated job-owned authorizer; never borrow retry shell identity."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
import aa_policy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def retry_args(directory):
|
|
11
|
+
root = Path(directory)
|
|
12
|
+
os.environ.pop("OMNILANE_AA_TRANSPORT_OVERLAY", None)
|
|
13
|
+
os.environ.pop("OMNILANE_AA_OVERLAY_SHA256", None)
|
|
14
|
+
lineage, _ = aa_policy._read_json(root / "aa-lineage.json")
|
|
15
|
+
aa_policy._check(lineage.get("schema_version") == 1, "unsupported retry lineage")
|
|
16
|
+
registry_path = root / "aa-registry.json"
|
|
17
|
+
registry, _ = aa_policy.load_registry(registry_path, lineage["registry_sha256"])
|
|
18
|
+
result = ["--aa-policy", str(registry_path)]
|
|
19
|
+
original = None
|
|
20
|
+
original_limit = 100
|
|
21
|
+
if lineage.get("operator_asserted_human") is not True:
|
|
22
|
+
context = root / "aa-authorizer.json"
|
|
23
|
+
aa_policy._check(isinstance(lineage.get("authorizer_sha256"), str), "missing retry authorizer hash")
|
|
24
|
+
original, _ = aa_policy.load_caller(context, registry, lineage["authorizer_sha256"])
|
|
25
|
+
rows = aa_policy._matching_rows(registry, original["caller"])
|
|
26
|
+
aa_policy._check(len(rows) == 1, "unknown original retry authorizer")
|
|
27
|
+
original_limit = min(rows[0]["score"], original["inherited_ceiling"])
|
|
28
|
+
current_path = os.environ.get("OMNILANE_AA_CALLER_CONTEXT")
|
|
29
|
+
current_human = os.environ.get("OMNILANE_AA_OPERATOR_ASSERTED_HUMAN") == "1"
|
|
30
|
+
aa_policy._check(bool(current_path) != current_human, "retry requires one current caller context or explicit current human assertion")
|
|
31
|
+
if current_path:
|
|
32
|
+
current, _ = aa_policy.load_caller(current_path, registry)
|
|
33
|
+
rows = aa_policy._matching_rows(registry, current["caller"])
|
|
34
|
+
aa_policy._check(len(rows) == 1, "unknown current retry caller")
|
|
35
|
+
narrowed = dict(current)
|
|
36
|
+
narrowed["inherited_ceiling"] = min(original_limit, current["inherited_ceiling"], rows[0]["score"])
|
|
37
|
+
digest = aa_policy.hashlib.sha256(aa_policy._json_line(narrowed).encode()).hexdigest()
|
|
38
|
+
context = root / ("aa-retry-authorizer-" + digest + ".json")
|
|
39
|
+
aa_policy.atomic_json(context, narrowed)
|
|
40
|
+
result += ["--caller-context", str(context)]
|
|
41
|
+
elif original is not None:
|
|
42
|
+
result += ["--caller-context", str(root / "aa-authorizer.json")]
|
|
43
|
+
else:
|
|
44
|
+
result += ["--operator-asserted-human"]
|
|
45
|
+
if lineage.get("transport_overlay_sha256"):
|
|
46
|
+
overlay = root / "aa-transport-overlay.json"
|
|
47
|
+
_, digest = aa_policy._read_json(overlay)
|
|
48
|
+
aa_policy._check(digest == lineage["transport_overlay_sha256"], "retry transport overlay changed")
|
|
49
|
+
result += ["--transport-overlay", str(overlay)]
|
|
50
|
+
config = lineage.get("target_config_id")
|
|
51
|
+
if config:
|
|
52
|
+
aa_policy._check(isinstance(config, str) and aa_policy.IDENTIFIER.fullmatch(config), "invalid retry target config")
|
|
53
|
+
result += ["--target-config", config]
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def metadata_fields(directory):
|
|
58
|
+
value, _ = aa_policy._read_json(Path(directory) / "meta.json")
|
|
59
|
+
keys = ("lane", "vendor", "model", "effort", "timeout", "job_timeout", "mode", "workdir")
|
|
60
|
+
aa_policy._check(value.get("mode") in ("advise", "work"), "invalid retry mode")
|
|
61
|
+
aa_policy._check(type(value.get("timeout")) is int and value["timeout"] > 0, "invalid retry timeout")
|
|
62
|
+
aa_policy._check(value.get("job_timeout") is None or (type(value["job_timeout"]) is int and value["job_timeout"] > 0), "invalid retry job timeout")
|
|
63
|
+
result = []
|
|
64
|
+
for key in keys:
|
|
65
|
+
field = value[key]
|
|
66
|
+
text = "null" if field is None else str(field)
|
|
67
|
+
aa_policy._check("\n" not in text and "\r" not in text and "\x00" not in text, "invalid retry metadata value")
|
|
68
|
+
result.append(text)
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
try:
|
|
74
|
+
print("\n".join(metadata_fields(sys.argv[2]) if sys.argv[1] == "--metadata" else retry_args(sys.argv[1])))
|
|
75
|
+
except (aa_policy.PolicyError, OSError, KeyError, TypeError):
|
|
76
|
+
print("omnilane: retry requires intact AA lineage; current caller is missing or original authorizer/registry is changed", file=sys.stderr)
|
|
77
|
+
sys.exit(3)
|
package/scripts/lib/common.sh
CHANGED
|
@@ -37,6 +37,110 @@ json_escape() {
|
|
|
37
37
|
printf '%s' "$out"
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
json_file_round_trip_valid() {
|
|
41
|
+
local path="$1"
|
|
42
|
+
local valid=1
|
|
43
|
+
if [[ ! -f "$path" || -L "$path" ]]; then
|
|
44
|
+
valid=0
|
|
45
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
46
|
+
python3 - "$path" <<'PY' >/dev/null 2>&1 || valid=0
|
|
47
|
+
import json
|
|
48
|
+
import pathlib
|
|
49
|
+
import sys
|
|
50
|
+
|
|
51
|
+
value = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
52
|
+
json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
53
|
+
PY
|
|
54
|
+
elif command -v perl >/dev/null 2>&1; then
|
|
55
|
+
perl -MJSON::PP -e '
|
|
56
|
+
use strict;
|
|
57
|
+
use warnings;
|
|
58
|
+
my ($path) = @ARGV;
|
|
59
|
+
open my $fh, "<", $path or die $!;
|
|
60
|
+
local $/;
|
|
61
|
+
my $value = decode_json(<$fh>);
|
|
62
|
+
JSON::PP->new->canonical->encode($value);
|
|
63
|
+
' "$path" >/dev/null 2>&1 || valid=0
|
|
64
|
+
else
|
|
65
|
+
valid=0
|
|
66
|
+
fi
|
|
67
|
+
if [[ "$valid" -ne 1 ]]; then
|
|
68
|
+
printf 'omnilane: JSON round-trip validation failed for %s\n' "$path" >&2
|
|
69
|
+
return 1
|
|
70
|
+
fi
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
file_sha256() {
|
|
74
|
+
local path="$1"
|
|
75
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
76
|
+
sha256sum "$path" | awk '{print $1}'
|
|
77
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
78
|
+
shasum -a 256 "$path" | awk '{print $1}'
|
|
79
|
+
else
|
|
80
|
+
printf 'omnilane: no sha256sum/shasum available for worker provenance\n' >&2
|
|
81
|
+
return 1
|
|
82
|
+
fi
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Re-run the same provider-free AA decision immediately before a runner or one
|
|
86
|
+
# of its internal constituents/retries. Inputs are cooperative workflow
|
|
87
|
+
# metadata; hashes detect mutation after dispatch but do not authenticate the
|
|
88
|
+
# operator or prove the provider's actual model identity.
|
|
89
|
+
aa_policy_gate() {
|
|
90
|
+
local vendor="$1" model="$2" effort="$3" target_config="${4:-${OMNILANE_AA_TARGET_CONFIG:-}}"
|
|
91
|
+
local policy_file="${OMNILANE_AA_POLICY_FILE:-$OMNILANE_REPO/config/aa-model-policy.json}" output rc=0
|
|
92
|
+
local authorizer="${OMNILANE_AA_AUTHORIZER_CONTEXT-${OMNILANE_AA_CALLER_CONTEXT:-}}"
|
|
93
|
+
local authorizer_sha="${OMNILANE_AA_AUTHORIZER_SHA256-${OMNILANE_AA_CALLER_SHA256:-}}"
|
|
94
|
+
local args=()
|
|
95
|
+
if [[ "$vendor" == "vote" && "${OMNILANE_AA_VOTE_PREFLIGHT:-0}" == "1" ]]; then
|
|
96
|
+
return 0
|
|
97
|
+
fi
|
|
98
|
+
[[ -n "$policy_file" ]] || {
|
|
99
|
+
echo 'omnilane: AA policy metadata missing before provider invocation' >&2
|
|
100
|
+
return 3
|
|
101
|
+
}
|
|
102
|
+
args=(--registry "$policy_file" --vendor "$vendor" --model "$model" --effort "$effort")
|
|
103
|
+
[[ -z "${OMNILANE_AA_REGISTRY_SHA256:-}" ]] ||
|
|
104
|
+
args+=(--expected-registry-sha256 "$OMNILANE_AA_REGISTRY_SHA256")
|
|
105
|
+
[[ -z "$target_config" ]] || args+=(--target-config "$target_config")
|
|
106
|
+
if [[ "${OMNILANE_AA_AUTHORIZER_HUMAN-${OMNILANE_AA_OPERATOR_ASSERTED_HUMAN:-0}}" == "1" ]]; then
|
|
107
|
+
args+=(--operator-asserted-human)
|
|
108
|
+
elif [[ -n "$authorizer" ]]; then
|
|
109
|
+
args+=(--caller-context "$authorizer")
|
|
110
|
+
[[ -z "$authorizer_sha" ]] ||
|
|
111
|
+
args+=(--expected-caller-sha256 "$authorizer_sha")
|
|
112
|
+
fi
|
|
113
|
+
output="$(python3 "$OMNILANE_REPO/scripts/lib/aa_policy.py" "${args[@]}")" || rc=$?
|
|
114
|
+
if [[ "$rc" -ne 0 ]]; then
|
|
115
|
+
printf '%s\n' "$output" >&2
|
|
116
|
+
return "$rc"
|
|
117
|
+
fi
|
|
118
|
+
if [[ -n "${OMNILANE_AA_CONTEXT_DIR:-}" ]]; then
|
|
119
|
+
local child_path
|
|
120
|
+
child_path="$(python3 - "$OMNILANE_REPO" "$OMNILANE_AA_CONTEXT_DIR" "$output" <<'AA_CHILD'
|
|
121
|
+
import hashlib, json, sys
|
|
122
|
+
from pathlib import Path
|
|
123
|
+
sys.path.insert(0, str(Path(sys.argv[1]) / "scripts/lib"))
|
|
124
|
+
import aa_policy
|
|
125
|
+
decision = json.loads(sys.argv[3])
|
|
126
|
+
child = decision.get("child_context")
|
|
127
|
+
if child is not None:
|
|
128
|
+
name = "aa-child-" + hashlib.sha256(aa_policy._json_line(child).encode()).hexdigest() + ".json"
|
|
129
|
+
path = Path(sys.argv[2]) / name
|
|
130
|
+
aa_policy.atomic_json(path, child)
|
|
131
|
+
print(path)
|
|
132
|
+
AA_CHILD
|
|
133
|
+
)" || return $?
|
|
134
|
+
export OMNILANE_AA_CALLER_CONTEXT="$child_path"
|
|
135
|
+
export OMNILANE_AA_CALLER_SHA256=""
|
|
136
|
+
if [[ -n "$child_path" ]]; then
|
|
137
|
+
OMNILANE_AA_CALLER_SHA256="$(file_sha256 "$child_path")"
|
|
138
|
+
export OMNILANE_AA_CALLER_SHA256
|
|
139
|
+
fi
|
|
140
|
+
fi
|
|
141
|
+
return 0
|
|
142
|
+
}
|
|
143
|
+
|
|
40
144
|
resolve_timeout_cmd() {
|
|
41
145
|
if command -v timeout &>/dev/null; then echo "timeout";
|
|
42
146
|
elif command -v gtimeout &>/dev/null; then echo "gtimeout";
|
|
@@ -456,7 +560,8 @@ acquire_cwd_lock() { # vendor, workdir — the lock keys on the TARGET dir, not
|
|
|
456
560
|
empty_since=-1
|
|
457
561
|
fi
|
|
458
562
|
if [[ "$waited" -ge "$lock_timeout" ]]; then
|
|
459
|
-
echo "omnilane: lock timeout for $vendor in $dir" >&2
|
|
563
|
+
echo "omnilane: lock timeout for $vendor in $dir; advise/read-only tasks may use a different --workdir to avoid this lock" >&2
|
|
564
|
+
exit 87
|
|
460
565
|
fi
|
|
461
566
|
sleep 2; waited=$((waited + 2))
|
|
462
567
|
done
|
|
@@ -4,9 +4,19 @@ set -euo pipefail
|
|
|
4
4
|
# Internal worker boundary for one dispatch. An optional whole-job supervisor
|
|
5
5
|
# wraps this process so lock wait, retries, and vote rounds share one budget.
|
|
6
6
|
|
|
7
|
-
#
|
|
7
|
+
# A per-job worker snapshot lives outside the repository tree. The dispatcher
|
|
8
|
+
# pins its library root explicitly; direct invocations remain runtime-relative.
|
|
9
|
+
JOB_WORKER_REPO="${OMNILANE_JOB_WORKER_REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
|
8
10
|
# shellcheck disable=SC1091
|
|
9
|
-
source "$
|
|
11
|
+
source "$JOB_WORKER_REPO/scripts/lib/common.sh"
|
|
12
|
+
if [[ -n "${OMNILANE_JOB_WORKER_EXPECTED_SHA256:-}" ]]; then
|
|
13
|
+
JOB_WORKER_STARTUP_SHA256="$(file_sha256 "${BASH_SOURCE[0]}")" || exit 2
|
|
14
|
+
if [[ "$JOB_WORKER_STARTUP_SHA256" != "$OMNILANE_JOB_WORKER_EXPECTED_SHA256" ]]; then
|
|
15
|
+
echo "omnilane: job worker snapshot SHA changed before startup" >&2
|
|
16
|
+
exit 2
|
|
17
|
+
fi
|
|
18
|
+
fi
|
|
19
|
+
unset OMNILANE_JOB_WORKER_REPO OMNILANE_JOB_WORKER_EXPECTED_SHA256
|
|
10
20
|
# shellcheck disable=SC1091
|
|
11
21
|
source "$OMNILANE_REPO/scripts/lib/live-protocol.sh"
|
|
12
22
|
|
|
@@ -33,6 +43,7 @@ emit_mode_notice() {
|
|
|
33
43
|
|
|
34
44
|
run_single_shot() {
|
|
35
45
|
local notice="$1" rc
|
|
46
|
+
aa_policy_gate "$VENDOR" "$MODEL" "$EFFORT" || return $?
|
|
36
47
|
set +e
|
|
37
48
|
(
|
|
38
49
|
unset OMNILANE_INBOX
|
|
@@ -56,13 +67,41 @@ LIVE_REQUIRED="${OMNILANE_LIVE_REQUIRED:-0}"
|
|
|
56
67
|
[[ "$LIVE_REQUIRED" == "0" || "$LIVE_REQUIRED" == "1" ]] || {
|
|
57
68
|
echo "omnilane: invalid worker live requirement" >&2; exit 2
|
|
58
69
|
}
|
|
70
|
+
if [[ "$VENDOR" == "grok" && "$MODE" != "sysops" && "$SESSION_MODE" != "single-shot" ]]; then
|
|
71
|
+
if [[ "$LIVE_REQUIRED" -eq 1 ]]; then
|
|
72
|
+
echo "omnilane: Grok --live supports only explicit --mode sysops; restricted ACP policy is unavailable" >&2
|
|
73
|
+
exit 2
|
|
74
|
+
fi
|
|
75
|
+
SESSION_MODE="single-shot"
|
|
76
|
+
fi
|
|
59
77
|
if [[ "$SESSION_MODE" == "auto" ]]; then
|
|
60
|
-
|
|
78
|
+
if [[ "$VENDOR" == "codex" || "$VENDOR" == "grok" ]]; then
|
|
79
|
+
SESSION_MODE="single-shot"
|
|
80
|
+
elif live_vendor_capable "$VENDOR"; then
|
|
81
|
+
SESSION_MODE="live"
|
|
82
|
+
else
|
|
83
|
+
SESSION_MODE="single-shot"
|
|
84
|
+
fi
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
LIVE_SURFACE_FALLBACK=""
|
|
88
|
+
if [[ "$VENDOR" == "codex" && "$SESSION_MODE" != "single-shot" ]]; then
|
|
89
|
+
if ! codex_live_surface_available "${CODEX_BIN:-codex}"; then
|
|
90
|
+
SESSION_MODE="single-shot"
|
|
91
|
+
LIVE_SURFACE_FALLBACK="codex"
|
|
92
|
+
fi
|
|
93
|
+
elif [[ "$VENDOR" == "grok" && "$SESSION_MODE" != "single-shot" ]]; then
|
|
94
|
+
if ! grok_live_surface_available "${GROK_BIN:-grok}"; then
|
|
95
|
+
SESSION_MODE="single-shot"
|
|
96
|
+
LIVE_SURFACE_FALLBACK="grok"
|
|
97
|
+
fi
|
|
61
98
|
fi
|
|
62
99
|
|
|
63
100
|
if [[ "$SESSION_MODE" == "single-shot" ]]; then
|
|
64
101
|
set +e
|
|
65
|
-
if
|
|
102
|
+
if [[ -n "$LIVE_SURFACE_FALLBACK" ]]; then
|
|
103
|
+
run_single_shot "omnilane: $LIVE_SURFACE_FALLBACK live surface unavailable; ran in single-shot mode"
|
|
104
|
+
elif live_vendor_capable "$VENDOR"; then
|
|
66
105
|
run_single_shot "omnilane: vendor '$VENDOR' was resolved to single-shot mode"
|
|
67
106
|
else
|
|
68
107
|
run_single_shot "omnilane: vendor '$VENDOR' is not live-capable; ran in single-shot mode"
|
|
@@ -87,14 +126,24 @@ EVENTS_FILE="${OUTPUT_FILE}.events.jsonl"
|
|
|
87
126
|
EVENTS_ALIAS="$JOB_DIR/events.jsonl"
|
|
88
127
|
close_requested=0
|
|
89
128
|
close_reason=""
|
|
129
|
+
close_drain_failed=0
|
|
130
|
+
close_partial=""
|
|
131
|
+
natural_runner_exit=0
|
|
132
|
+
CLOSE_DRAIN_TIMEOUT=0.1
|
|
133
|
+
CLOSE_RUNNER_GRACE=7.5
|
|
134
|
+
CLOSE_TERM_GRACE=0.1
|
|
135
|
+
CLOSE_KILL_GRACE=0.1
|
|
90
136
|
runner_writer_open=0
|
|
91
137
|
inbox_open=0
|
|
92
138
|
events_reader_open=0
|
|
139
|
+
forward_spool_open=0
|
|
93
140
|
runner_pid=""
|
|
94
141
|
|
|
95
142
|
# Invoked by the EXIT trap below.
|
|
96
143
|
# shellcheck disable=SC2329
|
|
97
144
|
cleanup_live_mailbox() {
|
|
145
|
+
if [[ -n "${forward_spool:-}" ]]; then rm "$forward_spool" 2>/dev/null || true; fi
|
|
146
|
+
if [[ "$forward_spool_open" -eq 1 ]]; then exec 6<&-; exec 7>&-; forward_spool_open=0; fi
|
|
98
147
|
if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
|
|
99
148
|
if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
|
|
100
149
|
if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
|
|
@@ -126,15 +175,52 @@ prepare_live_mailbox() {
|
|
|
126
175
|
return "$rc"
|
|
127
176
|
}
|
|
128
177
|
|
|
178
|
+
# The Claude normalizer appends a result event of its own when it recovers a
|
|
179
|
+
# partial transcript, so that marker is what separates "the turn finished" from
|
|
180
|
+
# "we salvaged what the model had written so far".
|
|
181
|
+
live_event_is_vendor_result() {
|
|
182
|
+
local event="$1"
|
|
183
|
+
live_event_is_result "$VENDOR" "$event" || return 1
|
|
184
|
+
[[ "$event" != *'"normalized_by"'* ]]
|
|
185
|
+
}
|
|
186
|
+
|
|
129
187
|
last_result_status() {
|
|
130
|
-
local event last_result=""
|
|
188
|
+
local event last_result="" vendor_only="${1:-0}"
|
|
131
189
|
while IFS= read -r event || [[ -n "$event" ]]; do
|
|
132
|
-
if
|
|
190
|
+
if [[ "$vendor_only" -eq 1 ]]; then
|
|
191
|
+
if live_event_is_vendor_result "$event"; then last_result="$event"; fi
|
|
192
|
+
elif live_event_is_result "$VENDOR" "$event"; then
|
|
193
|
+
last_result="$event"
|
|
194
|
+
fi
|
|
133
195
|
done < "$EVENTS_FILE"
|
|
134
196
|
[[ -n "$last_result" ]] || return 1
|
|
135
197
|
live_event_is_success "$VENDOR" "$last_result"
|
|
136
198
|
}
|
|
137
199
|
|
|
200
|
+
close_had_result() {
|
|
201
|
+
local vendor_only="$1"
|
|
202
|
+
if [[ -n "$last_result_event" ]] \
|
|
203
|
+
&& { [[ "$vendor_only" -eq 0 ]] || live_event_is_vendor_result "$last_result_event"; } \
|
|
204
|
+
&& live_event_is_success "$VENDOR" "$last_result_event"; then
|
|
205
|
+
return 0
|
|
206
|
+
fi
|
|
207
|
+
last_result_status "$vendor_only"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
recover_close_result_output() {
|
|
211
|
+
# Claude's stream-json process may remain resident after stdin EOF. The
|
|
212
|
+
# worker then enforces its bounded close deadline, so the runner's signal
|
|
213
|
+
# trap is not guaranteed enough time to normalize the already-completed
|
|
214
|
+
# result. Preserve that successful vendor result before declaring the job
|
|
215
|
+
# done; never synthesize output for an incomplete turn or overwrite output
|
|
216
|
+
# the runner already committed.
|
|
217
|
+
[[ "$VENDOR" == "claude" ]] || return 0
|
|
218
|
+
[[ ! -s "$OUTPUT_FILE" ]] || return 0
|
|
219
|
+
command -v python3 >/dev/null 2>&1 || return 1
|
|
220
|
+
python3 "$OMNILANE_REPO/scripts/lib/normalize-claude-stream.py" \
|
|
221
|
+
"$EVENTS_FILE" "$OUTPUT_FILE"
|
|
222
|
+
}
|
|
223
|
+
|
|
138
224
|
if ! prepare_live_mailbox; then
|
|
139
225
|
if [[ "$LIVE_REQUIRED" -eq 1 ]]; then
|
|
140
226
|
echo "omnilane: required $VENDOR live mailbox unavailable because FIFO setup failed" >&2
|
|
@@ -150,7 +236,72 @@ fi
|
|
|
150
236
|
trap 'close_requested=1' USR1
|
|
151
237
|
trap 'close_requested=1' PIPE
|
|
152
238
|
trap cleanup_live_mailbox EXIT
|
|
239
|
+
if [[ "$VENDOR" == "codex" ]]; then
|
|
240
|
+
# Allocate once before publishing the holder PID. Reader and writer offsets
|
|
241
|
+
# are independent; unlink immediately and keep this 0600 file private to us.
|
|
242
|
+
forward_spool="$(mktemp "$JOB_DIR/.forward.XXXXXX")" || exit 1
|
|
243
|
+
exec 6< "$forward_spool"
|
|
244
|
+
forward_spool_open=1
|
|
245
|
+
exec 7> "$forward_spool"
|
|
246
|
+
rm "$forward_spool"
|
|
247
|
+
forward_spool=""
|
|
248
|
+
fi
|
|
153
249
|
truncate_payload "$PROMPT_FILE" 102400
|
|
250
|
+
# Codex input must not be consumed by Bash read: an interrupted partial read
|
|
251
|
+
# can discard bytes before the close trap runs. Pump raw bytes instead. The
|
|
252
|
+
# anonymous file holds at most one 64 KiB chunk, with shared read offset on FD 6
|
|
253
|
+
# and an independent append/reset offset on FD 7. A close drains its remaining
|
|
254
|
+
# suffix first; successful sends truncate/reset it instead of growing a spool.
|
|
255
|
+
pump_codex_input() {
|
|
256
|
+
python3 - <<'PYPUMP'
|
|
257
|
+
import os
|
|
258
|
+
import select
|
|
259
|
+
import sys
|
|
260
|
+
import time
|
|
261
|
+
|
|
262
|
+
pending = b""
|
|
263
|
+
progress = False
|
|
264
|
+
|
|
265
|
+
def reset_spool():
|
|
266
|
+
os.ftruncate(7, 0)
|
|
267
|
+
os.lseek(6, 0, os.SEEK_SET)
|
|
268
|
+
os.lseek(7, 0, os.SEEK_SET)
|
|
269
|
+
|
|
270
|
+
os.set_blocking(3, False)
|
|
271
|
+
os.set_blocking(4, False)
|
|
272
|
+
try:
|
|
273
|
+
pending = os.read(6, 65536)
|
|
274
|
+
if not pending:
|
|
275
|
+
reset_spool()
|
|
276
|
+
readable, _, _ = select.select([4], [], [], 1)
|
|
277
|
+
if not readable:
|
|
278
|
+
sys.exit(3)
|
|
279
|
+
chunk = os.read(4, 65536)
|
|
280
|
+
if not chunk:
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
# FD 7 is a private regular file, never the backpressured FIFO.
|
|
283
|
+
while chunk:
|
|
284
|
+
chunk = chunk[os.write(7, chunk):]
|
|
285
|
+
pending = os.read(6, 65536)
|
|
286
|
+
progress = True
|
|
287
|
+
deadline = time.monotonic() + 0.05
|
|
288
|
+
while pending and time.monotonic() < deadline:
|
|
289
|
+
_, writable, _ = select.select([], [3], [], max(0, deadline - time.monotonic()))
|
|
290
|
+
if writable:
|
|
291
|
+
pending = pending[os.write(3, pending):]
|
|
292
|
+
progress = True
|
|
293
|
+
if not pending:
|
|
294
|
+
reset_spool()
|
|
295
|
+
except OSError:
|
|
296
|
+
if pending:
|
|
297
|
+
os.lseek(6, -len(pending), os.SEEK_CUR)
|
|
298
|
+
sys.exit(1)
|
|
299
|
+
if pending:
|
|
300
|
+
os.lseek(6, -len(pending), os.SEEK_CUR)
|
|
301
|
+
sys.exit((4 if progress else 2) if pending else 0)
|
|
302
|
+
PYPUMP
|
|
303
|
+
}
|
|
304
|
+
|
|
154
305
|
INITIAL_TEXT="$(cat "$PROMPT_FILE")"
|
|
155
306
|
if [[ "${FOREMAN_SESSION+x}" == "x" ]]; then
|
|
156
307
|
FOREMAN_SESSION_VALUE="$FOREMAN_SESSION"
|
|
@@ -160,7 +311,8 @@ fi
|
|
|
160
311
|
|
|
161
312
|
write_current_pid_file "$HOLDER_PID_FILE"
|
|
162
313
|
export OMNILANE_INBOX="$RUNNER_INBOX_FIFO"
|
|
163
|
-
|
|
314
|
+
aa_policy_gate "$VENDOR" "$MODEL" "$EFFORT" || exit $?
|
|
315
|
+
"$RUNNER" "$MODE" "$WORKDIR" "$MODEL" "$EFFORT" "$PROMPT_FILE" "$OUTPUT_FILE" 6<&- 7>&- &
|
|
164
316
|
runner_pid=$!
|
|
165
317
|
|
|
166
318
|
# Open the runner writer only after its FIFO reader starts.
|
|
@@ -175,16 +327,109 @@ if ! printf '%s\n' "$initial_payload" >&3; then close_requested=1; fi
|
|
|
175
327
|
(umask 077; : > "$READY_FILE")
|
|
176
328
|
|
|
177
329
|
last_activity=$SECONDS
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
330
|
+
last_result_event=""
|
|
331
|
+
# A dead Codex reader can leave accepted bytes in the public FIFO even when
|
|
332
|
+
# the previous pump was idle. Always take one final bounded drain in that case.
|
|
333
|
+
while kill -0 "$runner_pid" 2>/dev/null || [[ "$VENDOR" == "codex" || "$close_requested" -ne 0 ]]; do
|
|
334
|
+
if [[ "$VENDOR" == "codex" && "$close_requested" -eq 0 ]] && ! kill -0 "$runner_pid" 2>/dev/null; then
|
|
335
|
+
natural_runner_exit=1
|
|
336
|
+
close_requested=1
|
|
337
|
+
fi
|
|
338
|
+
if [[ "$close_requested" -ne 0 ]]; then
|
|
339
|
+
rm "$READY_FILE" 2>/dev/null || true
|
|
340
|
+
if [[ "$VENDOR" == "codex" ]]; then
|
|
341
|
+
# Bash 3.2 has no fractional read timeout. Bound the *whole* drain,
|
|
342
|
+
# including backpressure, rather than granting each line another second.
|
|
343
|
+
# Retain unforwarded bytes and report failure instead of silently dropping
|
|
344
|
+
# an accepted follow-up when the runner stalls or a writer never stops.
|
|
345
|
+
if ! CLOSE_PARTIAL="$close_partial" FORWARD_PENDING="$forward_spool_open" python3 - "$JOB_DIR/close-pending.jsonl" "$CLOSE_DRAIN_TIMEOUT" <<'PY'
|
|
346
|
+
import array
|
|
347
|
+
import fcntl
|
|
348
|
+
import os
|
|
349
|
+
import select
|
|
350
|
+
import sys
|
|
351
|
+
import termios
|
|
352
|
+
import time
|
|
353
|
+
|
|
354
|
+
deadline = time.monotonic() + float(sys.argv[2])
|
|
355
|
+
pending = b""
|
|
356
|
+
if os.environ.get("FORWARD_PENDING") == "1":
|
|
357
|
+
while True:
|
|
358
|
+
chunk = os.read(6, 65536)
|
|
359
|
+
if not chunk:
|
|
360
|
+
break
|
|
361
|
+
pending += chunk
|
|
362
|
+
pending += os.environ.get("CLOSE_PARTIAL", "").encode()
|
|
363
|
+
for fd in (3, 4):
|
|
364
|
+
os.set_blocking(fd, False)
|
|
365
|
+
try:
|
|
366
|
+
while time.monotonic() < deadline:
|
|
367
|
+
readable, writable, _ = select.select([4], [3] if pending else [], [], 0)
|
|
368
|
+
if not readable and not pending:
|
|
369
|
+
sys.exit(0)
|
|
370
|
+
if readable and len(pending) < 65536:
|
|
371
|
+
pending += os.read(4, 65536 - len(pending))
|
|
372
|
+
if pending:
|
|
373
|
+
_, writable, _ = select.select([], [3], [], max(0, deadline - time.monotonic()))
|
|
374
|
+
if writable:
|
|
375
|
+
pending = pending[os.write(3, pending):]
|
|
376
|
+
except (BrokenPipeError, OSError):
|
|
377
|
+
pass
|
|
378
|
+
# Snapshot only bytes already queued at the deadline; a continuous writer
|
|
379
|
+
# cannot extend this phase. New send callers no longer see inbox.ready.
|
|
380
|
+
available = array.array("i", [0])
|
|
381
|
+
fcntl.ioctl(4, termios.FIONREAD, available, True)
|
|
382
|
+
remaining = available[0]
|
|
383
|
+
while remaining:
|
|
384
|
+
chunk = os.read(4, min(remaining, 65536))
|
|
385
|
+
if not chunk:
|
|
386
|
+
break
|
|
387
|
+
pending += chunk
|
|
388
|
+
remaining -= len(chunk)
|
|
389
|
+
if pending:
|
|
390
|
+
fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
391
|
+
with os.fdopen(fd, "wb") as saved:
|
|
392
|
+
saved.write(pending)
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
PY
|
|
395
|
+
then
|
|
396
|
+
close_drain_failed=1
|
|
397
|
+
emit_mode_notice "omnilane: close drain incomplete; unforwarded input retained in $JOB_DIR/close-pending.jsonl" || true
|
|
398
|
+
fi
|
|
399
|
+
fi
|
|
400
|
+
break
|
|
401
|
+
fi
|
|
402
|
+
if [[ "$VENDOR" == "codex" ]]; then
|
|
403
|
+
if pump_codex_input; then
|
|
404
|
+
last_activity=$SECONDS
|
|
405
|
+
else
|
|
406
|
+
pump_status=$?
|
|
407
|
+
# 2/4 retain a suffix (4 made progress); 3 is an idle poll. A reader
|
|
408
|
+
# that dies with pending bytes still enters the close drain, even after
|
|
409
|
+
# its PID disappears, so those bytes are retained rather than dropped.
|
|
410
|
+
if [[ "$pump_status" -eq 2 || "$pump_status" -eq 4 ]]; then
|
|
411
|
+
[[ "$pump_status" -ne 4 ]] || last_activity=$SECONDS
|
|
412
|
+
kill -0 "$runner_pid" 2>/dev/null || close_requested=1
|
|
413
|
+
elif [[ "$pump_status" -ne 3 ]]; then
|
|
414
|
+
close_requested=1
|
|
415
|
+
fi
|
|
416
|
+
fi
|
|
417
|
+
else
|
|
418
|
+
incoming=""
|
|
419
|
+
if IFS= read -r -t 1 incoming <&4; then
|
|
420
|
+
if ! printf '%s\n' "$incoming" >&3; then close_requested=1; fi
|
|
421
|
+
last_activity=$SECONDS
|
|
422
|
+
elif [[ "$close_requested" -ne 0 && -n "$incoming" ]]; then
|
|
423
|
+
close_partial="$incoming"
|
|
424
|
+
fi
|
|
184
425
|
fi
|
|
185
426
|
while IFS= read -r event <&5; do
|
|
186
|
-
|
|
427
|
+
[[ "$close_requested" -eq 0 ]] || break
|
|
428
|
+
if live_event_is_valid "$event"; then
|
|
187
429
|
last_activity=$SECONDS
|
|
430
|
+
if live_event_is_result "$VENDOR" "$event"; then
|
|
431
|
+
last_result_event="$event"
|
|
432
|
+
fi
|
|
188
433
|
fi
|
|
189
434
|
done
|
|
190
435
|
if [[ "$IDLE_TIMEOUT" -gt 0 && $((SECONDS - last_activity)) -ge "$IDLE_TIMEOUT" ]]; then
|
|
@@ -193,16 +438,47 @@ while kill -0 "$runner_pid" 2>/dev/null; do
|
|
|
193
438
|
fi
|
|
194
439
|
done
|
|
195
440
|
|
|
441
|
+
if [[ "$forward_spool_open" -eq 1 ]]; then exec 6<&-; exec 7>&-; forward_spool_open=0; fi
|
|
196
442
|
if [[ "$runner_writer_open" -eq 1 ]]; then exec 3>&-; runner_writer_open=0; fi
|
|
197
443
|
if [[ "$inbox_open" -eq 1 ]]; then exec 4>&-; inbox_open=0; fi
|
|
198
444
|
if [[ "$events_reader_open" -eq 1 ]]; then exec 5<&-; events_reader_open=0; fi
|
|
199
445
|
|
|
200
446
|
set +e
|
|
201
447
|
if [[ "$close_requested" -eq 1 ]]; then
|
|
202
|
-
|
|
448
|
+
# 0.1s drain + 7.5s grace + 0.1s TERM + 0.1s KILL = 7.8s.
|
|
449
|
+
# With the input pump's 1s read + 0.05s write, 8.85s also precedes
|
|
450
|
+
# the shortest ~9s interval of jobs close's integer SECONDS + 10 deadline.
|
|
451
|
+
# Codex retains its full 3+2+1+1=7s normal shutdown budget.
|
|
452
|
+
# A monotonic timer avoids accumulating 75 shell/sleep launch overheads.
|
|
453
|
+
if ! perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC,sleep -e '
|
|
454
|
+
my ($pid, $grace, $term, $kill) = @ARGV;
|
|
455
|
+
sub wait_until {
|
|
456
|
+
my ($pid, $duration) = @_;
|
|
457
|
+
my $deadline = clock_gettime(CLOCK_MONOTONIC) + $duration;
|
|
458
|
+
while (kill 0, $pid) {
|
|
459
|
+
my $left = $deadline - clock_gettime(CLOCK_MONOTONIC);
|
|
460
|
+
return 0 if $left <= 0;
|
|
461
|
+
sleep($left < 0.02 ? $left : 0.02);
|
|
462
|
+
}
|
|
463
|
+
return 1;
|
|
464
|
+
}
|
|
465
|
+
exit 0 if wait_until($pid, $grace);
|
|
466
|
+
kill "TERM", $pid;
|
|
467
|
+
exit 0 if wait_until($pid, $term);
|
|
468
|
+
kill "KILL", $pid;
|
|
469
|
+
exit(wait_until($pid, $kill) ? 0 : 1);
|
|
470
|
+
' "$runner_pid" "$CLOSE_RUNNER_GRACE" "$CLOSE_TERM_GRACE" "$CLOSE_KILL_GRACE"; then
|
|
471
|
+
close_drain_failed=1
|
|
472
|
+
fi
|
|
473
|
+
fi
|
|
474
|
+
if [[ "$close_requested" -eq 1 ]] && kill -0 "$runner_pid" 2>/dev/null; then
|
|
475
|
+
runner_rc=1
|
|
476
|
+
close_drain_failed=1
|
|
477
|
+
emit_mode_notice "omnilane: runner did not stop within close deadline" || true
|
|
478
|
+
else
|
|
479
|
+
wait "$runner_pid" 2>/dev/null
|
|
480
|
+
runner_rc=$?
|
|
203
481
|
fi
|
|
204
|
-
wait "$runner_pid" 2>/dev/null
|
|
205
|
-
runner_rc=$?
|
|
206
482
|
set -e
|
|
207
483
|
|
|
208
484
|
if [[ -n "$close_reason" ]]; then
|
|
@@ -210,9 +486,27 @@ if [[ -n "$close_reason" ]]; then
|
|
|
210
486
|
emit_mode_notice "$close_reason" || true
|
|
211
487
|
fi
|
|
212
488
|
|
|
213
|
-
if [[ "$
|
|
214
|
-
|
|
215
|
-
|
|
489
|
+
if [[ "$natural_runner_exit" -eq 1 ]]; then
|
|
490
|
+
# Draining after a natural/failed reader exit must not turn its exit status
|
|
491
|
+
# into success because an earlier turn happened to emit a result event.
|
|
492
|
+
rc="$runner_rc"
|
|
493
|
+
if [[ "$rc" -eq 0 && "$close_drain_failed" -ne 0 ]]; then rc=1; fi
|
|
494
|
+
elif [[ "$close_requested" -eq 1 ]]; then
|
|
495
|
+
# A vendor-emitted result always stands. A transcript the normalizer recovered
|
|
496
|
+
# stands only for an idle-cap close whose runner still exited on its own: that
|
|
497
|
+
# is the case the idle-cap recovery was written for, and out.txt carries the
|
|
498
|
+
# cap notice next to it. An operator close is an abort, so its recovered
|
|
499
|
+
# transcript is written but must not be reported as a finished turn.
|
|
500
|
+
recovered_counts=0
|
|
501
|
+
if [[ -n "$close_reason" && "$runner_rc" -eq 0 ]]; then recovered_counts=1; fi
|
|
502
|
+
if [[ "$close_drain_failed" -eq 0 ]] && { close_had_result 1 \
|
|
503
|
+
|| { [[ "$recovered_counts" -eq 1 ]] && close_had_result 0; }; }; then
|
|
504
|
+
if recover_close_result_output; then
|
|
505
|
+
rc=0
|
|
506
|
+
else
|
|
507
|
+
rc=1
|
|
508
|
+
emit_mode_notice "omnilane: Claude live result completed but output recovery failed" || true
|
|
509
|
+
fi
|
|
216
510
|
else
|
|
217
511
|
rc=1
|
|
218
512
|
emit_mode_notice "omnilane: $VENDOR live mailbox closed without a successful result event" || true
|