@giamme/forge 0.11.0 → 0.12.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/CHANGELOG.md +47 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +46 -12
- package/docs/commands.md +48 -9
- package/docs/decomposed-runs.md +43 -2
- package/docs/forge-ui.md +27 -6
- package/docs/reviews.md +31 -6
- package/package.json +1 -1
- package/references/dataset.md +3 -1
- package/references/decompose.md +159 -17
- package/references/harnesses.md +13 -1
- package/references/ui.md +14 -4
- package/scripts/forge-control.py +268 -25
- package/scripts/forge-dispatch.sh +80 -3
- package/scripts/forge-guard.py +156 -15
- package/scripts/forge-issues.py +1 -1
- package/scripts/forge-lib-plans.sh +9 -3
- package/scripts/forge-lib-review.sh +22 -3
- package/scripts/forge-lib-task.sh +51 -4
- package/scripts/forge-lib-verify.sh +137 -6
- package/scripts/forge-parallel-options.sh +59 -0
- package/scripts/forge-parallel.sh +212 -28
- package/scripts/forge-ripwire.py +1 -1
- package/scripts/forge-route.py +157 -5
- package/scripts/forge-schedule.py +687 -50
- package/scripts/forge-ui.py +8 -4
- package/scripts/forge-verify.py +878 -0
- package/scripts/forge_dataset/forecast.py +29 -4
- package/scripts/forge_dataset/harvest.py +10 -4
- package/scripts/forge_verify_reports.py +225 -0
- package/ui/README.md +19 -4
- package/ui/docs/api.md +139 -25
- package/ui/forge_ui/control.py +26 -7
- package/ui/forge_ui/export.py +20 -7
- package/ui/forge_ui/reader.py +307 -11
- package/ui/forge_ui/server.py +16 -0
- package/ui/forge_ui/static/app.js +2 -1
- package/ui/forge_ui/static/controls.js +217 -33
- package/ui/forge_ui/static/findingsview.js +37 -12
- package/ui/forge_ui/static/gantt.js +14 -9
- package/ui/forge_ui/static/index.html +1 -0
- package/ui/forge_ui/static/panel.js +2 -1
- package/ui/forge_ui/static/runmodel.js +92 -11
- package/ui/forge_ui/static/scene3d.js +9 -5
- package/ui/forge_ui/static/settings.js +48 -12
- package/ui/forge_ui/static/style.css +60 -0
- package/ui/forge_ui/static/verifyview.js +324 -0
package/scripts/forge-guard.py
CHANGED
|
@@ -17,6 +17,19 @@
|
|
|
17
17
|
bounded ~2s, KILL, print how many were found. Never exits nonzero for an ordinary
|
|
18
18
|
outcome, and never touches its own or its caller's group.
|
|
19
19
|
|
|
20
|
+
forge-guard.py identity <pid>
|
|
21
|
+
Print the process's start time, which survives exec. Dispatch records it with the
|
|
22
|
+
harness pid right after `$!`, when that pid may still be the subshell or this helper
|
|
23
|
+
before the exec into the harness, so the command name cannot identify it.
|
|
24
|
+
|
|
25
|
+
forge-guard.py stop <record-file>
|
|
26
|
+
The backstop for a harness its dispatch could not stop. The record is
|
|
27
|
+
`<pid><TAB><identity>`. A live pid with the recorded start time gets its group
|
|
28
|
+
signalled (or itself alone, before setsid made it a leader): TERM, ~2s, KILL. A live
|
|
29
|
+
pid with another start time was reused and is left alone. A dead pid's group may
|
|
30
|
+
still hold the harness's background jobs, and cannot have been reused while they
|
|
31
|
+
live, so it is reaped. The record is removed; prints how many processes were signalled.
|
|
32
|
+
|
|
20
33
|
forge-guard.py promises <file> [--tail-bytes N]
|
|
21
34
|
Scan the tail of an implementer's final message for promises of later work ("it is
|
|
22
35
|
still running in the background", "I'll check back"). One `pattern<TAB>excerpt`
|
|
@@ -115,39 +128,153 @@ def wait_gone(pgid: int, seconds: float) -> bool:
|
|
|
115
128
|
return (not members) if members is not None else not group_exists(pgid)
|
|
116
129
|
|
|
117
130
|
|
|
118
|
-
def
|
|
119
|
-
# Never signal ourselves, the
|
|
131
|
+
def _forbidden() -> set:
|
|
132
|
+
# Never signal ourselves, the process that called us, or init.
|
|
120
133
|
forbidden = {0, 1, os.getpgrp()}
|
|
121
134
|
try:
|
|
122
135
|
forbidden.add(os.getpgid(os.getppid()))
|
|
123
136
|
except OSError:
|
|
124
137
|
pass
|
|
125
|
-
|
|
126
|
-
|
|
138
|
+
return forbidden
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _reap_group(pgid: int, orphans_file: str | None) -> int:
|
|
142
|
+
"""TERM, then KILL, whatever is alive in the group; how many were found."""
|
|
143
|
+
if pgid in _forbidden() or pgid < 0:
|
|
127
144
|
return 0
|
|
128
145
|
if not group_exists(pgid):
|
|
129
|
-
print(0)
|
|
130
146
|
return 0
|
|
131
147
|
members = list_group(pgid)
|
|
132
148
|
if members is not None and not members:
|
|
133
149
|
# Only zombies (or nothing visible) left; nothing worth reporting or killing.
|
|
134
|
-
print(0)
|
|
135
150
|
return 0
|
|
136
151
|
found = members if members is not None else [(0, '(unknown: ps unavailable; process group %d still has live members)' % pgid)]
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
if orphans_file:
|
|
153
|
+
try:
|
|
154
|
+
with open(orphans_file, 'w') as handle:
|
|
155
|
+
for pid, command in found:
|
|
156
|
+
handle.write('%s\t%s\n' % (pid or '?', ' '.join(command.split()) or '?'))
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
143
159
|
for sig, wait in ((signal.SIGTERM, TERM_WAIT_S), (signal.SIGKILL, KILL_WAIT_S)):
|
|
144
160
|
try:
|
|
145
161
|
os.killpg(pgid, sig)
|
|
146
|
-
except
|
|
162
|
+
except OSError:
|
|
147
163
|
break
|
|
148
164
|
if wait_gone(pgid, wait):
|
|
149
165
|
break
|
|
150
|
-
|
|
166
|
+
return len(found)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def reap(pgid: int, orphans_file: str) -> int:
|
|
170
|
+
print(_reap_group(pgid, orphans_file))
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- identity / stop ----------------------------------------------------------
|
|
175
|
+
def _ps_field(pid: int, field: str) -> str | None:
|
|
176
|
+
"""One ps column for one pid, whitespace-normalised; None when the pid is gone or ps
|
|
177
|
+
is unavailable. LC_ALL=C keeps lstart's format the same for every caller."""
|
|
178
|
+
if pid <= 0:
|
|
179
|
+
return None
|
|
180
|
+
for ps in PS_CANDIDATES:
|
|
181
|
+
if not os.access(ps, os.X_OK):
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
out = subprocess.run([ps, '-p', str(pid), '-o', field + '='], stdout=subprocess.PIPE,
|
|
185
|
+
stderr=subprocess.DEVNULL, timeout=5,
|
|
186
|
+
env=dict(os.environ, LC_ALL='C')).stdout.decode(errors='replace')
|
|
187
|
+
except (OSError, subprocess.SubprocessError):
|
|
188
|
+
continue
|
|
189
|
+
return ' '.join(out.split()) or None
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def identity(pid: int) -> str | None:
|
|
194
|
+
return _ps_field(pid, 'lstart')
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _pid_alive(pid: int) -> bool:
|
|
198
|
+
try:
|
|
199
|
+
os.kill(pid, 0)
|
|
200
|
+
except ProcessLookupError:
|
|
201
|
+
return False
|
|
202
|
+
except PermissionError:
|
|
203
|
+
return True
|
|
204
|
+
except OSError:
|
|
205
|
+
return False
|
|
206
|
+
# A zombie still answers kill(0) until its parent waits for it.
|
|
207
|
+
return not (_ps_field(pid, 'stat') or 'Z').startswith('Z')
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _wait_pid_gone(pid: int, seconds: float) -> bool:
|
|
211
|
+
deadline = time.monotonic() + seconds
|
|
212
|
+
while time.monotonic() < deadline:
|
|
213
|
+
if not _pid_alive(pid):
|
|
214
|
+
return True
|
|
215
|
+
time.sleep(0.05)
|
|
216
|
+
return not _pid_alive(pid)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _stop_live(pid: int) -> int:
|
|
220
|
+
try:
|
|
221
|
+
leader = os.getpgid(pid) == pid
|
|
222
|
+
except OSError:
|
|
223
|
+
leader = False
|
|
224
|
+
if leader:
|
|
225
|
+
if pid in _forbidden():
|
|
226
|
+
return 0
|
|
227
|
+
members = list_group(pid)
|
|
228
|
+
count = len(members) if members else 1
|
|
229
|
+
for sig, wait in ((signal.SIGTERM, TERM_WAIT_S), (signal.SIGKILL, KILL_WAIT_S)):
|
|
230
|
+
try:
|
|
231
|
+
os.killpg(pid, sig)
|
|
232
|
+
except OSError:
|
|
233
|
+
break
|
|
234
|
+
if wait_gone(pid, wait):
|
|
235
|
+
break
|
|
236
|
+
return count
|
|
237
|
+
# Recorded before setsid: the pid is still in its dispatcher's group, so only it is ours.
|
|
238
|
+
for sig, wait in ((signal.SIGTERM, TERM_WAIT_S), (signal.SIGKILL, KILL_WAIT_S)):
|
|
239
|
+
try:
|
|
240
|
+
os.kill(pid, sig)
|
|
241
|
+
except OSError:
|
|
242
|
+
break
|
|
243
|
+
if _wait_pid_gone(pid, wait):
|
|
244
|
+
break
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def stop(record: str) -> int:
|
|
249
|
+
try:
|
|
250
|
+
with open(record) as handle:
|
|
251
|
+
line = handle.readline()
|
|
252
|
+
except FileNotFoundError:
|
|
253
|
+
print(0)
|
|
254
|
+
return 0
|
|
255
|
+
except OSError as error:
|
|
256
|
+
print('forge-guard: cannot read %s: %s' % (record, error), file=sys.stderr)
|
|
257
|
+
print(0)
|
|
258
|
+
return 0
|
|
259
|
+
pid_text, _, recorded = line.rstrip('\n').partition('\t')
|
|
260
|
+
try:
|
|
261
|
+
pid = int(pid_text)
|
|
262
|
+
except ValueError:
|
|
263
|
+
pid = 0
|
|
264
|
+
recorded = ' '.join(recorded.split())
|
|
265
|
+
count = 0
|
|
266
|
+
if pid > 1:
|
|
267
|
+
if _pid_alive(pid):
|
|
268
|
+
# A mismatch, or no way to tell, means the pid now belongs to someone else.
|
|
269
|
+
if recorded and identity(pid) == recorded:
|
|
270
|
+
count = _stop_live(pid)
|
|
271
|
+
else:
|
|
272
|
+
count = _reap_group(pid, None)
|
|
273
|
+
try:
|
|
274
|
+
os.unlink(record)
|
|
275
|
+
except OSError:
|
|
276
|
+
pass
|
|
277
|
+
print(count)
|
|
151
278
|
return 0
|
|
152
279
|
|
|
153
280
|
|
|
@@ -229,7 +356,7 @@ def promises(path: str, tail_bytes: int = 3000) -> int:
|
|
|
229
356
|
def main(argv: list[str]) -> int:
|
|
230
357
|
if not argv:
|
|
231
358
|
print('usage: forge-guard.py ping | session -- CMD... | reap <pgid> <orphans-file> | '
|
|
232
|
-
'promises <file> [--tail-bytes N]', file=sys.stderr)
|
|
359
|
+
'identity <pid> | stop <record-file> | promises <file> [--tail-bytes N]', file=sys.stderr)
|
|
233
360
|
return 2
|
|
234
361
|
command, rest = argv[0], argv[1:]
|
|
235
362
|
try:
|
|
@@ -245,6 +372,20 @@ def main(argv: list[str]) -> int:
|
|
|
245
372
|
print('usage: forge-guard.py reap <pgid> <orphans-file>', file=sys.stderr)
|
|
246
373
|
return 2
|
|
247
374
|
return reap(int(rest[0]), rest[1])
|
|
375
|
+
if command == 'identity':
|
|
376
|
+
if len(rest) != 1:
|
|
377
|
+
print('usage: forge-guard.py identity <pid>', file=sys.stderr)
|
|
378
|
+
return 2
|
|
379
|
+
found = identity(int(rest[0]))
|
|
380
|
+
if found is None:
|
|
381
|
+
return 1
|
|
382
|
+
print(found)
|
|
383
|
+
return 0
|
|
384
|
+
if command == 'stop':
|
|
385
|
+
if len(rest) != 1:
|
|
386
|
+
print('usage: forge-guard.py stop <record-file>', file=sys.stderr)
|
|
387
|
+
return 2
|
|
388
|
+
return stop(rest[0])
|
|
248
389
|
if command == 'promises':
|
|
249
390
|
tail = 3000
|
|
250
391
|
if '--tail-bytes' in rest:
|
package/scripts/forge-issues.py
CHANGED
|
@@ -1107,7 +1107,7 @@ def print_human(out, applied):
|
|
|
1107
1107
|
f"{s['drafts']} draft(s) ({s['tolerated']} tolerated, {s['accepted']} accepted, "
|
|
1108
1108
|
f"{s['review']} review, {s['plausible']} plausible)")
|
|
1109
1109
|
for warning in out["warnings"]:
|
|
1110
|
-
print(f" warning {warning}")
|
|
1110
|
+
print(f" warning: {warning}")
|
|
1111
1111
|
for note in out["notes"]:
|
|
1112
1112
|
print(f" skipped {note}")
|
|
1113
1113
|
for a in out["already_filed"]:
|
|
@@ -130,7 +130,7 @@ plans_make_integration() { # plans_make_integration <repo> <branch> <commit> <wt
|
|
|
130
130
|
|
|
131
131
|
# Per-plan option files carried to a derived plan. The cost-bearing choices a user made
|
|
132
132
|
# for this plan apply to the plan made from it; `planner` does not (nothing plans again).
|
|
133
|
-
PLANS_OPTION_FILES="no_ripwire no_memory yolo_dwarf yolo_qa reviewer_session qa_threshold timeout verify_retries verify_cmd setup_cmd final_qa shadow_qa shadow_rate explore escalate_after fractal-routing.json fractal-selection.json jev-selection.json"
|
|
133
|
+
PLANS_OPTION_FILES="no_ripwire no_memory yolo_dwarf yolo_qa reviewer_session qa_threshold timeout verify_retries verify_cmd verify_parallel setup_cmd final_qa shadow_qa shadow_rate explore escalate_after fractal-routing.json fractal-selection.json jev-selection.json"
|
|
134
134
|
|
|
135
135
|
# --- accept ---------------------------------------------------------------------
|
|
136
136
|
# A reviewer's FAIL is the QA gate; overriding it is the user's call and never forge's or
|
|
@@ -524,7 +524,7 @@ Retry or accept them (or split them off into their own plan) first." 3
|
|
|
524
524
|
# and what the dataset records; readers only test that the file exists.
|
|
525
525
|
all=1; for p in "${SRC[@]}"; do [ -f "$p/reviewer_session" ] || all=0; done
|
|
526
526
|
[ "$all" = 1 ] && printf '%s\n' on > "$NEW/reviewer_session"
|
|
527
|
-
for f in qa_threshold timeout verify_retries; do
|
|
527
|
+
for f in qa_threshold timeout verify_retries verify_parallel; do
|
|
528
528
|
same=1
|
|
529
529
|
for p in "${SRC[@]}"; do
|
|
530
530
|
if [ ! -f "$p/$f" ] || ! cmp -s "$p/$f" "${SRC[0]}/$f"; then same=0; fi
|
|
@@ -541,7 +541,11 @@ Retry or accept them (or split them off into their own plan) first." 3
|
|
|
541
541
|
for p in "${SRC[@]}"; do [ -s "$p/setup_cmd" ] && { cp -p "$p/setup_cmd" "$NEW/setup_cmd"; break; }; done
|
|
542
542
|
if [ -n "$VERIFY" ]; then printf '%s' "$VERIFY" > "$NEW/verify_cmd"
|
|
543
543
|
else
|
|
544
|
-
|
|
544
|
+
# The first source that declares verification gives it: its lanes, else its command.
|
|
545
|
+
for p in "${SRC[@]}"; do
|
|
546
|
+
if verify_lanes_present "$p/verify.d"; then cp -Rp "$p/verify.d" "$NEW/verify.d"; break; fi
|
|
547
|
+
[ -s "$p/verify_cmd" ] && { cp -p "$p/verify_cmd" "$NEW/verify_cmd"; break; }
|
|
548
|
+
done
|
|
545
549
|
fi
|
|
546
550
|
for f in fractal-routing.json jev-selection.json; do
|
|
547
551
|
for p in "${SRC[@]}"; do [ -f "$p/$f" ] && { cp -p "$p/$f" "$NEW/$f"; break; }; done
|
|
@@ -743,6 +747,8 @@ except Exception:
|
|
|
743
747
|
for f in $PLANS_OPTION_FILES; do
|
|
744
748
|
[ ! -e "$PLAN/$f" ] || cp -p "$PLAN/$f" "$NEW/$f" || ok=0
|
|
745
749
|
done
|
|
750
|
+
# The plan's verification lanes travel with it, like its --verify.
|
|
751
|
+
[ ! -d "$PLAN/verify.d" ] || cp -Rp "$PLAN/verify.d" "$NEW/verify.d" || ok=0
|
|
746
752
|
for id in $(all_ids "$T"); do
|
|
747
753
|
case "$SEL" in *" $id "*) ;; *) continue ;; esac
|
|
748
754
|
plans_copy_taskdir "$PLAN/tasks/$id" "$NEW/tasks/$id" || ok=0
|
|
@@ -94,7 +94,7 @@ review_warn_unmerged() { # review_warn_unmerged <plan>
|
|
|
94
94
|
# notes what forge observed that no per-task reviewer was shown
|
|
95
95
|
review_context() { # review_context <pre|notes> <plan> <tasks.summary> <verification-dir>
|
|
96
96
|
python3 - "$1" "$2" "$3" "$4" "$REVIEW_REQ_BUDGET" <<'PY'
|
|
97
|
-
import os, re, sys
|
|
97
|
+
import json, os, re, sys
|
|
98
98
|
|
|
99
99
|
mode, plan, summary, vdir, budget = sys.argv[1:6]
|
|
100
100
|
BUDGET = int(budget)
|
|
@@ -195,13 +195,32 @@ else:
|
|
|
195
195
|
'A promise of later work means a check may never have run.')
|
|
196
196
|
ver = []
|
|
197
197
|
status = read(os.path.join(vdir, 'verification.status'))
|
|
198
|
+
lanes = None
|
|
199
|
+
try:
|
|
200
|
+
doc = json.loads(read(os.path.join(vdir, 'verification.lanes.json')) or b'null')
|
|
201
|
+
lanes = [l for l in doc.get('lanes') or [] if isinstance(l, dict) and l.get('name')]
|
|
202
|
+
except (ValueError, AttributeError):
|
|
203
|
+
pass
|
|
198
204
|
if status and status.strip():
|
|
199
205
|
ver.append('status: %s' % text(status))
|
|
200
206
|
cmd = read(os.path.join(vdir, 'verification.command'))
|
|
201
|
-
if
|
|
207
|
+
if lanes is not None:
|
|
208
|
+
# Lanes: each one's state; a flaky lane passed only on its rerun, a known lane failed
|
|
209
|
+
# only tests the project's baseline lists.
|
|
210
|
+
ver.append('lanes (each ran on its own; a failed lane was rerun alone):')
|
|
211
|
+
for l in lanes:
|
|
212
|
+
ver.append(' %s: %s' % (l['name'], l.get('state') or 'unknown'))
|
|
213
|
+
by = lambda state: [l['name'] for l in lanes if l.get('state') == state]
|
|
214
|
+
if by('fail'):
|
|
215
|
+
ver.append('failed: ' + ', '.join('lane ' + n for n in by('fail')))
|
|
216
|
+
for n in by('flaky'):
|
|
217
|
+
ver.append('FLAKY: lane %s failed once and passed on rerun, so its pass is not clean' % n)
|
|
218
|
+
for n in by('known'):
|
|
219
|
+
ver.append('known failures only: lane %s (every failing test is in its baseline)' % n)
|
|
220
|
+
elif cmd and cmd.strip():
|
|
202
221
|
ver.append('command: %s' % text(cmd))
|
|
203
222
|
flaky = read(os.path.join(vdir, 'verification.flaky'))
|
|
204
|
-
if flaky and flaky.strip():
|
|
223
|
+
if flaky and flaky.strip() and lanes is None:
|
|
205
224
|
ver.append('FLAKY: the command failed once and passed on rerun, so the pass is not clean:')
|
|
206
225
|
ver += [' ' + l for l in text(flaky).splitlines() if not l.startswith(('first_log=', 'retry_log='))]
|
|
207
226
|
cover = read(os.path.join(vdir, 'verification.coverage.txt'))
|
|
@@ -406,15 +406,43 @@ prepare_resume() { # prepare_resume <plan> <id>
|
|
|
406
406
|
return 0
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
# --infra-fallback: a dwarf-stage capacity stop moves the task to the next healthy member of
|
|
410
|
+
# its own tier pool (forge-route.py fallback), recorded like an escalation with source
|
|
411
|
+
# `fallback`. No attempt is spent: the stop already refunded it. Returns 1, changing nothing,
|
|
412
|
+
# when there is no candidate, a pin is waiting (a human's choice wins) or the plan is Fractal.
|
|
413
|
+
prepare_fallback() { # prepare_fallback <plan> <id> <excluded harness/model,...>
|
|
414
|
+
local PLAN="$1" id="$2" tdir="$1/tasks/$2" cur pick
|
|
415
|
+
[ -e "$tdir/escalation.pin" ] && return 1
|
|
416
|
+
forge_plan_is_fractal "$PLAN" && return 1
|
|
417
|
+
cur="$(field "$PLAN/tasks.tsv" "$id" dwarf)"
|
|
418
|
+
pick="$(python3 "$SKILL_DIR/scripts/forge-route.py" fallback --plan "$PLAN" --task "$id" --exclude "$3")" || return 1
|
|
419
|
+
[ -n "$pick" ] || return 1
|
|
420
|
+
write_escalation "$PLAN" "$id" "$cur" "$pick" fallback || return 1
|
|
421
|
+
rm -f "$tdir/status" "$tdir/infra.txt"
|
|
422
|
+
note "$id: $cur is at capacity; falling back to $pick (same tier)"
|
|
423
|
+
}
|
|
424
|
+
|
|
409
425
|
# The scheduler's `_prepare_retry` target: prepare, then clear the status so the task
|
|
410
426
|
# reads as PENDING again. `retry` clears it itself, after its own preflight. With --resume it
|
|
411
|
-
# prepares an infrastructure stop's resume instead (prepare_resume), and the status stays
|
|
412
|
-
|
|
427
|
+
# prepares an infrastructure stop's resume instead (prepare_resume), and the status stays;
|
|
428
|
+
# with --dwarf <spec> the spec is the explicit new implementer; --fallback <pairs> is
|
|
429
|
+
# prepare_fallback.
|
|
430
|
+
retry_for_scheduler() { # retry_for_scheduler <plan> <id> [--resume | --dwarf <spec> | --fallback <pairs>]
|
|
413
431
|
if [ "${3:-}" = --resume ]; then
|
|
414
432
|
prepare_resume "$1" "$2"
|
|
415
433
|
return $?
|
|
416
434
|
fi
|
|
417
|
-
|
|
435
|
+
if [ "${3:-}" = --fallback ]; then
|
|
436
|
+
prepare_fallback "$1" "$2" "${4:-}"
|
|
437
|
+
return $?
|
|
438
|
+
fi
|
|
439
|
+
# --dwarf: `forge control retry --dwarf`, the same explicit escalation as `retry --dwarf`.
|
|
440
|
+
if [ "${3:-}" = --dwarf ]; then
|
|
441
|
+
[ -n "${4:-}" ] || die "_prepare_retry: --dwarf needs a spec"
|
|
442
|
+
prepare_retry "$1" "$2" "$4" || return $?
|
|
443
|
+
else
|
|
444
|
+
prepare_retry "$1" "$2" || return $?
|
|
445
|
+
fi
|
|
418
446
|
rm -f "$1/tasks/$2/status"
|
|
419
447
|
}
|
|
420
448
|
|
|
@@ -540,7 +568,26 @@ report_guard_notes() { # report_guard_notes <plan>
|
|
|
540
568
|
|
|
541
569
|
report_verify_notes() { # report_verify_notes <dir holding verification.*>
|
|
542
570
|
local d="$1"
|
|
543
|
-
if [ -s "$d/verification.
|
|
571
|
+
if [ -s "$d/verification.lanes.json" ]; then
|
|
572
|
+
# Lanes: per lane, from the runner's own record
|
|
573
|
+
python3 - "$d" <<'PY'
|
|
574
|
+
import json, os, sys
|
|
575
|
+
d = sys.argv[1]
|
|
576
|
+
try:
|
|
577
|
+
with open(os.path.join(d, 'verification.lanes.json'), encoding='utf-8') as fh:
|
|
578
|
+
lanes = [l for l in json.load(fh).get('lanes') or [] if isinstance(l, dict) and l.get('name')]
|
|
579
|
+
except (OSError, ValueError, AttributeError):
|
|
580
|
+
sys.exit(0)
|
|
581
|
+
lines = ['FLAKY: lane %s failed once and passed on rerun' % l['name'] for l in lanes if l.get('state') == 'flaky']
|
|
582
|
+
lines += ['known failures only: lane %s' % l['name'] for l in lanes if l.get('state') == 'known']
|
|
583
|
+
failed = [l['name'] for l in lanes if l.get('state') == 'fail']
|
|
584
|
+
if failed:
|
|
585
|
+
lines.append('verification failed in lane%s %s: read %s' % ('s' if len(failed) > 1 else '', ', '.join(failed),
|
|
586
|
+
os.path.join(d, 'verification.log')))
|
|
587
|
+
if lines:
|
|
588
|
+
print('\n'.join(lines) + '\n')
|
|
589
|
+
PY
|
|
590
|
+
elif [ -s "$d/verification.flaky" ]; then
|
|
544
591
|
echo "FLAKY verification: the command failed once and passed on rerun — read $d/verification.log"
|
|
545
592
|
echo " before trusting this result; an intermittent failure can be a real bug."
|
|
546
593
|
echo
|
|
@@ -1,12 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Verification helpers for forge-parallel.sh. Sourced; functions only.
|
|
3
3
|
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# A lanes directory counts once it holds a lane file (dot files and backups are skipped,
|
|
5
|
+
# as forge-verify.py skips them).
|
|
6
|
+
verify_lanes_present() { # verify_lanes_present <dir>
|
|
7
|
+
local f
|
|
8
|
+
[ -d "$1" ] || return 1
|
|
9
|
+
for f in "$1"/*; do
|
|
10
|
+
[ -e "$f" ] || continue
|
|
11
|
+
case "${f##*/}" in *~) continue ;; esac
|
|
12
|
+
return 0
|
|
13
|
+
done
|
|
14
|
+
return 1
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Where a plan's verification comes from, in this order: the plan's lanes (--verify-lane,
|
|
18
|
+
# $PLAN/verify.d/), the plan's --verify, the repository's lanes (.forge/verify.d/), the
|
|
19
|
+
# repository's .forge/verify. Prints "<kind><TAB><location>" with kind lanes:plan,
|
|
20
|
+
# command:plan, lanes:repo or command:repo, or nothing when none is configured.
|
|
21
|
+
verify_source_for() { # verify_source_for <plan> <repo>
|
|
22
|
+
if verify_lanes_present "$1/verify.d"; then printf 'lanes:plan\t%s\n' "$1/verify.d"
|
|
23
|
+
elif [ -s "$1/verify_cmd" ]; then printf 'command:plan\t%s\n' "$1/verify_cmd"
|
|
24
|
+
elif verify_lanes_present "$2/.forge/verify.d"; then printf 'lanes:repo\t%s\n' "$2/.forge/verify.d"
|
|
25
|
+
elif [ -s "$2/.forge/verify" ]; then printf 'command:repo\t%s\n' "$2/.forge/verify"
|
|
26
|
+
fi
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# The verify command a plan would run, as text: the command itself, or for lanes every
|
|
31
|
+
# lane's command (its #forge: option lines left out), one after another. Prints nothing
|
|
32
|
+
# when nothing is configured.
|
|
6
33
|
verify_command_for() { # verify_command_for <plan> <repo>
|
|
7
|
-
|
|
8
|
-
|
|
34
|
+
local src loc f
|
|
35
|
+
src="$(verify_source_for "$1" "$2")"
|
|
36
|
+
loc="${src#*$'\t'}"
|
|
37
|
+
case "$src" in
|
|
38
|
+
command:*) cat "$loc" ;;
|
|
39
|
+
lanes:*)
|
|
40
|
+
for f in "$loc"/*; do
|
|
41
|
+
[ -f "$f" ] || continue
|
|
42
|
+
case "${f##*/}" in *~) continue ;; esac
|
|
43
|
+
awk 'body || !/^#forge:/ { body = 1; print }' "$f"
|
|
44
|
+
done ;;
|
|
45
|
+
esac
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Lanes are checked before any model is paid for: the runner's own validation, against the
|
|
50
|
+
# checkout the lanes will run in (baseline= files exist, reports= and baseline= stay inside).
|
|
51
|
+
# Dies with exit 2 and the runner's messages on an invalid source; a single command, or
|
|
52
|
+
# nothing, passes.
|
|
53
|
+
verify_lanes_check() { # verify_lanes_check <plan> <repo> <checkout>
|
|
54
|
+
local src loc out
|
|
55
|
+
src="$(verify_source_for "$1" "$2")"
|
|
56
|
+
case "$src" in lanes:*) ;; *) return 0 ;; esac
|
|
57
|
+
loc="${src#*$'\t'}"
|
|
58
|
+
if [ "${src%%$'\t'*}" = lanes:repo ] && [ -s "$2/.forge/verify" ]; then
|
|
59
|
+
note ".forge/verify is ignored: the repository declares verification lanes in .forge/verify.d/"
|
|
9
60
|
fi
|
|
61
|
+
out="$(python3 "$SKILL_DIR/scripts/forge-verify.py" lanes --lanes-dir "$loc" --checkout "$3" 2>&1 >/dev/null)" \
|
|
62
|
+
|| die "invalid verification lanes in $loc:
|
|
63
|
+
$out"
|
|
10
64
|
return 0
|
|
11
65
|
}
|
|
12
66
|
|
|
@@ -15,10 +69,16 @@ verify_command_for() { # verify_command_for <plan> <repo>
|
|
|
15
69
|
# configuration and say which suites the command does not run. Advisory by design — it
|
|
16
70
|
# prints and records, it never changes an outcome, and a failure of the checker is silent.
|
|
17
71
|
verify_coverage_report() { # verify_coverage_report <checkout> <command> <out-file>
|
|
18
|
-
local dir="$1" cmd="$2" out="$3" line
|
|
72
|
+
local dir="$1" cmd="$2" out="$3" line file
|
|
19
73
|
[ -f "$SKILL_DIR/scripts/forge-verify-coverage.py" ] || return 0
|
|
20
74
|
rm -f "$out"
|
|
21
|
-
if [
|
|
75
|
+
if [[ "$cmd" == *$'\n'* ]]; then
|
|
76
|
+
# Several lines (every lane's command, or a multi-line script): through a file.
|
|
77
|
+
file="$(mktemp "${TMPDIR:-/tmp}/forge-verify-cmd.XXXXXX")" || return 0
|
|
78
|
+
printf '%s\n' "$cmd" > "$file"
|
|
79
|
+
python3 "$SKILL_DIR/scripts/forge-verify-coverage.py" check --repo "$dir" --command-file "$file" --out "$out" >/dev/null 2>&1
|
|
80
|
+
rm -f "$file"
|
|
81
|
+
elif [ -n "$cmd" ]; then
|
|
22
82
|
python3 "$SKILL_DIR/scripts/forge-verify-coverage.py" check --repo "$dir" --command="$cmd" --out "$out" >/dev/null 2>&1
|
|
23
83
|
else
|
|
24
84
|
python3 "$SKILL_DIR/scripts/forge-verify-coverage.py" check --repo "$dir" --none --out "$out" >/dev/null 2>&1
|
|
@@ -28,3 +88,74 @@ verify_coverage_report() { # verify_coverage_report <checkout> <command> <out-fi
|
|
|
28
88
|
fi
|
|
29
89
|
return 0
|
|
30
90
|
}
|
|
91
|
+
|
|
92
|
+
# Lanes mode of verify_result: the lane runner (forge-verify.py) through the same stoppable
|
|
93
|
+
# path as a single command, then verification.* from what it wrote. No whole-command rerun
|
|
94
|
+
# and no Jev triage: the runner reruns a failed lane itself. Returns 0 pass, 1 fail, 130
|
|
95
|
+
# interrupted.
|
|
96
|
+
verify_lanes_run() { # verify_lanes_run <plan> <checkout> <out> <lanes dir> <retries>
|
|
97
|
+
local plan="$1" wt="$2" out="$3" dir="$4" retries="$5" parallel rc=0 cmd fpcmd
|
|
98
|
+
parallel="$(cat "$plan/verify_parallel" 2>/dev/null)"
|
|
99
|
+
case "$parallel" in ''|*[!0-9]*) parallel=3 ;; esac
|
|
100
|
+
[ "$retries" -le 9 ] 2>/dev/null || retries=9 # the runner takes 0 to 9
|
|
101
|
+
python3 "$SKILL_DIR/scripts/forge-verify.py" lanes --lanes-dir "$dir" 2>/dev/null \
|
|
102
|
+
| awk -F'\t' '{ print $1 ": " $3 }' > "$out/verification.command"
|
|
103
|
+
verify_coverage_report "$wt" "$(verify_command_for "$plan" "$(cat "$plan/repo")")" "$out/verification.coverage.txt"
|
|
104
|
+
forge_fingerprint "$wt" > "$out/verification.fingerprint" || return 1
|
|
105
|
+
# The runner judges "a lane changed the checkout" by forge's own fingerprint.
|
|
106
|
+
fpcmd="source $(printf %q "$SKILL_DIR/scripts/forge-artifact.sh") && forge_fingerprint ."
|
|
107
|
+
cmd="exec python3 $(printf %q "$SKILL_DIR/scripts/forge-verify.py") run --checkout $(printf %q "$wt")"
|
|
108
|
+
cmd="$cmd --out $(printf %q "$out") --lanes-dir $(printf %q "$dir") --parallel $parallel --retries $retries"
|
|
109
|
+
cmd="$cmd --fingerprint-cmd $(printf %q "$fpcmd")"
|
|
110
|
+
verify_stopped "$out" && return 130
|
|
111
|
+
# The runner stops its own lanes (TERM, then KILL 5 s later; each lane is a session the
|
|
112
|
+
# outer KILL cannot reach), so its grace here outlasts that.
|
|
113
|
+
verify_exec "$wt" "$cmd" "$out/verification.log" 15 || rc=$?
|
|
114
|
+
[ "$FORGE_VERIFY_STOP" = 1 ] && rc=130
|
|
115
|
+
echo "$rc" > "$out/verification.exit"
|
|
116
|
+
# verification.flaky per flaky lane; then each failed lane's last 200 lines under the summary
|
|
117
|
+
python3 - "$out" <<'PY'
|
|
118
|
+
import json, os, sys
|
|
119
|
+
out = sys.argv[1]
|
|
120
|
+
try:
|
|
121
|
+
with open(os.path.join(out, 'verification.lanes.json'), encoding='utf-8') as fh:
|
|
122
|
+
doc = json.load(fh)
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
lanes = [l for l in doc.get('lanes') or [] if isinstance(l, dict) and l.get('name')]
|
|
126
|
+
flaky = [l for l in lanes if l.get('state') == 'flaky']
|
|
127
|
+
if flaky:
|
|
128
|
+
with open(os.path.join(out, 'verification.flaky'), 'w', encoding='utf-8') as fh:
|
|
129
|
+
fh.write('lanes=%s\n' % ','.join(l['name'] for l in flaky))
|
|
130
|
+
for l in flaky:
|
|
131
|
+
runs = l.get('runs') or [{}]
|
|
132
|
+
fh.write('%s.first_exit=%s\n%s.retries_used=%d\n' % (l['name'], runs[0].get('exit'), l['name'], len(runs) - 1))
|
|
133
|
+
|
|
134
|
+
def tail(name, lines=200, size=256 << 10):
|
|
135
|
+
try:
|
|
136
|
+
with open(os.path.join(out, os.path.basename(name or '')), 'rb') as fh:
|
|
137
|
+
fh.seek(max(0, os.fstat(fh.fileno()).st_size - size))
|
|
138
|
+
return fh.read().decode('utf-8', 'replace').splitlines()[-lines:]
|
|
139
|
+
except OSError:
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
with open(os.path.join(out, 'verification.log'), 'a', encoding='utf-8') as fh:
|
|
143
|
+
for l in lanes:
|
|
144
|
+
if l.get('state') != 'fail':
|
|
145
|
+
continue
|
|
146
|
+
runs = l.get('runs') or []
|
|
147
|
+
lines = tail(runs[-1].get('log') if runs else l.get('log'))
|
|
148
|
+
fh.write('\n== lane %s failed (exit %s); the last %d lines of its output:\n' % (l['name'], l.get('exit'), len(lines)))
|
|
149
|
+
fh.write(''.join(line + '\n' for line in lines))
|
|
150
|
+
PY
|
|
151
|
+
case "$rc" in
|
|
152
|
+
0) echo PASS > "$out/verification.status" ;;
|
|
153
|
+
130) verify_mark_interrupted "$out"; return 130 ;;
|
|
154
|
+
*) echo FAIL > "$out/verification.status" ;;
|
|
155
|
+
esac
|
|
156
|
+
local flaky; flaky="$(sed -n 's/^lanes=//p' "$out/verification.flaky" 2>/dev/null)"
|
|
157
|
+
[ -z "$flaky" ] || note "FLAKY: lane ${flaky//,/, lane } failed once and passed on rerun; this is not a clean pass"
|
|
158
|
+
[ "$rc" = 0 ] && return 0
|
|
159
|
+
note "verification failed ($( [ "$rc" = 2 ] && echo 'the lane runner refused its configuration' || echo 'see the lanes below' )): $out/verification.log"
|
|
160
|
+
return 1
|
|
161
|
+
}
|
|
@@ -21,15 +21,46 @@
|
|
|
21
21
|
# --timeout S run retry $PLAN/timeout dispatch timeout, seconds (0 = none)
|
|
22
22
|
# --retry-failed N run (this run only) automatic reviewer-driven retries
|
|
23
23
|
# --infra-retries M run (this run only) waits out quota/auth/network stops
|
|
24
|
+
# --infra-backoff SHAPE run $PLAN/infra_backoff waits between those stops: exp:B[,C],
|
|
25
|
+
# linear:S,T[,C] or list:a,b,... (default exp:60)
|
|
26
|
+
# --infra-fallback run $PLAN/infra_fallback a dwarf's capacity stop moves it to the next healthy
|
|
27
|
+
# --no-infra-fallback model of its tier pool (off by default)
|
|
24
28
|
# --qa-threshold Pn|none run retry $PLAN/qa_threshold least severe finding that still blocks
|
|
25
29
|
# --verify-retries N run retry integrate $PLAN/verify_retries reruns of a failed verify command
|
|
26
30
|
# --escalate-after N run retry $PLAN/escalate_after failed attempts on one model before the
|
|
27
31
|
# next goes a tier up (default 3; 0 = off)
|
|
32
|
+
# --escalate-effort run retry $PLAN/escalate_effort on: before the stop, retry the same
|
|
33
|
+
# model at the next effort its pairing accepts;
|
|
34
|
+
# tasks already stopped are decided again
|
|
35
|
+
# --no-escalate-effort run retry (removes the file) turn that step off (the default)
|
|
28
36
|
# --final-review SPEC integrate $PLAN/final_qa QA spec for the whole-run review
|
|
37
|
+
# --verify-lane L=CMD run retry $PLAN/verify.d/<L> a verification lane: name[options]=command,
|
|
38
|
+
# options as in a lane file (group=g,first,...);
|
|
39
|
+
# repeatable; replaces --verify and the lanes
|
|
40
|
+
# of an earlier invocation
|
|
41
|
+
# --verify-parallel N run retry integrate $PLAN/verify_parallel lanes run at once (1-32, default 3)
|
|
29
42
|
PP_SHIFT=2
|
|
30
43
|
PP_RETRY_FAILED=0
|
|
31
44
|
PP_INFRA_RETRIES=0
|
|
32
45
|
PP_FINAL_REVIEW=""
|
|
46
|
+
PP_LANES_FRESH=0 # the first --verify-lane of an invocation, or the first after a --verify, starts a fresh verify.d/
|
|
47
|
+
|
|
48
|
+
# --verify-lane 'name[opt,opt]=command' -> $PLAN/verify.d/name: the options become the lane
|
|
49
|
+
# file's first line, `#forge: opt opt`, and the command follows, as in a lane file.
|
|
50
|
+
pp_lane() { # pp_lane <plan> <argument>
|
|
51
|
+
local plan="$1" arg="$2" re='^([A-Za-z0-9][A-Za-z0-9_-]{0,63})(\[([^]]*)\])?=(.*)$'
|
|
52
|
+
[[ "$arg" =~ $re ]] \
|
|
53
|
+
|| die "--verify-lane '$arg': expected name[options]=command (a name of letters, digits, _ and -, at most 64; options in closed brackets)"
|
|
54
|
+
local name="${BASH_REMATCH[1]}" opts="${BASH_REMATCH[3]}" cmd="${BASH_REMATCH[4]}"
|
|
55
|
+
[ -n "${cmd//[[:space:]]/}" ] || die "--verify-lane '$arg': the command is empty"
|
|
56
|
+
if [ "$PP_LANES_FRESH" = 0 ]; then
|
|
57
|
+
rm -rf "$plan/verify.d" && mkdir "$plan/verify.d" || die "could not create $plan/verify.d"
|
|
58
|
+
PP_LANES_FRESH=1
|
|
59
|
+
fi
|
|
60
|
+
{ [ -z "$opts" ] || printf '#forge: %s\n' "${opts//,/ }"; printf '%s\n' "$cmd"; } > "$plan/verify.d/$name" \
|
|
61
|
+
|| die "could not write $plan/verify.d/$name"
|
|
62
|
+
rm -f "$plan/verify_cmd"
|
|
63
|
+
}
|
|
33
64
|
|
|
34
65
|
pp_nonneg() { # pp_nonneg <flag> <value>
|
|
35
66
|
case "$2" in ''|*[!0-9]*) die "$1 needs a non-negative integer" ;; esac
|
|
@@ -49,6 +80,16 @@ forge_pp_flag() {
|
|
|
49
80
|
--infra-retries)
|
|
50
81
|
[ "$cmd" = run ] || return 1
|
|
51
82
|
pp_nonneg --infra-retries "${2:-}"; PP_INFRA_RETRIES="$2" ;;
|
|
83
|
+
--infra-fallback)
|
|
84
|
+
[ "$cmd" = run ] || return 1
|
|
85
|
+
PP_SHIFT=1; printf 'on\n' > "$plan/infra_fallback" ;;
|
|
86
|
+
--no-infra-fallback)
|
|
87
|
+
[ "$cmd" = run ] || return 1
|
|
88
|
+
PP_SHIFT=1; rm -f "$plan/infra_fallback" ;;
|
|
89
|
+
--infra-backoff)
|
|
90
|
+
[ "$cmd" = run ] || return 1
|
|
91
|
+
PP_MSG="$(python3 "$SKILL_DIR/scripts/forge-schedule.py" backoff-check "${2:-}" 2>&1)" || die "${PP_MSG#forge: }"
|
|
92
|
+
printf '%s\n' "$2" > "$plan/infra_backoff" ;;
|
|
52
93
|
--qa-threshold)
|
|
53
94
|
case "$cmd" in run|retry|review) ;; *) return 1 ;; esac
|
|
54
95
|
case "${2:-}" in
|
|
@@ -64,10 +105,28 @@ forge_pp_flag() {
|
|
|
64
105
|
if [ "$2" = 0 ]; then
|
|
65
106
|
rm -f "$plan"/tasks/*/escalation.stop "$plan"/tasks/*/split.suggested "$plan"/tasks/*/escalation.next
|
|
66
107
|
fi ;;
|
|
108
|
+
--escalate-effort)
|
|
109
|
+
case "$cmd" in run|retry) ;; *) return 1 ;; esac
|
|
110
|
+
PP_SHIFT=1 # a switch, not --escalate-after's N
|
|
111
|
+
printf 'on\n' > "$plan/escalate_effort"
|
|
112
|
+
# A task the policy stopped with the step off is decided again: the step may have a move.
|
|
113
|
+
rm -f "$plan"/tasks/*/escalation.stop "$plan"/tasks/*/split.suggested "$plan"/tasks/*/escalation.next ;;
|
|
114
|
+
--no-escalate-effort)
|
|
115
|
+
case "$cmd" in run|retry) ;; *) return 1 ;; esac
|
|
116
|
+
PP_SHIFT=1
|
|
117
|
+
rm -f "$plan/escalate_effort" ;;
|
|
67
118
|
--verify-retries)
|
|
68
119
|
case "$cmd" in run|retry|integrate) ;; *) return 1 ;; esac
|
|
69
120
|
pp_nonneg --verify-retries "${2:-}"
|
|
70
121
|
printf '%s' "$2" > "$plan/verify_retries" ;;
|
|
122
|
+
--verify-lane)
|
|
123
|
+
case "$cmd" in run|retry) ;; *) return 1 ;; esac
|
|
124
|
+
pp_lane "$plan" "${2-}" ;;
|
|
125
|
+
--verify-parallel)
|
|
126
|
+
case "$cmd" in run|retry|integrate) ;; *) return 1 ;; esac
|
|
127
|
+
case "${2:-}" in ''|*[!0-9]*) die "--verify-parallel needs an integer from 1 to 32" ;; esac
|
|
128
|
+
[ "$2" -ge 1 ] && [ "$2" -le 32 ] || die "--verify-parallel needs an integer from 1 to 32"
|
|
129
|
+
printf '%s' "$2" > "$plan/verify_parallel" ;;
|
|
71
130
|
--final-review)
|
|
72
131
|
[ "$cmd" = integrate ] || return 1
|
|
73
132
|
PP_FINAL_REVIEW="${2:?--final-review needs a QA spec}"
|