@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +46 -12
  5. package/docs/commands.md +48 -9
  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-issues.py +1 -1
  18. package/scripts/forge-lib-plans.sh +9 -3
  19. package/scripts/forge-lib-review.sh +22 -3
  20. package/scripts/forge-lib-task.sh +51 -4
  21. package/scripts/forge-lib-verify.sh +137 -6
  22. package/scripts/forge-parallel-options.sh +59 -0
  23. package/scripts/forge-parallel.sh +212 -28
  24. package/scripts/forge-ripwire.py +1 -1
  25. package/scripts/forge-route.py +157 -5
  26. package/scripts/forge-schedule.py +687 -50
  27. package/scripts/forge-ui.py +8 -4
  28. package/scripts/forge-verify.py +878 -0
  29. package/scripts/forge_dataset/forecast.py +29 -4
  30. package/scripts/forge_dataset/harvest.py +10 -4
  31. package/scripts/forge_verify_reports.py +225 -0
  32. package/ui/README.md +19 -4
  33. package/ui/docs/api.md +139 -25
  34. package/ui/forge_ui/control.py +26 -7
  35. package/ui/forge_ui/export.py +20 -7
  36. package/ui/forge_ui/reader.py +307 -11
  37. package/ui/forge_ui/server.py +16 -0
  38. package/ui/forge_ui/static/app.js +2 -1
  39. package/ui/forge_ui/static/controls.js +217 -33
  40. package/ui/forge_ui/static/findingsview.js +37 -12
  41. package/ui/forge_ui/static/gantt.js +14 -9
  42. package/ui/forge_ui/static/index.html +1 -0
  43. package/ui/forge_ui/static/panel.js +2 -1
  44. package/ui/forge_ui/static/runmodel.js +92 -11
  45. package/ui/forge_ui/static/scene3d.js +9 -5
  46. package/ui/forge_ui/static/settings.js +48 -12
  47. package/ui/forge_ui/static/style.css +60 -0
  48. package/ui/forge_ui/static/verifyview.js +324 -0
@@ -18,6 +18,8 @@
18
18
  # forge-parallel.sh split <plan-dir> <new-plan> --tasks <id,id> [--dry-run]
19
19
  # forge-parallel.sh combine <new-plan> <plan-a> <plan-b> [<plan>...] [--verify <command>] [--dry-run]
20
20
  # forge-parallel.sh _task <plan-dir> <task-id> (internal; coordinator target)
21
+ # forge-parallel.sh _qa_only <plan-dir> <task-id> (internal; exit 0 when the next entry only re-runs QA)
22
+ # forge-parallel.sh _waves <plan-dir> (internal; rewrite waves.tsv after a live `forge control add`)
21
23
  #
22
24
  # Routing flags (plan):
23
25
  # --dwarf <spec> fallback for any difficulty tier not named below
@@ -430,8 +432,9 @@ EOF
430
432
  # parallelism: two dwarves editing one file produce a conflict no reviewer can
431
433
  # untangle, so they get separate waves no matter how the goal was decomposed.
432
434
  compute_waves() {
433
- local PLAN="$1" T="$1/tasks.tsv" W="$1/waves.tsv"
434
- : > "$W"; : > "$PLAN/deferrals.txt"
435
+ # Written to temp files and renamed only on success: a cycle leaves the previous files.
436
+ local PLAN="$1" T="$1/tasks.tsv" W="$1/.waves.tsv.$$" D="$1/.deferrals.txt.$$"
437
+ : > "$W"; : > "$D"
435
438
  local remaining done_ids wave n id deps files wave_files ok d progressed
436
439
  remaining="$(all_ids "$T" | tr '\n' ' ')"
437
440
  done_ids=" "; wave=0
@@ -450,7 +453,7 @@ compute_waves() {
450
453
  if [ "$ok" = 1 ] && lists_overlap "$files" "$wave_files"; then
451
454
  ok=0
452
455
  printf 'wave %s: %s deferred (file overlap with a task already in this wave)\n' \
453
- "$wave" "$id" >> "$PLAN/deferrals.txt"
456
+ "$wave" "$id" >> "$D"
454
457
  fi
455
458
  if [ "$ok" = 1 ]; then
456
459
  printf '%s\t%s\n' "$wave" "$id" >> "$W"
@@ -460,10 +463,11 @@ compute_waves() {
460
463
  next_remaining="$next_remaining $id"
461
464
  fi
462
465
  done
463
- [ "$progressed" = 1 ] || die "dependency cycle or unsatisfiable deps among:$next_remaining"
466
+ [ "$progressed" = 1 ] || { rm -f "$W" "$D"; die "dependency cycle or unsatisfiable deps among:$next_remaining"; }
464
467
  for id in $(awk -F'\t' -v w="$wave" '$1==w {print $2}' "$W"); do done_ids="$done_ids$id "; done
465
468
  remaining="$next_remaining"
466
469
  done
470
+ mv -f "$D" "$PLAN/deferrals.txt" && mv -f "$W" "$PLAN/waves.tsv"
467
471
  }
468
472
 
469
473
  render_table() {
@@ -1171,7 +1175,7 @@ do_retry() {
1171
1175
  --dwarf) NEWDWARF="${2:?--dwarf needs a spec}"; shift 2 ;;
1172
1176
  --yolo-dwarf) touch "$PLAN/yolo_dwarf"; shift ;;
1173
1177
  --yolo-qa) touch "$PLAN/yolo_qa"; shift ;;
1174
- --verify) printf '%s' "${2:?--verify needs a command}" > "$PLAN/verify_cmd"; shift 2 ;;
1178
+ --verify) printf '%s' "${2:?--verify needs a command}" > "$PLAN/verify_cmd"; rm -rf "$PLAN/verify.d"; PP_LANES_FRESH=0; shift 2 ;;
1175
1179
  --setup) printf '%s' "${2:?--setup needs a command}" > "$PLAN/setup_cmd"; shift 2 ;;
1176
1180
  *)
1177
1181
  # `case` is not a loop, so `continue` here belongs to the enclosing while.
@@ -1210,6 +1214,9 @@ do_retry() {
1210
1214
  fi
1211
1215
  [ "${FORGE_RIPWIRE:-}" != off ] || touch "$PLAN/no_ripwire"
1212
1216
  dependencies_ready "$PLAN" "$id" || { write_results "$PLAN"; return 5; }
1217
+ local vrepo vcheckout; vrepo="$(cat "$PLAN/repo" 2>/dev/null)"; vcheckout="$(cat "$PLAN/wt_root")/_integration"
1218
+ [ -d "$vcheckout" ] || vcheckout="$vrepo"
1219
+ verify_lanes_check "$PLAN" "$vrepo" "$vcheckout"
1213
1220
  [ ! -f "$PLAN/no_ripwire" ] || export FORGE_RIPWIRE=off
1214
1221
  preflight_plan "$PLAN" || return $?
1215
1222
  /bin/bash "$SKILL_DIR/scripts/forge-install-ripwire.sh" || true
@@ -1286,14 +1293,104 @@ verify_run_note() { # verify_run_note <out> <kind> <rc> <before> <after> <log> <
1286
1293
  "$(( $(date +%s) - $7 ))" "verification.run-$n.log" >> "$out/verification.runs.tsv"
1287
1294
  }
1288
1295
 
1289
- verify_result() ( # plan, checkout, artifact directory
1296
+ # --- stoppable verification --------------------------------------------------
1297
+ # `run` sets FORGE_RUN_STOPPABLE for the verification it supervises. Then the verify command
1298
+ # runs as its own process group, and a TERM to the verification subshell stops that whole
1299
+ # group: TERM at once, KILL after a grace period. `retry` and `integrate` leave it unset and
1300
+ # keep the plain foreground command.
1301
+ verify_group_stop() { # TERM the verify command's group; KILL it after FORGE_VERIFY_GRACE seconds
1302
+ [ -n "$FORGE_VERIFY_GROUP" ] && [ -z "$FORGE_VERIFY_TIMER" ] || return 0
1303
+ local group="$FORGE_VERIFY_GROUP" termed=1
1304
+ # Until the guard's setsid there is no group: the TERM goes to the guard itself, which dies
1305
+ # of it before it starts the command. A group that forms all the same gets its TERM from
1306
+ # the timer.
1307
+ kill -TERM -- "-$group" 2>/dev/null || { termed=0; kill -TERM "$group" 2>/dev/null; }
1308
+ # The timer polls in short naps rather than sleeping the whole grace in one: it ends by
1309
+ # itself within a nap of the group's last member, and a long sleep left behind would hold
1310
+ # the plan lock it inherited.
1311
+ ( trap - TERM INT
1312
+ ticks=$((FORGE_VERIFY_GRACE * 10))
1313
+ while kill -0 -- "-$group" 2>/dev/null || { [ "$termed" = 0 ] && kill -0 "$group" 2>/dev/null; }; do
1314
+ if [ "$termed" = 0 ] && kill -TERM -- "-$group" 2>/dev/null; then termed=1; fi
1315
+ if [ "$ticks" -le 0 ]; then
1316
+ if [ "$termed" = 1 ]; then kill -KILL -- "-$group" 2>/dev/null; else kill -KILL "$group" 2>/dev/null; fi
1317
+ break
1318
+ fi
1319
+ sleep 0.1
1320
+ ticks=$((ticks - 1))
1321
+ done ) &
1322
+ FORGE_VERIFY_TIMER=$!
1323
+ }
1324
+
1325
+ verify_signal() { # trap handler of a stoppable verification
1326
+ FORGE_VERIFY_STOP=1
1327
+ verify_group_stop
1328
+ }
1329
+
1330
+ verify_exec() { # verify_exec <checkout> <command> <log> [grace seconds, default 5] -> the command's exit code, 130 when stopped
1331
+ local wt="$1" cmd="$2" log="$3" rc=0 group deadline
1332
+ if [ "${FORGE_RUN_STOPPABLE:-0}" != 1 ]; then
1333
+ (cd "$wt" && /bin/bash -c "$cmd") > "$log" 2>&1
1334
+ return $?
1335
+ fi
1336
+ FORGE_VERIFY_GRACE="${4:-5}"
1337
+ [ "$FORGE_VERIFY_STOP" = 1 ] && return 130
1338
+ # A background job ignores SIGINT and SIGQUIT, and the command would inherit that; the
1339
+ # wrapper restores both before the guard puts the command in a session of its own.
1340
+ (cd "$wt" && exec python3 -c 'import os, signal, sys
1341
+ for name in (signal.SIGINT, signal.SIGQUIT):
1342
+ signal.signal(name, signal.SIG_DFL)
1343
+ os.execv(sys.executable, [sys.executable] + sys.argv[1:])' \
1344
+ "$SKILL_DIR/scripts/forge-guard.py" session -- /bin/bash -c "$cmd") > "$log" 2>&1 <&0 &
1345
+ group=$!
1346
+ FORGE_VERIFY_GROUP="$group"
1347
+ [ "$FORGE_VERIFY_STOP" = 1 ] && verify_group_stop
1348
+ # `wait` returns at once when a trapped signal arrives, so wait again until the job is gone.
1349
+ wait "$group" || rc=$?
1350
+ while kill -0 "$group" 2>/dev/null; do rc=0; wait "$group" || rc=$?; done
1351
+ if [ "$FORGE_VERIFY_STOP" = 1 ]; then
1352
+ # Members that outlived the leader hold the plan lock; the KILL timer ends them.
1353
+ deadline=$((SECONDS + FORGE_VERIFY_GRACE + 5))
1354
+ while kill -0 -- "-$group" 2>/dev/null && [ "$SECONDS" -lt "$deadline" ]; do sleep 0.1; done
1355
+ fi
1356
+ FORGE_VERIFY_GROUP=""
1357
+ if [ -n "$FORGE_VERIFY_TIMER" ]; then
1358
+ wait "$FORGE_VERIFY_TIMER" 2>/dev/null
1359
+ FORGE_VERIFY_TIMER=""
1360
+ fi
1361
+ [ "$FORGE_VERIFY_STOP" = 1 ] && return 130
1362
+ return "$rc"
1363
+ }
1364
+
1365
+ verify_mark_interrupted() { # verify_mark_interrupted <out>
1366
+ echo INTERRUPTED > "$1/verification.status"
1367
+ echo 130 > "$1/verification.exit"
1368
+ note "verification interrupted; the verify command was stopped and is not rerun"
1369
+ }
1370
+
1371
+ # True, once the verification is marked INTERRUPTED, when a stop has arrived. Checked before
1372
+ # each run of the command (a stop that came first runs nothing and records no run) and after.
1373
+ verify_stopped() { # verify_stopped <out>
1374
+ [ "$FORGE_VERIFY_STOP" = 1 ] || return 1
1375
+ verify_mark_interrupted "$1"
1376
+ }
1377
+
1378
+ verify_result() ( verify_result_body "$@" ) # plan, checkout, artifact directory
1379
+
1380
+ # The body is a function of its own so `run` can start it as `( verify_result_body ... ) &`:
1381
+ # that one subshell is the background job, the home of the signal handler above and of
1382
+ # forge_metric_begin's EXIT trap.
1383
+ verify_result_body() { # plan, checkout, artifact directory
1290
1384
  local plan="$1" wt="$2" out="$3" cmd="" before after rc=0 repo jev_loaded=0 vretries
1385
+ FORGE_VERIFY_STOP=0 FORGE_VERIFY_GROUP="" FORGE_VERIFY_TIMER="" FORGE_VERIFY_GRACE=5
1386
+ [ "${FORGE_RUN_STOPPABLE:-0}" = 1 ] && trap verify_signal TERM INT
1291
1387
  mkdir -p "$out"
1292
1388
  forge_metric_begin "$out" verification
1293
1389
  forge_metric_phase verification
1294
1390
  repo="$(cat "$plan/repo")"
1295
1391
  rm -f "$out/verification.flaky" "$out/verification.retry.log" "$out/verification.coverage.txt" \
1296
- "$out/verification.runs.tsv" "$out"/verification.run-*.log "$out/verification.commit"
1392
+ "$out/verification.runs.tsv" "$out"/verification.run-*.log "$out/verification.commit" \
1393
+ "$out/verification.lanes.json" "$out"/verification.lane.*.log
1297
1394
  # The commit being verified, named before any early return, so every outcome (FAIL and
1298
1395
  # UNVERIFIED included) can be joined to the code it judged.
1299
1396
  git -C "$wt" rev-parse HEAD > "$out/verification.commit" 2>/dev/null || rm -f "$out/verification.commit"
@@ -1309,12 +1406,20 @@ verify_result() ( # plan, checkout, artifact directory
1309
1406
  if [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
1310
1407
  source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded=1
1311
1408
  fi
1312
- if [ -s "$plan/verify_cmd" ]; then cmd="$(cat "$plan/verify_cmd")"
1313
- elif [ -s "$repo/.forge/verify" ]; then cmd="$(cat "$repo/.forge/verify")"; fi
1409
+ local vsource; vsource="$(verify_source_for "$plan" "$repo")"
1410
+ case "$vsource" in command:*) cmd="$(cat "${vsource#*$'\t'}")" ;; esac
1314
1411
  if [ "$(forge_tree "$wt")" != "$(git -C "$wt" rev-parse HEAD^{tree})" ]; then
1315
1412
  echo FAIL > "$out/verification.status"
1316
1413
  note "verification checkout has source changes outside its commit"; return 1
1317
1414
  fi
1415
+ case "$vsource" in
1416
+ lanes:*)
1417
+ if [ "$jev_loaded" = 1 ] && forge_jev_active tests; then
1418
+ note "jev: flake triage applies to a single verify command; lanes rerun their own failures"
1419
+ fi
1420
+ verify_lanes_run "$plan" "$wt" "$out" "${vsource#*$'\t'}" "$vretries"
1421
+ return $? ;;
1422
+ esac
1318
1423
  # Only when nothing is configured: ask Jev to name a command that actually exists
1319
1424
  # in the repo. Its exit code, not its stdout text, decides whether a judgment was
1320
1425
  # produced — any non-zero exit (no key, disabled, nothing suitable, a traceback)
@@ -1363,10 +1468,12 @@ with open(out + "/verification.jev.json", "w") as fh:
1363
1468
  printf '%s\n' "$cmd" > "$out/verification.command"
1364
1469
  before="$(forge_fingerprint "$wt")" || return 1
1365
1470
  local vt0; vt0="$(date +%s)"
1366
- (cd "$wt" && /bin/bash -c "$cmd") > "$out/verification.log" 2>&1 || rc=$?
1367
- echo "$rc" > "$out/verification.exit"
1368
- after="$(forge_fingerprint "$wt")" || return 1
1471
+ verify_stopped "$out" && return 130
1472
+ verify_exec "$wt" "$cmd" "$out/verification.log" || rc=$?
1473
+ [ "$FORGE_VERIFY_STOP" = 1 ] || echo "$rc" > "$out/verification.exit"
1474
+ after="$(forge_fingerprint "$wt")" || { verify_stopped "$out" && return 130; return 1; }
1369
1475
  verify_run_note "$out" initial "$rc" "$before" "$after" verification.log "$vt0"
1476
+ verify_stopped "$out" && return 130
1370
1477
  echo "$before" > "$out/verification.fingerprint"
1371
1478
  if [ "$rc" != 0 ] || [ "$before" != "$after" ]; then
1372
1479
  local flaked=0 det_retried=0 n_retry=0 rc3 b3 a3
@@ -1379,9 +1486,11 @@ with open(out + "/verification.jev.json", "w") as fh:
1379
1486
  note "verification failed (exit $rc); rerunning to tell a flaky lane from a regression ($n_retry/$vretries)"
1380
1487
  b3="$(forge_fingerprint "$wt")" || return 1
1381
1488
  vt0="$(date +%s)"
1382
- (cd "$wt" && /bin/bash -c "$cmd") > "$out/verification.retry.log" 2>&1 || rc3=$?
1383
- a3="$(forge_fingerprint "$wt")" || return 1
1489
+ verify_stopped "$out" && return 130
1490
+ verify_exec "$wt" "$cmd" "$out/verification.retry.log" || rc3=$?
1491
+ a3="$(forge_fingerprint "$wt")" || { verify_stopped "$out" && return 130; return 1; }
1384
1492
  verify_run_note "$out" rerun "$rc3" "$b3" "$a3" verification.retry.log "$vt0"
1493
+ verify_stopped "$out" && return 130
1385
1494
  if [ "$rc3" = 0 ] && [ "$b3" = "$a3" ]; then
1386
1495
  flaked=1
1387
1496
  {
@@ -1446,10 +1555,12 @@ print(v if v is not None else "0.90")
1446
1555
  note "jev: known_flake (confidence $confidence >= $flake_act), corroborated by a recorded trap — retrying once"
1447
1556
  before2="$(forge_fingerprint "$wt")" || return 1
1448
1557
  vt0="$(date +%s)"
1449
- (cd "$wt" && /bin/bash -c "$cmd") > "$out/verification.retry.log" 2>&1 || rc2=$?
1558
+ verify_stopped "$out" && return 130
1559
+ verify_exec "$wt" "$cmd" "$out/verification.retry.log" || rc2=$?
1450
1560
  rc2="${rc2:-0}"
1451
- after2="$(forge_fingerprint "$wt")" || return 1
1561
+ after2="$(forge_fingerprint "$wt")" || { verify_stopped "$out" && return 130; return 1; }
1452
1562
  verify_run_note "$out" jev_retry "$rc2" "$before2" "$after2" verification.retry.log "$vt0"
1563
+ verify_stopped "$out" && return 130
1453
1564
  if [ "$rc2" = 0 ] && [ "$before2" = "$after2" ]; then
1454
1565
  flaked=1
1455
1566
  note "jev: retry passed — treating as a corroborated flake, not a regression"
@@ -1490,7 +1601,7 @@ with open(path, "w") as fh:
1490
1601
  return 1
1491
1602
  fi
1492
1603
  echo PASS > "$out/verification.status"
1493
- )
1604
+ }
1494
1605
 
1495
1606
  write_results() { # write_results <plan>
1496
1607
  local PLAN="$1" T="$1/tasks.tsv" run_id id
@@ -1511,7 +1622,29 @@ forecast_report() {
1511
1622
  }
1512
1623
 
1513
1624
  # --- run ----------------------------------------------------------------------
1514
- do_run() (
1625
+ # Waits for a background job and, when a trapped signal ends the wait early, waits again
1626
+ # until the job is gone; its exit code is left in run_rc.
1627
+ run_wait() { # run_wait <pid>
1628
+ run_rc=0
1629
+ wait "$1" || run_rc=$?
1630
+ while kill -0 "$1" 2>/dev/null; do run_rc=0; wait "$1" || run_rc=$?; done
1631
+ }
1632
+
1633
+ # The handler of do_run's subshell: a signal reaches the job in progress (the scheduler,
1634
+ # then the verification) as TERM. Before any job exists there is nothing to clean up.
1635
+ run_signal() {
1636
+ run_stop=1
1637
+ if [ -n "$run_child" ]; then kill -TERM "$run_child" 2>/dev/null
1638
+ elif [ "$run_phase" = pre ]; then exit 130; fi
1639
+ }
1640
+
1641
+ # Not a ( ) function: main runs it as `( do_run ... ) &`, one subshell that is both the
1642
+ # background job and the home of its signal handler. A function called with `&` would be a
1643
+ # second subshell level, and an async function does not run its EXIT trap.
1644
+ do_run() {
1645
+ local run_child="" run_stop=0 run_phase=pre run_rc=0 interrupted=0 vstopped=0
1646
+ FORGE_RUN_STOPPABLE=1
1647
+ trap run_signal TERM INT
1515
1648
  local PLAN="${1:?run needs a plan dir}"; shift
1516
1649
  local fractal_pipeline_args=("$@")
1517
1650
  local MAXP=3 DRY=0 jev_rc=0 FORECAST=1
@@ -1527,7 +1660,7 @@ do_run() (
1527
1660
  --max-parallel) MAXP="${2:?}"; shift 2 ;;
1528
1661
  --yolo-dwarf) touch "$PLAN/yolo_dwarf"; shift ;;
1529
1662
  --yolo-qa) touch "$PLAN/yolo_qa"; shift ;;
1530
- --verify) printf '%s' "${2:?--verify needs a command}" > "$PLAN/verify_cmd"; shift 2 ;;
1663
+ --verify) printf '%s' "${2:?--verify needs a command}" > "$PLAN/verify_cmd"; rm -rf "$PLAN/verify.d"; PP_LANES_FRESH=0; shift 2 ;;
1531
1664
  --setup) printf '%s' "${2:?--setup needs a command}" > "$PLAN/setup_cmd"; shift 2 ;;
1532
1665
  --dry-run) DRY=1; shift ;;
1533
1666
  *)
@@ -1623,22 +1756,46 @@ do_run() (
1623
1756
  # Also before any model is paid for: what this plan is likely to cost, and which tasks
1624
1757
  # history says may bounce. Advisory, budgeted and silent without history; it never stops a run.
1625
1758
  [ "$FORECAST" = 1 ] && forecast_report "$PLAN"
1759
+ # Invalid verification lanes stop the run here, before any model is paid for.
1760
+ verify_lanes_check "$PLAN" "$REPO" "$int_wt"
1626
1761
  # Say now, before any model is paid for, which suites the verify command will not run.
1627
1762
  verify_coverage_report "$REPO" "$(verify_command_for "$PLAN" "$REPO")" "$PLAN/verification.coverage.txt"
1628
1763
  local sched_rc=0 paused=0 sched_args=()
1629
1764
  [ "$PP_RETRY_FAILED" -gt 0 ] && sched_args+=(--retry-failed "$PP_RETRY_FAILED")
1630
1765
  [ "$PP_INFRA_RETRIES" -gt 0 ] && sched_args+=(--infra-retries "$PP_INFRA_RETRIES")
1631
1766
  forge_metric_phase dispatch
1632
- python3 "$SKILL_DIR/scripts/forge-schedule.py" "$SELF" "$PLAN" "$MAXP" ${sched_args[@]+"${sched_args[@]}"} || sched_rc=$?
1767
+ # In the background so a signal to this shell ends the wait; the scheduler gets it as TERM,
1768
+ # stops its tasks and returns 130.
1769
+ run_phase=scheduler
1770
+ python3 "$SKILL_DIR/scripts/forge-schedule.py" "$SELF" "$PLAN" "$MAXP" ${sched_args[@]+"${sched_args[@]}"} <&0 &
1771
+ run_child=$!
1772
+ [ "$run_stop" = 1 ] && kill -TERM "$run_child" 2>/dev/null
1773
+ run_wait "$run_child"; sched_rc=$run_rc
1774
+ run_child=""
1633
1775
  [ "$sched_rc" != 0 ] && failed=1
1634
1776
  [ "$sched_rc" = 8 ] && paused=1
1635
1777
 
1636
1778
  forge_metric_phase verification
1779
+ # Counted after that phase call: a signal that arrived while it ran is handled only once it
1780
+ # returned, and must still keep verification from starting.
1781
+ [ "$sched_rc" = 130 ] || [ "$run_stop" = 1 ] && interrupted=1
1637
1782
  # An infrastructure pause leaves the run unfinished, so there is nothing yet worth
1638
- # verifying; the next `run` resumes and verifies at its end.
1639
- if [ "$paused" = 0 ]; then
1640
- verify_result "$PLAN" "$int_wt" "$PLAN" || failed=1
1783
+ # verifying; the next `run` resumes and verifies at its end. An interrupted run has
1784
+ # merged only part of its work: a verification of that is not worth its minutes, and it
1785
+ # would run again as the rerun if it were killed.
1786
+ if [ "$paused" = 0 ] && [ "$interrupted" = 0 ]; then
1787
+ run_phase=verification
1788
+ ( verify_result_body "$PLAN" "$int_wt" "$PLAN" ) <&0 &
1789
+ run_child=$!
1790
+ [ "$run_stop" = 1 ] && kill -TERM "$run_child" 2>/dev/null
1791
+ run_wait "$run_child"
1792
+ [ "$run_rc" != 0 ] && failed=1
1793
+ [ "$run_rc" = 130 ] && vstopped=1
1794
+ run_child=""
1795
+ # A stop that killed the subshell before its handler was set leaves no record: it counts too.
1796
+ [ "$vstopped" = 1 ] || [ "$run_stop" = 1 ] && interrupted=1
1641
1797
  fi
1798
+ run_phase=results
1642
1799
  write_results "$PLAN"
1643
1800
  # Only prune worktrees for work that is safely merged, and only after the record
1644
1801
  # of it has been written and read back. A failed task keeps its worktree and
@@ -1670,15 +1827,27 @@ do_run() (
1670
1827
  done
1671
1828
  [ "$drift" = 1 ] && echo
1672
1829
 
1830
+ # From this run's own flags, never from a verification.status an earlier run left.
1831
+ if [ "$interrupted" = 1 ]; then
1832
+ if [ "$vstopped" = 1 ]; then echo "interrupted during verification, which was stopped and not rerun."
1833
+ else echo "interrupted; verification was skipped."; fi
1834
+ fi
1835
+ local stopped_ids; stopped_ids="$(awk -F'\t' '$2=="INTERRUPTED" {print $1}' "$PLAN/results.tsv")"
1836
+ if [ -n "$stopped_ids" ]; then
1837
+ echo "A later run does not dispatch an interrupted task; continue each one with:"
1838
+ for id in $stopped_ids; do echo " forge-parallel.sh retry $PLAN $id"; done
1839
+ fi
1840
+ [ "$interrupted" = 1 ] || [ -n "$stopped_ids" ] && echo
1673
1841
  # The retry hint is for tasks that actually failed; a run that only failed verification,
1674
- # or only paused on infrastructure, has nothing to retry task by task.
1675
- if [ "$(awk -F'\t' '$2!="MERGED" && $2!="INFRA" && $2!="PENDING" && $2!="BLOCKED" {n++} END {print n+0}' "$PLAN/results.tsv")" -gt 0 ]; then
1842
+ # or only paused on infrastructure, has nothing to retry task by task. An interrupted task
1843
+ # has its own hint above.
1844
+ if [ "$(awk -F'\t' '$2!="MERGED" && $2!="INFRA" && $2!="PENDING" && $2!="BLOCKED" && $2!="INTERRUPTED" {n++} END {print n+0}' "$PLAN/results.tsv")" -gt 0 ]; then
1676
1845
  echo "retry a failed task with its reviewer's findings:"
1677
1846
  echo " forge-parallel.sh retry $PLAN <task-id> [--dwarf <spec>]"
1678
1847
  echo "or let a run retry them itself: forge-parallel.sh run $PLAN --retry-failed 1"
1679
1848
  echo
1680
1849
  fi
1681
- if [ "$failed" = 1 ] && [ "$paused" = 0 ] && [ "$(cat "$PLAN/verification.status" 2>/dev/null)" = FAIL ]; then
1850
+ if [ "$failed" = 1 ] && [ "$paused" = 0 ] && [ "$interrupted" = 0 ] && [ "$(cat "$PLAN/verification.status" 2>/dev/null)" = FAIL ]; then
1682
1851
  echo "combined verification failed: see $PLAN/verification.log"
1683
1852
  echo
1684
1853
  fi
@@ -1695,10 +1864,11 @@ do_run() (
1695
1864
  else
1696
1865
  echo "Your branch and working tree were not touched."
1697
1866
  fi
1867
+ [ "$interrupted" = 1 ] && return 130
1698
1868
  [ "$paused" = 1 ] && return 8
1699
1869
  [ "$failed" = 1 ] && return 5
1700
1870
  return 0
1701
- )
1871
+ }
1702
1872
 
1703
1873
  # --- integrate ----------------------------------------------------------------
1704
1874
  do_integrate() {
@@ -1789,7 +1959,19 @@ esac
1789
1959
  need_fn() { declare -F "$1" >/dev/null || die "'$CMD' is not available: $1 is missing" 3; }
1790
1960
  case "$CMD" in
1791
1961
  plan) do_plan "$@"; rc=$?; forge_update_notice; exit "$rc" ;;
1792
- run) do_run "$@"; rc=$?; forge_update_notice; exit "$rc" ;;
1962
+ run)
1963
+ # The signal handler lives here, at the pid the user started: do_run is a subshell, and
1964
+ # bash runs a trap only between commands, so it is started in the background and waited
1965
+ # for. A signal goes to it as TERM; do_run stops the scheduler or the verification.
1966
+ run_signalled=0 run_job=""
1967
+ trap 'run_signalled=1; [ -n "$run_job" ] && kill -TERM "$run_job" 2>/dev/null' TERM INT
1968
+ ( do_run "$@" ) <&0 &
1969
+ run_job=$!
1970
+ [ "$run_signalled" = 1 ] && kill -TERM "$run_job" 2>/dev/null
1971
+ run_wait "$run_job"; rc=$run_rc
1972
+ trap - TERM INT
1973
+ [ "$run_signalled" = 1 ] && [ "$rc" -gt 128 ] && rc=130
1974
+ forge_update_notice; exit "$rc" ;;
1793
1975
  retry) do_retry "$@" ;;
1794
1976
  integrate) do_integrate "$@" ;;
1795
1977
  accept) need_fn do_accept; do_accept "$@" ;;
@@ -1799,5 +1981,7 @@ case "$CMD" in
1799
1981
  _task) do_task "$@" ;;
1800
1982
  _merge) merge_task "$@" ;;
1801
1983
  _prepare_retry) retry_for_scheduler "$@" ;;
1984
+ _qa_only) qa_only_next "$@" ;;
1985
+ _waves) compute_waves "$@" ;;
1802
1986
  *) die "unknown subcommand '$CMD'" ;;
1803
1987
  esac
@@ -52,7 +52,7 @@ def bounded(command, cwd, deadline, limit=16384):
52
52
  # Also kill descendants that closed their pipes before the parent exited.
53
53
  try:
54
54
  os.killpg(process.pid, signal.SIGKILL)
55
- except ProcessLookupError:
55
+ except (ProcessLookupError, PermissionError): # macOS: EPERM for a group of zombies only
56
56
  pass
57
57
  process.wait()
58
58
  streams.close()
@@ -20,9 +20,15 @@ bias (inverse-propensity weighting needs the probability each choice had).
20
20
  The retry policy: after <plan>/escalate_after (default 3; 0 = off) failed attempts on
21
21
  one spec, the next attempt goes to the next tier up of the user's pools instead of
22
22
  repeating the same dispatch. Prints that spec when one is due. When none exists, writes
23
- tasks/<id>/escalation.stop and split.suggested and exits 3. `--check` (run after every
24
- attempt) also records a due escalation in escalation.next, and clears the markers once
25
- the streak no longer holds. A pending tasks/<id>/escalation.pin (a human's one-shot
23
+ tasks/<id>/escalation.stop and split.suggested and exits 3. When <plan>/escalate_effort
24
+ contains `on` and no higher tier has another model, the same alias and harness is tried
25
+ once more at the next effort its pairing accepts (weakest above the current one; a spec
26
+ that names no effort counts as medium, a dwarf dispatch's default) before that stop.
27
+ The streak then counts per effort, so the new effort starts again. At the pairing's
28
+ highest effort the task still stops, and split.suggested says that effort was tried too.
29
+ With the file absent the streak and the stop are exactly as before. `--check` (run after
30
+ every attempt) also records a due escalation in escalation.next, and clears the markers
31
+ once the streak no longer holds. A pending tasks/<id>/escalation.pin (a human's one-shot
26
32
  choice, applied by prepare_retry) is what `--check` reports as the next model instead
27
33
  (escalation.next = its spec), whatever escalate_after says, unless the next retry is a
28
34
  QA-only resume or the plan is Fractal (pins never apply there). `--qa-only-next` is
@@ -60,6 +66,7 @@ import os
60
66
  import random
61
67
  import secrets
62
68
  import stat
69
+ import subprocess
63
70
  import sys
64
71
  import tempfile
65
72
  import time
@@ -71,7 +78,12 @@ REGISTRY = SCRIPTS.parent / 'registry.tsv'
71
78
  FAILED = ('FAIL', 'TIMEOUT') # a judgment against the spec that ran
72
79
  NEUTRAL = ('INFRA', 'UNKNOWN', 'ERROR', 'RUNNING', 'INTERRUPTED') # say nothing about it
73
80
  DEFAULT_ESCALATE_AFTER = 3
81
+ DEFAULT_EFFORT = 'medium' # forge-dispatch.sh dwarf default
74
82
  LOCK_WAIT = 5.0 # seconds to wait for P/control.lock
83
+ # forge-dispatch.sh rank_of. Only for "which accepted effort is above this one"; the set of
84
+ # efforts a pairing accepts comes from `forge-dispatch.sh efforts`, not from this table.
85
+ # ultra and ultracode share a rank: each is its own harness's top, under different names.
86
+ EFFORT_RANK = {'low': 0, 'medium': 1, 'high': 2, 'xhigh': 3, 'max': 4, 'ultra': 5, 'ultracode': 5}
75
87
 
76
88
 
77
89
  def rng_for(task: str):
@@ -209,6 +221,64 @@ def stronger(spec: str, pools: dict, registry: dict):
209
221
  return None, 'it is already in the highest tier of this plan\'s pools (%s)' % TIERS[rank]
210
222
 
211
223
 
224
+ # --- capacity fallback ---------------------------------------------------------------
225
+
226
+ def pair_of(spec: str, registry: dict):
227
+ """(harness, model id) a spec runs on, as forge-dispatch.sh's lookup resolves it; None when it cannot."""
228
+ parts = str(spec).split(':')
229
+ alias, harness = parts[0], ':'.join(parts[2:])
230
+ rows = registry.get(alias)
231
+ if rows:
232
+ if not harness:
233
+ return next(iter(rows.items()))
234
+ return harness, rows.get(harness, alias)
235
+ return (harness, alias) if harness else None
236
+
237
+
238
+ def parse_excluded(text: str) -> set:
239
+ """`harness/model` items, comma-separated; `harness/*` excludes every model on that harness."""
240
+ out = set()
241
+ for item in (text or '').split(','):
242
+ harness, slash, model = item.strip().partition('/')
243
+ if harness and slash and model:
244
+ out.add((harness, model))
245
+ return out
246
+
247
+
248
+ def fallback_pick(current: str, pools: dict, registry: dict, excluded: set):
249
+ """The next member of the current dwarf's own tier pool, in pool order and wrapping around,
250
+ that runs another model on a pair nobody paused; None when there is none."""
251
+ rank = rank_of(current, pools, registry)
252
+ if rank is None:
253
+ return None
254
+ pool = pools[TIERS[rank]]
255
+ own = canonical(current, registry)
256
+ keys = [canonical(s, registry) for s in pool]
257
+ start = keys.index(own) if own in keys else -1
258
+ for step in range(1, len(pool) + 1):
259
+ candidate = pool[(start + step) % len(pool)]
260
+ pair = pair_of(candidate, registry)
261
+ if canonical(candidate, registry) == own or pair is None:
262
+ continue
263
+ if pair in excluded or (pair[0], '*') in excluded:
264
+ continue
265
+ return candidate
266
+ return None
267
+
268
+
269
+ def fallback(args) -> int:
270
+ plan = Path(os.path.realpath(args.plan))
271
+ current = _dwarf_of(plan, args.task)
272
+ if not current or current in ('-', 'UNASSIGNED'):
273
+ return 1
274
+ pick = fallback_pick(current, load_pools(plan / 'fractal-routing.json'), registry_models(),
275
+ parse_excluded(args.exclude))
276
+ if not pick:
277
+ return 1
278
+ print(pick)
279
+ return 0
280
+
281
+
212
282
  # --- exploration -------------------------------------------------------------------
213
283
 
214
284
  def explore(args) -> str:
@@ -250,20 +320,87 @@ def _dwarf_of(plan: Path, task: str) -> str:
250
320
  return ''
251
321
 
252
322
 
253
- def failed_streak(rows: list, current: str, registry: dict) -> int:
323
+ def _spec_parts(spec: str):
324
+ """(alias, effort, harness) of alias[:effort[:harness]]. Effort and harness are '' when unnamed."""
325
+ alias, sep, rest = str(spec).partition(':')
326
+ if not sep:
327
+ return alias, '', ''
328
+ effort, sep, harness = rest.partition(':')
329
+ return alias, effort, harness if sep else ''
330
+
331
+
332
+ def _effort_of(spec: str) -> str:
333
+ """The effort a spec names, or medium when it names none (what a dwarf dispatch runs)."""
334
+ _alias, effort, _harness = _spec_parts(spec)
335
+ return effort or DEFAULT_EFFORT
336
+
337
+
338
+ def _effort_step(plan: Path) -> bool:
339
+ try:
340
+ return (plan / 'escalate_effort').read_text().strip() == 'on'
341
+ except OSError:
342
+ return False
343
+
344
+
345
+ def _accepted_efforts(spec: str) -> list:
346
+ """Efforts `spec`'s pairing accepts, weakest first. Empty when dispatch cannot say."""
347
+ try:
348
+ done = subprocess.run(
349
+ ['bash', str(SCRIPTS / 'forge-dispatch.sh'), 'efforts', '--spec', spec],
350
+ capture_output=True, text=True, timeout=30)
351
+ except (OSError, subprocess.SubprocessError):
352
+ return []
353
+ if done.returncode != 0:
354
+ return []
355
+ return [line.strip() for line in done.stdout.splitlines() if line.strip()]
356
+
357
+
358
+ def _effective_effort(requested: str, accepted: list):
359
+ """The effort a dwarf dispatch runs when `requested` is asked of a pairing accepting
360
+ `accepted`: as asked when accepted, else the strongest accepted at or below it, else the
361
+ weakest accepted (forge-dispatch.sh's clamp)."""
362
+ if requested in accepted:
363
+ return requested
364
+ want = EFFORT_RANK.get(requested)
365
+ below = [e for e in accepted if want is not None and EFFORT_RANK.get(e, 99) <= want]
366
+ return below[-1] if below else (accepted[0] if accepted else None)
367
+
368
+
369
+ def _next_effort_spec(spec: str):
370
+ """The same alias and harness at the next accepted effort above the one a dispatch of
371
+ `spec` actually runs, or None."""
372
+ alias, _effort, harness = _spec_parts(spec)
373
+ accepted = _accepted_efforts(spec)
374
+ rank = EFFORT_RANK.get(_effective_effort(_effort_of(spec), accepted) or '')
375
+ if not alias or rank is None:
376
+ return None
377
+ for effort in accepted:
378
+ other = EFFORT_RANK.get(effort)
379
+ if other is not None and other > rank:
380
+ return '%s:%s:%s' % (alias, effort, harness) if harness else '%s:%s' % (alias, effort)
381
+ return None
382
+
383
+
384
+ def failed_streak(rows: list, current: str, registry: dict, by_effort: bool = False) -> int:
254
385
  """Distinct trailing attempt numbers that failed on `current`'s model.
255
386
 
256
387
  rows: (attempt, status, spec-or-None) oldest first. A row without a spec predates the
257
388
  column and is taken as the current spec. Infra stops, missing verdicts and forge errors
258
389
  judge nothing and are skipped; a pass, a merge or another model ends the streak.
390
+ With by_effort, another effort of the same model ends it too (a bump starts a new
391
+ streak). A spec that names no effort counts as medium. With by_effort off the count
392
+ is by model only, as before.
259
393
  """
260
394
  model = canonical(current, registry)
395
+ effort = _effort_of(current) if by_effort else None
261
396
  seen = set()
262
397
  for attempt, status, spec in reversed(rows):
263
398
  if status in NEUTRAL:
264
399
  continue
265
400
  if spec and canonical(spec, registry) != model:
266
401
  break
402
+ if effort is not None and _effort_of(spec or current) != effort:
403
+ break
267
404
  if status not in FAILED:
268
405
  break
269
406
  seen.add(attempt)
@@ -439,13 +576,19 @@ def _decide(plan: Path, tdir: Path, args, after: int):
439
576
  if not current or current in ('-', 'UNASSIGNED'):
440
577
  return (), (), '', 0
441
578
  registry = registry_models()
442
- streak = failed_streak(attempt_rows(tdir), current, registry)
579
+ step = _effort_step(plan)
580
+ streak = failed_streak(attempt_rows(tdir), current, registry, by_effort=step)
443
581
  if streak < after:
444
582
  return MARKERS, (), '', 0
445
583
  target, why = stronger(current, load_pools(plan / 'fractal-routing.json'), registry)
584
+ # Only once no higher tier has a model. A tier-up is still the stronger move.
585
+ if target is None and step:
586
+ target = _next_effort_spec(current)
446
587
  if target:
447
588
  writes = (('escalation.next', target + '\n'),) if args.check else ()
448
589
  return ('escalation.stop', 'split.suggested'), writes, target, 0
590
+ if step:
591
+ why = '%s. The highest effort this pairing accepts was tried as well' % why
449
592
  split = ('%s failed %d attempts on %s, and %s. Another attempt on the same model rarely passes, '
450
593
  'so split the task into smaller ones, or retry it with an explicit --dwarf <spec>.\n'
451
594
  % (args.task, streak, current, why))
@@ -536,6 +679,10 @@ def main(argv=None) -> int:
536
679
  p.add_argument('--check', action='store_true')
537
680
  p.add_argument('--qa-only-next', choices=('yes', 'no'), default=None,
538
681
  help='whether the next entry only re-runs QA, as the caller measured it')
682
+ p = sub.add_parser('fallback', help='the same-tier model a capacity stop moves to')
683
+ p.add_argument('--plan', required=True)
684
+ p.add_argument('--task', required=True)
685
+ p.add_argument('--exclude', default='', help='harness/model pairs to skip, comma-separated (harness/* for all)')
539
686
  p = sub.add_parser('record', help=argparse.SUPPRESS)
540
687
  p.add_argument('--plan', required=True)
541
688
  p.add_argument('--task', required=True)
@@ -551,6 +698,11 @@ def main(argv=None) -> int:
551
698
  return 0
552
699
  if args.command == 'record':
553
700
  return record_escalation(args)
701
+ if args.command == 'fallback':
702
+ try:
703
+ return fallback(args)
704
+ except Exception:
705
+ return 1
554
706
  try:
555
707
  return escalation(args)
556
708
  except Exception: