omnilane 0.20.0 → 0.31.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +37 -1
- package/README.ja.md +36 -3
- package/README.ko.md +36 -3
- package/README.md +38 -3
- package/README.zh-CN.md +36 -3
- package/README.zh-TW.md +36 -3
- package/VERSION +1 -1
- package/bin/omnilane +10 -1
- package/hooks/routing-instruction.md +16 -7
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/dispatch.sh +55 -7
- package/scripts/doctor.sh +26 -0
- package/scripts/jobs.sh +24 -12
- package/scripts/lib/goal-loop.sh +670 -0
- package/scripts/lib/job-worker.sh +98 -37
- package/scripts/lib/live-protocol.sh +70 -0
- package/scripts/runners/run-gemini.sh +109 -0
- package/skills/omnilane/SKILL.md +9 -4
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Foreman-driven goal ledger around normal background dispatch.
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
7
|
+
REPO="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
|
|
8
|
+
# shellcheck disable=SC1091
|
|
9
|
+
source "$SCRIPT_DIR/common.sh"
|
|
10
|
+
|
|
11
|
+
DISPATCH="$REPO/scripts/dispatch.sh"
|
|
12
|
+
DEFAULT_BUDGET_JOBS=""
|
|
13
|
+
DEFAULT_BUDGET_SECONDS=""
|
|
14
|
+
GOAL_ID_PATTERN='^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$'
|
|
15
|
+
LOCK_HELD=0
|
|
16
|
+
GOAL_DIR=""
|
|
17
|
+
|
|
18
|
+
usage() {
|
|
19
|
+
cat >&2 <<'EOF'
|
|
20
|
+
usage: omnilane goal open "TEXT" [--budget-jobs N] [--budget-seconds S] [--workdir DIR]
|
|
21
|
+
omnilane goal dispatch GOAL_ID [dispatch.sh args...]
|
|
22
|
+
omnilane goal note GOAL_ID "TEXT"
|
|
23
|
+
omnilane goal status GOAL_ID
|
|
24
|
+
omnilane goal close GOAL_ID [--summary "TEXT"]
|
|
25
|
+
EOF
|
|
26
|
+
exit 2
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
die() {
|
|
30
|
+
local rc="$1"
|
|
31
|
+
shift
|
|
32
|
+
printf 'omnilane goal: %s\n' "$*" >&2
|
|
33
|
+
exit "$rc"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
require_python() {
|
|
37
|
+
command -v python3 >/dev/null 2>&1 || die 1 "Python 3 required for goal records"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
validate_positive_integer() {
|
|
41
|
+
local label="$1" value="$2"
|
|
42
|
+
[[ "$value" =~ ^[1-9][0-9]{0,8}$ ]] ||
|
|
43
|
+
die 2 "invalid $label value (want 1..999999999)"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
load_goal() {
|
|
47
|
+
local goal_id="$1" goals_root="$OMNILANE_HOME/goals"
|
|
48
|
+
[[ "$goal_id" =~ $GOAL_ID_PATTERN ]] || die 2 "invalid goal id"
|
|
49
|
+
GOAL_DIR="$goals_root/$goal_id"
|
|
50
|
+
[[ -d "$GOAL_DIR" && ! -L "$GOAL_DIR" ]] || die 1 "no such goal: $goal_id"
|
|
51
|
+
[[ -f "$GOAL_DIR/goal.txt" && ! -L "$GOAL_DIR/goal.txt" ]] ||
|
|
52
|
+
die 1 "goal text is missing or unsafe: $goal_id"
|
|
53
|
+
[[ -f "$GOAL_DIR/budget.json" && ! -L "$GOAL_DIR/budget.json" ]] ||
|
|
54
|
+
die 1 "goal budget is missing or unsafe: $goal_id"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
release_lock() {
|
|
58
|
+
if [[ "$LOCK_HELD" -eq 1 ]]; then
|
|
59
|
+
[[ ! -e "$GOAL_DIR/.lock/pid" ]] || rm "$GOAL_DIR/.lock/pid" 2>/dev/null || true
|
|
60
|
+
rmdir "$GOAL_DIR/.lock" 2>/dev/null || true
|
|
61
|
+
LOCK_HELD=0
|
|
62
|
+
fi
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
acquire_lock() {
|
|
66
|
+
local tries=0
|
|
67
|
+
while ! mkdir -m 700 "$GOAL_DIR/.lock" 2>/dev/null; do
|
|
68
|
+
tries=$((tries + 1))
|
|
69
|
+
[[ "$tries" -lt 50 ]] || die 75 "goal is busy: $(basename "$GOAL_DIR")"
|
|
70
|
+
sleep 0.1
|
|
71
|
+
done
|
|
72
|
+
LOCK_HELD=1
|
|
73
|
+
printf '%s\n' "$$" > "$GOAL_DIR/.lock/pid"
|
|
74
|
+
chmod 600 "$GOAL_DIR/.lock/pid"
|
|
75
|
+
trap release_lock EXIT
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
refresh_goal() {
|
|
79
|
+
python3 - "$GOAL_DIR" "$OMNILANE_HOME" "$(date +%s)" <<'PY'
|
|
80
|
+
import datetime
|
|
81
|
+
import glob
|
|
82
|
+
import json
|
|
83
|
+
import os
|
|
84
|
+
import re
|
|
85
|
+
import sys
|
|
86
|
+
|
|
87
|
+
goal_dir, home, now_text = sys.argv[1:]
|
|
88
|
+
now = int(now_text)
|
|
89
|
+
|
|
90
|
+
def load_regular_json(path, limit):
|
|
91
|
+
if not os.path.isfile(path) or os.path.islink(path):
|
|
92
|
+
raise ValueError(f"unsafe or missing JSON record: {path}")
|
|
93
|
+
if os.path.getsize(path) > limit:
|
|
94
|
+
raise ValueError(f"oversized JSON record: {path}")
|
|
95
|
+
with open(path, encoding="utf-8") as handle:
|
|
96
|
+
return json.load(handle)
|
|
97
|
+
|
|
98
|
+
def write_json(path, value):
|
|
99
|
+
tmp = f"{path}.tmp.{os.getpid()}"
|
|
100
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
101
|
+
json.dump(value, handle, separators=(",", ":"), ensure_ascii=False)
|
|
102
|
+
handle.write("\n")
|
|
103
|
+
os.chmod(tmp, 0o600)
|
|
104
|
+
os.replace(tmp, path)
|
|
105
|
+
|
|
106
|
+
budget_path = os.path.join(goal_dir, "budget.json")
|
|
107
|
+
failures_path = os.path.join(goal_dir, "failures.json")
|
|
108
|
+
budget = load_regular_json(budget_path, 16384)
|
|
109
|
+
failures = load_regular_json(failures_path, 1048576)
|
|
110
|
+
if not isinstance(budget, dict) or not isinstance(failures, dict):
|
|
111
|
+
raise ValueError("invalid goal state")
|
|
112
|
+
|
|
113
|
+
records = []
|
|
114
|
+
pattern = os.path.join(goal_dir, "jobs", "job-*.json")
|
|
115
|
+
for record_path in sorted(glob.glob(pattern)):
|
|
116
|
+
record = load_regular_json(record_path, 262144)
|
|
117
|
+
job_id = record.get("job_id", "")
|
|
118
|
+
if not re.fullmatch(r"[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+", job_id):
|
|
119
|
+
raise ValueError(f"invalid recorded job id: {record_path}")
|
|
120
|
+
job_dir = os.path.join(home, "jobs", job_id)
|
|
121
|
+
if os.path.isdir(job_dir) and not os.path.islink(job_dir):
|
|
122
|
+
meta_path = os.path.join(job_dir, "meta.json")
|
|
123
|
+
if os.path.isfile(meta_path) and not os.path.islink(meta_path):
|
|
124
|
+
meta = load_regular_json(meta_path, 16384)
|
|
125
|
+
for key in ("lane", "vendor", "model", "mode", "workdir", "started"):
|
|
126
|
+
if key in meta:
|
|
127
|
+
record[key] = meta[key]
|
|
128
|
+
exit_path = os.path.join(job_dir, "exit")
|
|
129
|
+
if os.path.isfile(exit_path) and not os.path.islink(exit_path):
|
|
130
|
+
if os.path.getsize(exit_path) > 32:
|
|
131
|
+
raise ValueError(f"oversized job exit record: {job_id}")
|
|
132
|
+
with open(exit_path, encoding="ascii") as handle:
|
|
133
|
+
exit_text = handle.read().strip()
|
|
134
|
+
if not re.fullmatch(r"[0-9]+", exit_text):
|
|
135
|
+
raise ValueError(f"invalid job exit record: {job_id}")
|
|
136
|
+
record["exit"] = int(exit_text)
|
|
137
|
+
record["state"] = "done"
|
|
138
|
+
record["finished"] = datetime.datetime.fromtimestamp(
|
|
139
|
+
os.path.getmtime(exit_path), datetime.timezone.utc
|
|
140
|
+
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
141
|
+
submitted = int(record.get("submitted_epoch", now))
|
|
142
|
+
record["seconds"] = max(0, int(os.path.getmtime(exit_path)) - submitted)
|
|
143
|
+
inbox_path = os.path.join(home, "inbox", f"{job_id}.json")
|
|
144
|
+
if os.path.isfile(inbox_path) and not os.path.islink(inbox_path):
|
|
145
|
+
completion = load_regular_json(inbox_path, 1048576)
|
|
146
|
+
if isinstance(completion.get("finished"), str):
|
|
147
|
+
record["finished"] = completion["finished"]
|
|
148
|
+
if isinstance(completion.get("tail"), str):
|
|
149
|
+
record["tail"] = completion["tail"][-2000:]
|
|
150
|
+
if record["exit"] != 0 and not record.get("failure_counted", False):
|
|
151
|
+
fingerprint = record.get("fingerprint", "")
|
|
152
|
+
if re.fullmatch(r"[0-9a-f]{64}", fingerprint):
|
|
153
|
+
failures[fingerprint] = int(failures.get(fingerprint, 0)) + 1
|
|
154
|
+
record["failure_counted"] = True
|
|
155
|
+
else:
|
|
156
|
+
record["state"] = "running"
|
|
157
|
+
record["exit"] = None
|
|
158
|
+
submitted = int(record.get("submitted_epoch", now))
|
|
159
|
+
record["seconds"] = max(0, now - submitted)
|
|
160
|
+
else:
|
|
161
|
+
record["state"] = "missing"
|
|
162
|
+
record["exit"] = None
|
|
163
|
+
submitted = int(record.get("submitted_epoch", now))
|
|
164
|
+
record["seconds"] = max(0, now - submitted)
|
|
165
|
+
write_json(record_path, record)
|
|
166
|
+
records.append(record)
|
|
167
|
+
|
|
168
|
+
budget["spent_jobs"] = len(records)
|
|
169
|
+
if budget.get("status") == "open":
|
|
170
|
+
budget["spent_seconds"] = max(0, now - int(budget["started_epoch"]))
|
|
171
|
+
write_json(failures_path, failures)
|
|
172
|
+
write_json(budget_path, budget)
|
|
173
|
+
PY
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
state_fields() {
|
|
177
|
+
python3 - "$GOAL_DIR/budget.json" <<'PY'
|
|
178
|
+
import json
|
|
179
|
+
import os
|
|
180
|
+
import sys
|
|
181
|
+
|
|
182
|
+
path = sys.argv[1]
|
|
183
|
+
if not os.path.isfile(path) or os.path.islink(path) or os.path.getsize(path) > 16384:
|
|
184
|
+
raise SystemExit("invalid goal budget")
|
|
185
|
+
with open(path, encoding="utf-8") as handle:
|
|
186
|
+
state = json.load(handle)
|
|
187
|
+
required = {
|
|
188
|
+
"status", "budget_jobs", "budget_seconds", "spent_jobs",
|
|
189
|
+
"spent_seconds", "fuse_trips", "started_epoch", "workdir",
|
|
190
|
+
}
|
|
191
|
+
if not isinstance(state, dict) or not required.issubset(state):
|
|
192
|
+
raise SystemExit("invalid goal budget")
|
|
193
|
+
def field_value(key):
|
|
194
|
+
value = state[key]
|
|
195
|
+
if key in {"budget_jobs", "budget_seconds"} and value is None:
|
|
196
|
+
return "unlimited"
|
|
197
|
+
return str(value)
|
|
198
|
+
|
|
199
|
+
print("\t".join(field_value(key) for key in (
|
|
200
|
+
"status", "spent_jobs", "budget_jobs", "spent_seconds",
|
|
201
|
+
"budget_seconds", "fuse_trips", "workdir",
|
|
202
|
+
)))
|
|
203
|
+
PY
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
failure_count() {
|
|
207
|
+
local fingerprint="$1"
|
|
208
|
+
python3 - "$GOAL_DIR/failures.json" "$fingerprint" <<'PY'
|
|
209
|
+
import json
|
|
210
|
+
import os
|
|
211
|
+
import sys
|
|
212
|
+
|
|
213
|
+
path, fingerprint = sys.argv[1:]
|
|
214
|
+
if not os.path.isfile(path) or os.path.islink(path) or os.path.getsize(path) > 1048576:
|
|
215
|
+
raise SystemExit("invalid failure record")
|
|
216
|
+
with open(path, encoding="utf-8") as handle:
|
|
217
|
+
failures = json.load(handle)
|
|
218
|
+
print(int(failures.get(fingerprint, 0)))
|
|
219
|
+
PY
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
record_fuse_trip() {
|
|
223
|
+
local fingerprint="$1" lane="$2" task="$3" failures="$4"
|
|
224
|
+
GOAL_FINGERPRINT="$fingerprint" GOAL_LANE="$lane" GOAL_TASK="$task" \
|
|
225
|
+
GOAL_FAILURES="$failures" python3 - "$GOAL_DIR" <<'PY'
|
|
226
|
+
import datetime
|
|
227
|
+
import json
|
|
228
|
+
import os
|
|
229
|
+
import sys
|
|
230
|
+
|
|
231
|
+
goal_dir = sys.argv[1]
|
|
232
|
+
budget_path = os.path.join(goal_dir, "budget.json")
|
|
233
|
+
with open(budget_path, encoding="utf-8") as handle:
|
|
234
|
+
budget = json.load(handle)
|
|
235
|
+
budget["fuse_trips"] = int(budget.get("fuse_trips", 0)) + 1
|
|
236
|
+
tmp = f"{budget_path}.tmp.{os.getpid()}"
|
|
237
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
238
|
+
json.dump(budget, handle, separators=(",", ":"), ensure_ascii=False)
|
|
239
|
+
handle.write("\n")
|
|
240
|
+
os.chmod(tmp, 0o600)
|
|
241
|
+
os.replace(tmp, budget_path)
|
|
242
|
+
record = {
|
|
243
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
244
|
+
"fingerprint": os.environ["GOAL_FINGERPRINT"],
|
|
245
|
+
"lane": os.environ["GOAL_LANE"],
|
|
246
|
+
"task": os.environ["GOAL_TASK"][:2000],
|
|
247
|
+
"failures": int(os.environ["GOAL_FAILURES"]),
|
|
248
|
+
}
|
|
249
|
+
path = os.path.join(goal_dir, "fuses.jsonl")
|
|
250
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
251
|
+
handle.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
252
|
+
os.chmod(path, 0o600)
|
|
253
|
+
PY
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
record_dispatch() {
|
|
257
|
+
local job_id="$1" lane="$2" fingerprint="$3" task="$4"
|
|
258
|
+
GOAL_JOB_ID="$job_id" GOAL_LANE="$lane" GOAL_FINGERPRINT="$fingerprint" \
|
|
259
|
+
GOAL_TASK="$task" python3 - "$GOAL_DIR" "$OMNILANE_HOME" "$(date +%s)" <<'PY'
|
|
260
|
+
import datetime
|
|
261
|
+
import json
|
|
262
|
+
import os
|
|
263
|
+
import sys
|
|
264
|
+
|
|
265
|
+
goal_dir, home, submitted_text = sys.argv[1:]
|
|
266
|
+
budget_path = os.path.join(goal_dir, "budget.json")
|
|
267
|
+
with open(budget_path, encoding="utf-8") as handle:
|
|
268
|
+
budget = json.load(handle)
|
|
269
|
+
ordinal = int(budget["spent_jobs"]) + 1
|
|
270
|
+
job_id = os.environ["GOAL_JOB_ID"]
|
|
271
|
+
record = {
|
|
272
|
+
"ordinal": ordinal,
|
|
273
|
+
"job_id": job_id,
|
|
274
|
+
"lane": os.environ["GOAL_LANE"],
|
|
275
|
+
"vendor": "unknown",
|
|
276
|
+
"model": "",
|
|
277
|
+
"mode": "unknown",
|
|
278
|
+
"workdir": budget["workdir"],
|
|
279
|
+
"task": os.environ["GOAL_TASK"][:2000],
|
|
280
|
+
"fingerprint": os.environ["GOAL_FINGERPRINT"],
|
|
281
|
+
"submitted_epoch": int(submitted_text),
|
|
282
|
+
"submitted": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
283
|
+
"state": "running",
|
|
284
|
+
"exit": None,
|
|
285
|
+
"seconds": 0,
|
|
286
|
+
"failure_counted": False,
|
|
287
|
+
}
|
|
288
|
+
meta_path = os.path.join(home, "jobs", job_id, "meta.json")
|
|
289
|
+
if os.path.isfile(meta_path) and not os.path.islink(meta_path) and os.path.getsize(meta_path) <= 16384:
|
|
290
|
+
with open(meta_path, encoding="utf-8") as handle:
|
|
291
|
+
meta = json.load(handle)
|
|
292
|
+
for key in ("lane", "vendor", "model", "mode", "workdir", "started"):
|
|
293
|
+
if key in meta:
|
|
294
|
+
record[key] = meta[key]
|
|
295
|
+
jobs_dir = os.path.join(goal_dir, "jobs")
|
|
296
|
+
record_path = os.path.join(jobs_dir, f"job-{ordinal:08d}.json")
|
|
297
|
+
if os.path.exists(record_path):
|
|
298
|
+
raise SystemExit("goal job record collision")
|
|
299
|
+
with open(record_path, "x", encoding="utf-8") as handle:
|
|
300
|
+
json.dump(record, handle, separators=(",", ":"), ensure_ascii=False)
|
|
301
|
+
handle.write("\n")
|
|
302
|
+
os.chmod(record_path, 0o600)
|
|
303
|
+
budget["spent_jobs"] = ordinal
|
|
304
|
+
budget["spent_seconds"] = max(0, int(submitted_text) - int(budget["started_epoch"]))
|
|
305
|
+
tmp = f"{budget_path}.tmp.{os.getpid()}"
|
|
306
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
307
|
+
json.dump(budget, handle, separators=(",", ":"), ensure_ascii=False)
|
|
308
|
+
handle.write("\n")
|
|
309
|
+
os.chmod(tmp, 0o600)
|
|
310
|
+
os.replace(tmp, budget_path)
|
|
311
|
+
PY
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
open_goal() {
|
|
315
|
+
local goal_text="${1:-}" budget_jobs="$DEFAULT_BUDGET_JOBS"
|
|
316
|
+
local budget_seconds="$DEFAULT_BUDGET_SECONDS" workdir="$PWD"
|
|
317
|
+
local goals_root goal_id goal_dir started
|
|
318
|
+
[[ $# -ge 1 && -n "$goal_text" ]] || usage
|
|
319
|
+
shift
|
|
320
|
+
while [[ $# -gt 0 ]]; do
|
|
321
|
+
case "$1" in
|
|
322
|
+
--budget-jobs)
|
|
323
|
+
[[ $# -ge 2 ]] || usage
|
|
324
|
+
budget_jobs="$2"
|
|
325
|
+
shift 2 ;;
|
|
326
|
+
--budget-seconds)
|
|
327
|
+
[[ $# -ge 2 ]] || usage
|
|
328
|
+
budget_seconds="$2"
|
|
329
|
+
shift 2 ;;
|
|
330
|
+
--workdir)
|
|
331
|
+
[[ $# -ge 2 ]] || usage
|
|
332
|
+
workdir="$2"
|
|
333
|
+
shift 2 ;;
|
|
334
|
+
*) usage ;;
|
|
335
|
+
esac
|
|
336
|
+
done
|
|
337
|
+
[[ -z "$budget_jobs" ]] || validate_positive_integer "--budget-jobs" "$budget_jobs"
|
|
338
|
+
[[ -z "$budget_seconds" ]] || validate_positive_integer "--budget-seconds" "$budget_seconds"
|
|
339
|
+
[[ -d "$workdir" ]] || die 2 "workdir is not a directory: $workdir"
|
|
340
|
+
workdir="$(cd "$workdir" && pwd -P)"
|
|
341
|
+
[[ -x "$DISPATCH" ]] || die 1 "dispatch helper unavailable"
|
|
342
|
+
goals_root="$OMNILANE_HOME/goals"
|
|
343
|
+
prepare_private_store "$goals_root" "goals store" || die 1 "could not prepare goals store"
|
|
344
|
+
goal_id="$(date +%Y%m%d-%H%M%S)-$$-$RANDOM"
|
|
345
|
+
goal_dir="$goals_root/$goal_id"
|
|
346
|
+
mkdir -m 700 "$goal_dir"
|
|
347
|
+
mkdir -m 700 "$goal_dir/jobs"
|
|
348
|
+
printf '%s\n' "$goal_text" > "$goal_dir/goal.txt"
|
|
349
|
+
printf '{}\n' > "$goal_dir/failures.json"
|
|
350
|
+
: > "$goal_dir/notes.jsonl"
|
|
351
|
+
chmod 600 "$goal_dir/goal.txt" "$goal_dir/failures.json" "$goal_dir/notes.jsonl"
|
|
352
|
+
started="$(date +%s)"
|
|
353
|
+
GOAL_WORKDIR="$workdir" python3 - "$goal_dir/budget.json" "$budget_jobs" \
|
|
354
|
+
"$budget_seconds" "$started" <<'PY'
|
|
355
|
+
import json
|
|
356
|
+
import os
|
|
357
|
+
import sys
|
|
358
|
+
|
|
359
|
+
path, jobs, seconds, started = sys.argv[1:]
|
|
360
|
+
state = {
|
|
361
|
+
"schema_version": 3,
|
|
362
|
+
"budget_jobs": None if jobs == "" else int(jobs),
|
|
363
|
+
"budget_seconds": None if seconds == "" else int(seconds),
|
|
364
|
+
"spent_jobs": 0,
|
|
365
|
+
"spent_seconds": 0,
|
|
366
|
+
"fuse_trips": 0,
|
|
367
|
+
"status": "open",
|
|
368
|
+
"started_epoch": int(started),
|
|
369
|
+
"workdir": os.environ["GOAL_WORKDIR"],
|
|
370
|
+
}
|
|
371
|
+
with open(path, "x", encoding="utf-8") as handle:
|
|
372
|
+
json.dump(state, handle, separators=(",", ":"), ensure_ascii=False)
|
|
373
|
+
handle.write("\n")
|
|
374
|
+
os.chmod(path, 0o600)
|
|
375
|
+
PY
|
|
376
|
+
printf '%s\n' "$goal_id"
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
dispatch_goal() {
|
|
380
|
+
local goal_id="${1:-}" lane task task_value fingerprint failures
|
|
381
|
+
local status spent_jobs budget_jobs spent_seconds budget_seconds workdir lane_index
|
|
382
|
+
local dispatch_output dispatch_rc error_path
|
|
383
|
+
[[ $# -ge 3 ]] || usage
|
|
384
|
+
shift
|
|
385
|
+
load_goal "$goal_id"
|
|
386
|
+
acquire_lock
|
|
387
|
+
refresh_goal
|
|
388
|
+
IFS=$'\t' read -r status spent_jobs budget_jobs spent_seconds budget_seconds \
|
|
389
|
+
_ workdir < <(state_fields)
|
|
390
|
+
[[ "$status" == "open" ]] || die 75 "goal is closed: $goal_id"
|
|
391
|
+
if [[ "$budget_jobs" != "unlimited" && "$spent_jobs" -ge "$budget_jobs" ]]; then
|
|
392
|
+
die 75 "jobs budget exhausted: $spent_jobs/$budget_jobs"
|
|
393
|
+
fi
|
|
394
|
+
if [[ "$budget_seconds" != "unlimited" && "$spent_seconds" -ge "$budget_seconds" ]]; then
|
|
395
|
+
die 75 "seconds budget exhausted: ${spent_seconds}s/${budget_seconds}s"
|
|
396
|
+
fi
|
|
397
|
+
|
|
398
|
+
lane_index=$(($# - 1))
|
|
399
|
+
lane="${!lane_index}"
|
|
400
|
+
task="${!#}"
|
|
401
|
+
task_value="$task"
|
|
402
|
+
if [[ "$task" == "-" ]]; then
|
|
403
|
+
task_value="$(cat)"
|
|
404
|
+
fi
|
|
405
|
+
fingerprint="$(python3 - "$lane" "$task_value" <<'PY'
|
|
406
|
+
import hashlib
|
|
407
|
+
import sys
|
|
408
|
+
print(hashlib.sha256((sys.argv[1] + "\0" + sys.argv[2]).encode("utf-8")).hexdigest())
|
|
409
|
+
PY
|
|
410
|
+
)"
|
|
411
|
+
failures="$(failure_count "$fingerprint")"
|
|
412
|
+
if [[ "$failures" -ge 2 ]]; then
|
|
413
|
+
record_fuse_trip "$fingerprint" "$lane" "$task_value" "$failures"
|
|
414
|
+
die 75 "failure fuse tripped: lane=$lane failures=$failures fingerprint=$fingerprint"
|
|
415
|
+
fi
|
|
416
|
+
|
|
417
|
+
error_path="$GOAL_DIR/dispatch-error-$(date +%s)-$$.txt"
|
|
418
|
+
set +e
|
|
419
|
+
if [[ "$task" == "-" ]]; then
|
|
420
|
+
dispatch_output="$(printf '%s' "$task_value" | OMNILANE_HOME="$OMNILANE_HOME" \
|
|
421
|
+
"$DISPATCH" --background --workdir "$workdir" "$@" 2>"$error_path")"
|
|
422
|
+
dispatch_rc=$?
|
|
423
|
+
else
|
|
424
|
+
dispatch_output="$(OMNILANE_HOME="$OMNILANE_HOME" "$DISPATCH" --background \
|
|
425
|
+
--workdir "$workdir" "$@" 2>"$error_path")"
|
|
426
|
+
dispatch_rc=$?
|
|
427
|
+
fi
|
|
428
|
+
set -e
|
|
429
|
+
if [[ "$dispatch_rc" -ne 0 ]]; then
|
|
430
|
+
chmod 600 "$error_path" 2>/dev/null || true
|
|
431
|
+
die "$dispatch_rc" "dispatch failed (exit $dispatch_rc; details: $error_path)"
|
|
432
|
+
fi
|
|
433
|
+
if [[ ! "$dispatch_output" =~ $GOAL_ID_PATTERN ]]; then
|
|
434
|
+
chmod 600 "$error_path" 2>/dev/null || true
|
|
435
|
+
die 1 "dispatch returned invalid job id (details: $error_path)"
|
|
436
|
+
fi
|
|
437
|
+
[[ ! -s "$error_path" ]] || chmod 600 "$error_path"
|
|
438
|
+
[[ -s "$error_path" ]] || rm "$error_path"
|
|
439
|
+
record_dispatch "$dispatch_output" "$lane" "$fingerprint" "$task_value"
|
|
440
|
+
release_lock
|
|
441
|
+
trap - EXIT
|
|
442
|
+
printf '%s\n' "$dispatch_output"
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
note_goal() {
|
|
446
|
+
local goal_id="${1:-}" note_text="${2:-}"
|
|
447
|
+
[[ $# -eq 2 && -n "$note_text" ]] || usage
|
|
448
|
+
load_goal "$goal_id"
|
|
449
|
+
acquire_lock
|
|
450
|
+
refresh_goal
|
|
451
|
+
GOAL_NOTE="$note_text" python3 - "$GOAL_DIR/notes.jsonl" <<'PY'
|
|
452
|
+
import datetime
|
|
453
|
+
import json
|
|
454
|
+
import os
|
|
455
|
+
import sys
|
|
456
|
+
|
|
457
|
+
path = sys.argv[1]
|
|
458
|
+
record = {
|
|
459
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
460
|
+
"text": os.environ["GOAL_NOTE"],
|
|
461
|
+
}
|
|
462
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
463
|
+
handle.write(json.dumps(record, separators=(",", ":"), ensure_ascii=False) + "\n")
|
|
464
|
+
os.chmod(path, 0o600)
|
|
465
|
+
PY
|
|
466
|
+
release_lock
|
|
467
|
+
trap - EXIT
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
status_goal() {
|
|
471
|
+
local goal_id="${1:-}"
|
|
472
|
+
[[ $# -eq 1 ]] || usage
|
|
473
|
+
load_goal "$goal_id"
|
|
474
|
+
acquire_lock
|
|
475
|
+
refresh_goal
|
|
476
|
+
python3 - "$GOAL_DIR" <<'PY'
|
|
477
|
+
import glob
|
|
478
|
+
import json
|
|
479
|
+
import os
|
|
480
|
+
import sys
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
goal_dir = sys.argv[1]
|
|
484
|
+
with open(os.path.join(goal_dir, "budget.json"), encoding="utf-8") as handle:
|
|
485
|
+
budget = json.load(handle)
|
|
486
|
+
|
|
487
|
+
def budget_limit(value):
|
|
488
|
+
return "unlimited" if value is None else str(value)
|
|
489
|
+
|
|
490
|
+
print(f"status: {budget['status']}")
|
|
491
|
+
print(f"jobs: {budget['spent_jobs']} / {budget_limit(budget['budget_jobs'])}")
|
|
492
|
+
print(f"seconds: {budget['spent_seconds']} / {budget_limit(budget['budget_seconds'])}")
|
|
493
|
+
print(f"fuse trips: {budget.get('fuse_trips', 0)}")
|
|
494
|
+
for path in sorted(glob.glob(os.path.join(goal_dir, "jobs", "job-*.json"))):
|
|
495
|
+
with open(path, encoding="utf-8") as handle:
|
|
496
|
+
record = json.load(handle)
|
|
497
|
+
exit_value = record.get("exit")
|
|
498
|
+
exit_text = "running" if exit_value is None else str(exit_value)
|
|
499
|
+
print(
|
|
500
|
+
f"job {record['job_id']}: lane={record.get('lane', 'unknown')} "
|
|
501
|
+
f"vendor={record.get('vendor', 'unknown')} exit={exit_text} "
|
|
502
|
+
f"seconds={record.get('seconds', 0)}"
|
|
503
|
+
)
|
|
504
|
+
sys.stdout.flush()
|
|
505
|
+
except BrokenPipeError:
|
|
506
|
+
# Prevent Python's shutdown flush from reporting the same closed pipe again.
|
|
507
|
+
with open(os.devnull, "w", encoding="utf-8") as devnull:
|
|
508
|
+
os.dup2(devnull.fileno(), sys.stdout.fileno())
|
|
509
|
+
sys.exit(0)
|
|
510
|
+
PY
|
|
511
|
+
release_lock
|
|
512
|
+
trap - EXIT
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
close_goal() {
|
|
516
|
+
local goal_id="${1:-}" summary="" report_path
|
|
517
|
+
[[ $# -ge 1 ]] || usage
|
|
518
|
+
shift
|
|
519
|
+
while [[ $# -gt 0 ]]; do
|
|
520
|
+
case "$1" in
|
|
521
|
+
--summary)
|
|
522
|
+
[[ $# -ge 2 ]] || usage
|
|
523
|
+
summary="$2"
|
|
524
|
+
shift 2 ;;
|
|
525
|
+
*) usage ;;
|
|
526
|
+
esac
|
|
527
|
+
done
|
|
528
|
+
load_goal "$goal_id"
|
|
529
|
+
acquire_lock
|
|
530
|
+
refresh_goal
|
|
531
|
+
GOAL_SUMMARY="$summary" python3 - "$GOAL_DIR" "$goal_id" "$(date +%s)" <<'PY'
|
|
532
|
+
import glob
|
|
533
|
+
import json
|
|
534
|
+
import os
|
|
535
|
+
import re
|
|
536
|
+
import sys
|
|
537
|
+
|
|
538
|
+
goal_dir, goal_id, closed_text = sys.argv[1:]
|
|
539
|
+
|
|
540
|
+
def read_text(name):
|
|
541
|
+
path = os.path.join(goal_dir, name)
|
|
542
|
+
if not os.path.isfile(path) or os.path.islink(path):
|
|
543
|
+
raise ValueError(f"unsafe or missing goal record: {name}")
|
|
544
|
+
with open(path, encoding="utf-8") as handle:
|
|
545
|
+
return handle.read().rstrip("\n")
|
|
546
|
+
|
|
547
|
+
def data_block(value):
|
|
548
|
+
longest = max((len(item) for item in re.findall(r"`+", value)), default=0)
|
|
549
|
+
fence = "`" * max(3, longest + 1)
|
|
550
|
+
suffix = "" if value.endswith("\n") else "\n"
|
|
551
|
+
return f"{fence}text\n{value}{suffix}{fence}"
|
|
552
|
+
|
|
553
|
+
def budget_limit(value):
|
|
554
|
+
return "unlimited" if value is None else str(value)
|
|
555
|
+
|
|
556
|
+
budget_path = os.path.join(goal_dir, "budget.json")
|
|
557
|
+
with open(budget_path, encoding="utf-8") as handle:
|
|
558
|
+
budget = json.load(handle)
|
|
559
|
+
if budget.get("status") != "open":
|
|
560
|
+
raise SystemExit("goal is already closed")
|
|
561
|
+
budget["status"] = "closed"
|
|
562
|
+
budget["spent_seconds"] = max(0, int(closed_text) - int(budget["started_epoch"]))
|
|
563
|
+
budget["closed_epoch"] = int(closed_text)
|
|
564
|
+
tmp = f"{budget_path}.tmp.{os.getpid()}"
|
|
565
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
566
|
+
json.dump(budget, handle, separators=(",", ":"), ensure_ascii=False)
|
|
567
|
+
handle.write("\n")
|
|
568
|
+
os.chmod(tmp, 0o600)
|
|
569
|
+
os.replace(tmp, budget_path)
|
|
570
|
+
|
|
571
|
+
notes = []
|
|
572
|
+
notes_path = os.path.join(goal_dir, "notes.jsonl")
|
|
573
|
+
with open(notes_path, encoding="utf-8") as handle:
|
|
574
|
+
for line in handle:
|
|
575
|
+
if line.strip():
|
|
576
|
+
notes.append(json.loads(line))
|
|
577
|
+
summary = os.environ.get("GOAL_SUMMARY", "")
|
|
578
|
+
if not summary:
|
|
579
|
+
summary = "\n".join(f"[{note['timestamp']}] {note['text']}" for note in notes)
|
|
580
|
+
if not summary:
|
|
581
|
+
summary = "No foreman summary provided."
|
|
582
|
+
summary_path = os.path.join(goal_dir, "summary.txt")
|
|
583
|
+
with open(summary_path, "w", encoding="utf-8") as handle:
|
|
584
|
+
handle.write(summary + "\n")
|
|
585
|
+
os.chmod(summary_path, 0o600)
|
|
586
|
+
|
|
587
|
+
jobs = []
|
|
588
|
+
for path in sorted(glob.glob(os.path.join(goal_dir, "jobs", "job-*.json"))):
|
|
589
|
+
with open(path, encoding="utf-8") as handle:
|
|
590
|
+
jobs.append(json.load(handle))
|
|
591
|
+
job_lines = []
|
|
592
|
+
for job in jobs:
|
|
593
|
+
exit_value = job.get("exit")
|
|
594
|
+
exit_text = "running" if exit_value is None else str(exit_value)
|
|
595
|
+
job_lines.append(
|
|
596
|
+
f"job {job['job_id']}: lane={job.get('lane', 'unknown')} "
|
|
597
|
+
f"vendor={job.get('vendor', 'unknown')} exit={exit_text} "
|
|
598
|
+
f"seconds={job.get('seconds', 0)} task={job.get('task', '')}"
|
|
599
|
+
)
|
|
600
|
+
if not job_lines:
|
|
601
|
+
job_lines.append("No jobs recorded.")
|
|
602
|
+
note_lines = [f"[{note['timestamp']}] {note['text']}" for note in notes]
|
|
603
|
+
if not note_lines:
|
|
604
|
+
note_lines.append("No foreman notes recorded.")
|
|
605
|
+
|
|
606
|
+
artifacts = []
|
|
607
|
+
for value in (summary, *(note["text"] for note in notes)):
|
|
608
|
+
for candidate in re.findall(r"`([^`\r\n]+)`", value):
|
|
609
|
+
if candidate.startswith(("/", "./", "../", "~/")) or "/" in candidate:
|
|
610
|
+
if candidate not in artifacts:
|
|
611
|
+
artifacts.append(candidate)
|
|
612
|
+
|
|
613
|
+
lines = [
|
|
614
|
+
f"# Goal report: {goal_id}",
|
|
615
|
+
"",
|
|
616
|
+
"## Goal",
|
|
617
|
+
"",
|
|
618
|
+
data_block(read_text("goal.txt")),
|
|
619
|
+
"",
|
|
620
|
+
"## Outcome",
|
|
621
|
+
"",
|
|
622
|
+
"- Status: closed",
|
|
623
|
+
"",
|
|
624
|
+
"## Budget",
|
|
625
|
+
"",
|
|
626
|
+
f"- Jobs: {budget['spent_jobs']} / {budget_limit(budget['budget_jobs'])}",
|
|
627
|
+
f"- Seconds: {budget['spent_seconds']} / {budget_limit(budget['budget_seconds'])}",
|
|
628
|
+
f"- Fuse trips: {budget.get('fuse_trips', 0)}",
|
|
629
|
+
"",
|
|
630
|
+
"## Jobs",
|
|
631
|
+
"",
|
|
632
|
+
data_block("\n".join(job_lines)),
|
|
633
|
+
"",
|
|
634
|
+
"## Foreman notes (data)",
|
|
635
|
+
"",
|
|
636
|
+
data_block("\n".join(note_lines)),
|
|
637
|
+
"",
|
|
638
|
+
"## Foreman summary (data)",
|
|
639
|
+
"",
|
|
640
|
+
data_block(summary),
|
|
641
|
+
"",
|
|
642
|
+
"## Artifact paths named by foreman",
|
|
643
|
+
"",
|
|
644
|
+
]
|
|
645
|
+
lines.append(data_block("\n".join(artifacts)) if artifacts else "None recorded.")
|
|
646
|
+
lines.append("")
|
|
647
|
+
report_path = os.path.join(goal_dir, "report.md")
|
|
648
|
+
report_tmp = f"{report_path}.tmp.{os.getpid()}"
|
|
649
|
+
with open(report_tmp, "w", encoding="utf-8") as handle:
|
|
650
|
+
handle.write("\n".join(lines))
|
|
651
|
+
os.chmod(report_tmp, 0o600)
|
|
652
|
+
os.replace(report_tmp, report_path)
|
|
653
|
+
PY
|
|
654
|
+
report_path="$GOAL_DIR/report.md"
|
|
655
|
+
release_lock
|
|
656
|
+
trap - EXIT
|
|
657
|
+
printf '%s\n' "$report_path"
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
require_python
|
|
661
|
+
subcommand="${1:-}"
|
|
662
|
+
shift || true
|
|
663
|
+
case "$subcommand" in
|
|
664
|
+
open) open_goal "$@" ;;
|
|
665
|
+
dispatch) dispatch_goal "$@" ;;
|
|
666
|
+
note) note_goal "$@" ;;
|
|
667
|
+
status) status_goal "$@" ;;
|
|
668
|
+
close) close_goal "$@" ;;
|
|
669
|
+
*) usage ;;
|
|
670
|
+
esac
|