@giamme/forge 0.11.1 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +44 -11
  5. package/docs/commands.md +45 -7
  6. package/docs/decomposed-runs.md +43 -2
  7. package/docs/forge-ui.md +27 -6
  8. package/docs/reviews.md +31 -6
  9. package/package.json +1 -1
  10. package/references/dataset.md +3 -1
  11. package/references/decompose.md +159 -17
  12. package/references/harnesses.md +13 -1
  13. package/references/ui.md +14 -4
  14. package/scripts/forge-control.py +268 -25
  15. package/scripts/forge-dispatch.sh +80 -3
  16. package/scripts/forge-guard.py +156 -15
  17. package/scripts/forge-lib-plans.sh +9 -3
  18. package/scripts/forge-lib-review.sh +22 -3
  19. package/scripts/forge-lib-task.sh +51 -4
  20. package/scripts/forge-lib-verify.sh +137 -6
  21. package/scripts/forge-parallel-options.sh +59 -0
  22. package/scripts/forge-parallel.sh +212 -28
  23. package/scripts/forge-ripwire.py +1 -1
  24. package/scripts/forge-route.py +157 -5
  25. package/scripts/forge-schedule.py +687 -50
  26. package/scripts/forge-verify.py +878 -0
  27. package/scripts/forge_dataset/forecast.py +29 -4
  28. package/scripts/forge_dataset/harvest.py +10 -4
  29. package/scripts/forge_verify_reports.py +225 -0
  30. package/ui/README.md +18 -3
  31. package/ui/docs/api.md +126 -17
  32. package/ui/forge_ui/control.py +12 -2
  33. package/ui/forge_ui/export.py +20 -7
  34. package/ui/forge_ui/reader.py +307 -11
  35. package/ui/forge_ui/server.py +16 -0
  36. package/ui/forge_ui/static/app.js +2 -1
  37. package/ui/forge_ui/static/controls.js +217 -33
  38. package/ui/forge_ui/static/gantt.js +14 -9
  39. package/ui/forge_ui/static/index.html +1 -0
  40. package/ui/forge_ui/static/panel.js +2 -1
  41. package/ui/forge_ui/static/runmodel.js +92 -11
  42. package/ui/forge_ui/static/scene3d.js +9 -5
  43. package/ui/forge_ui/static/style.css +60 -0
  44. package/ui/forge_ui/static/verifyview.js +324 -0
@@ -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 reap(pgid: int, orphans_file: str) -> int:
119
- # Never signal ourselves, the dispatcher that called us, or init.
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
- if pgid in forbidden or pgid < 0:
126
- print(0)
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
- try:
138
- with open(orphans_file, 'w') as handle:
139
- for pid, command in found:
140
- handle.write('%s\t%s\n' % (pid or '?', ' '.join(command.split()) or '?'))
141
- except OSError:
142
- pass
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 (ProcessLookupError, PermissionError, OSError):
162
+ except OSError:
147
163
  break
148
164
  if wait_gone(pgid, wait):
149
165
  break
150
- print(len(found))
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:
@@ -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
- for p in "${SRC[@]}"; do [ -s "$p/verify_cmd" ] && { cp -p "$p/verify_cmd" "$NEW/verify_cmd"; break; }; done
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 cmd and cmd.strip():
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
- retry_for_scheduler() { # retry_for_scheduler <plan> <id> [--resume]
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
- prepare_retry "$1" "$2" || return $?
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.flaky" ]; then
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
- # The verify command a plan would run: --verify (persisted in the plan) first, then the
5
- # repository's own .forge/verify. Prints nothing when neither is configured.
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
- if [ -s "$1/verify_cmd" ]; then cat "$1/verify_cmd"
8
- elif [ -s "$2/.forge/verify" ]; then cat "$2/.forge/verify"
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 [ -n "$cmd" ]; then
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}"