@giamme/forge 0.9.0 → 0.11.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 (67) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/SKILL.md +22 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +28 -2
  5. package/docs/commands.md +55 -0
  6. package/docs/dataset.md +22 -0
  7. package/docs/decomposed-runs.md +54 -2
  8. package/docs/forge-ui.md +48 -7
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +87 -2
  11. package/forge +31 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +103 -4
  14. package/references/decompose.md +41 -5
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +31 -6
  17. package/scripts/forge-config.py +350 -0
  18. package/scripts/forge-control.py +649 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +1134 -0
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +358 -41
  24. package/scripts/forge-parallel-options.sh +3 -1
  25. package/scripts/forge-parallel.sh +99 -13
  26. package/scripts/forge-prompt.py +61 -4
  27. package/scripts/forge-route.py +245 -28
  28. package/scripts/forge-runs-rm.py +840 -0
  29. package/scripts/forge-runs.py +19 -1
  30. package/scripts/forge-runtime.py +47 -1
  31. package/scripts/forge-schedule.py +227 -11
  32. package/scripts/forge-ui.py +85 -19
  33. package/scripts/forge_dataset/__init__.py +84 -8
  34. package/scripts/forge_dataset/capture.py +5 -2
  35. package/scripts/forge_dataset/cli.py +44 -1
  36. package/scripts/forge_dataset/export.py +36 -3
  37. package/scripts/forge_dataset/forecast.py +355 -0
  38. package/scripts/forge_dataset/harvest.py +45 -2
  39. package/scripts/forge_dataset/importer.py +431 -17
  40. package/scripts/forge_dataset/reviewers.py +145 -0
  41. package/scripts/forge_dataset/routing.py +1 -0
  42. package/scripts/forge_jev/__init__.py +7 -3
  43. package/scripts/forge_registry.py +183 -26
  44. package/ui/README.md +41 -7
  45. package/ui/docs/api.md +259 -21
  46. package/ui/forge_ui/__main__.py +10 -2
  47. package/ui/forge_ui/control.py +587 -0
  48. package/ui/forge_ui/export.py +17 -3
  49. package/ui/forge_ui/reader.py +210 -12
  50. package/ui/forge_ui/server.py +125 -4
  51. package/ui/forge_ui/static/app.js +104 -18
  52. package/ui/forge_ui/static/cloud3d.js +4 -1
  53. package/ui/forge_ui/static/cloudmodel.js +1 -1
  54. package/ui/forge_ui/static/cloudui.js +13 -4
  55. package/ui/forge_ui/static/controls.js +619 -0
  56. package/ui/forge_ui/static/findingsview.js +438 -0
  57. package/ui/forge_ui/static/graph.js +6 -5
  58. package/ui/forge_ui/static/index.html +7 -1
  59. package/ui/forge_ui/static/leaderboard.js +18 -0
  60. package/ui/forge_ui/static/modelstats.js +19 -1
  61. package/ui/forge_ui/static/panel.js +76 -14
  62. package/ui/forge_ui/static/rundelete.js +388 -0
  63. package/ui/forge_ui/static/runmodel.js +128 -13
  64. package/ui/forge_ui/static/scene3d.js +32 -15
  65. package/ui/forge_ui/static/settings.js +399 -0
  66. package/ui/forge_ui/static/style.css +184 -5
  67. package/ui/forge_ui/static/uikit.js +161 -0
@@ -9,7 +9,7 @@
9
9
  # forge-parallel.sh run <plan-dir> [--max-parallel N] [--yolo-dwarf] [--yolo-qa]
10
10
  # [--verify <command>] [--setup <command>] [--dry-run]
11
11
  # [--timeout S] [--retry-failed N] [--infra-retries M]
12
- # [--qa-threshold P0|P1|P2|P3] [--verify-retries N]
12
+ # [--qa-threshold P0|P1|P2|P3] [--verify-retries N] [--no-forecast]
13
13
  # forge-parallel.sh retry <plan-dir> <task-id> [--dwarf <spec>] [--setup <command>]
14
14
  # [--timeout S] [--qa-threshold Pn] [--verify-retries N]
15
15
  # forge-parallel.sh integrate <plan-dir> --approved [--verify-retries N] [--final-review <qa-spec>]
@@ -30,6 +30,11 @@
30
30
  # by a listed reviewer of another model family, recorded and
31
31
  # never read (measures reviewer agreement). `none` turns it off
32
32
  # --shadow-rate R that share, 0..1 (default 0.1)
33
+ # --reviewer-session opt-in experiment: a later review round continues the reviewer's
34
+ # earlier session (claude, openclaude, codex) when the QA spec is
35
+ # unchanged, and starts fresh whenever it cannot; each round is
36
+ # logged in tasks/<id>/review_sessions.tsv and tagged in the
37
+ # dataset. `--no-reviewer-session` turns it off (the default)
33
38
  # --explore E on by default at 0.2 (0 turns it off, and is kept): a row left
34
39
  # to forge gets, with probability E/2, its neighbouring model
35
40
  # (one tier away in the given pools) instead of its pool's turn;
@@ -129,7 +134,7 @@ pool_pick() { # pool_pick <comma-list> <index>
129
134
  do_plan() {
130
135
  local PLAN="${1:?plan needs a plan dir}"; shift
131
136
  local REPO="" D_ANY="" D_HI="" D_MD="" D_LO="" Q_ANY="" Q_HI="" Q_MD="" Q_LO=""
132
- local PLANNER="" NOMEM=0 jev_rc=0 DLEVEL="" SHADOW_QA="" SHADOW_RATE="" EXPLORE="" FAIL_FIRST=""
137
+ local PLANNER="" NOMEM=0 jev_rc=0 DLEVEL="" SHADOW_QA="" SHADOW_RATE="" EXPLORE="" FAIL_FIRST="" REVIEWER_SESSION=""
133
138
  while [ $# -gt 0 ]; do
134
139
  case "$1" in
135
140
  --repo) REPO="${2:?}"; shift 2 ;;
@@ -157,6 +162,8 @@ do_plan() {
157
162
  --ui|--no-ui) forge_ui_flag "$1"; shift ;;
158
163
  --fail-first) FAIL_FIRST=on; shift ;;
159
164
  --no-fail-first) FAIL_FIRST=off; shift ;;
165
+ --reviewer-session) REVIEWER_SESSION=on; shift ;;
166
+ --no-reviewer-session) REVIEWER_SESSION=off; shift ;;
160
167
  *)
161
168
  # `case` is not a loop, so `continue` here belongs to the enclosing while.
162
169
  if [ "$JEV_OPTS" = 1 ]; then
@@ -192,6 +199,9 @@ do_plan() {
192
199
  || printf '%s\n' "$SHADOW_RATE" > "$PLAN/shadow_rate"
193
200
  # Fail-first evidence (opt-in, advisory) is part of the plan too: every task's review gets it.
194
201
  case "$FAIL_FIRST" in on) : > "$PLAN/fail_first" ;; off) rm -f "$PLAN/fail_first" ;; esac
202
+ # Reviewer-session continuation (opt-in experiment): the file holds `on`. Readers only
203
+ # test that it exists; the dataset records its content.
204
+ case "$REVIEWER_SESSION" in on) printf '%s\n' on > "$PLAN/reviewer_session" ;; off) rm -f "$PLAN/reviewer_session" ;; esac
195
205
  # Exploration is decided here and only here, so the table and preflight see what will run.
196
206
  # On by default: choosing models from measured pass rates needs some randomized picks, and
197
207
  # history had almost none (9 tasks ever ran on more than one spec). The flag wins, then
@@ -480,6 +490,9 @@ render_table() {
480
490
  echo "fail-first: on, but $(cat "$PLAN/repo")/.forge/verify-subset (a test command with {files}) is missing, so nothing will run"
481
491
  fi
482
492
  fi
493
+ if [ -f "$PLAN/reviewer_session" ]; then
494
+ echo "reviewer-session: a later review round continues the reviewer's earlier session when its QA spec is unchanged (experiment; falls back to a fresh session)"
495
+ fi
483
496
  echo
484
497
  local w id dws starred=0
485
498
  for w in $(awk -F'\t' '{print $1}' "$W" | sort -un); do
@@ -868,8 +881,11 @@ do_task_body() (
868
881
  echo "$review_fp" > "$tdir/review.fingerprint"
869
882
 
870
883
  forge_metric_phase preparation
871
- local jev_loaded_qa=0 subset_cmd="" qa_thr
884
+ local jev_loaded_qa=0 subset_cmd="" qa_thr rs_on=0
872
885
  qa_thr="$(qa_threshold_for "$PLAN" "$id")"
886
+ # Read once per round, like the threshold: continuing the earlier reviewer's session is an
887
+ # opt-in experiment (plan --reviewer-session), and a round never changes its mind midway.
888
+ [ -f "$PLAN/reviewer_session" ] && rs_on=1
873
889
 
874
890
  # A SEPARATE file from .forge/verify, and opt-in. It must contain {files}, because
875
891
  # only the project knows how its runner takes a file list -- `pytest -q {files}` works,
@@ -895,11 +911,29 @@ do_task_body() (
895
911
  # on the review snapshot, like the subset run above; the reviewer gets the evidence.
896
912
  rm -f "$tdir/failfirst.json" "$tdir/failfirst.txt"
897
913
  [ ! -f "$PLAN/fail_first" ] || forge_fail_first "$review" "$tdir" "$REPO"
914
+ # What changed since the review whose findings this reviewer is handed. Fractal keeps
915
+ # today's prompt unless it retries, as changes.prev.diff does. dwarf.last is the reply of
916
+ # the attempt under review: a dwarf run just rewrote it, or $resume (the validated QA-only
917
+ # path, never the raw marker) guarantees the reviewed commit is the one it describes.
918
+ local prompt_extra=()
919
+ if [ -z "${FORGE_FRACTAL_RUN:-}" ] || [ "${FORGE_FRACTAL_RETRY:-}" = 1 ]; then
920
+ prompt_extra+=(--role qa)
921
+ attempt_delta "$tdir" "$wt" "$reviewed"
922
+ if [ -s "$tdir/changes.since.diff" ]; then
923
+ prompt_extra+=(--retry-diff "$tdir/changes.since.diff" --retry-stat "$tdir/changes.since.stat"
924
+ --retry-range "$(awk '{ print substr($1, 1, 7) ".." substr($2, 1, 7) }' "$tdir/changes.since.range")")
925
+ fi
926
+ if [ -s "$tdir/retry_findings.md" ] && [ -s "$tdir/dwarf.last" ]; then
927
+ prompt_extra+=(--reply "$tdir/dwarf.last")
928
+ fi
929
+ else
930
+ rm -f "$tdir/changes.since.diff" "$tdir/changes.since.range" "$tdir/changes.since.stat"
931
+ fi
898
932
  # QA uses the dispatch-time ownership and baseline, never later merge state.
899
933
  /bin/bash "$MEMORY" inject "$REPO" qa --task "$tdir/prompt.md" \
900
934
  --run-dir "$tdir" > "$tdir/qa.memory"
901
935
  {
902
- python3 "$SKILL_DIR/scripts/forge-prompt.py" --requirements "$tdir/prompt.md" --capsule "$tdir/baseline.capsule" --approach "$tdir/approach.md" --retry "$tdir/retry_findings.md" --memory "$tdir/dwarf.memory" --memory "$tdir/qa.memory"
936
+ python3 "$SKILL_DIR/scripts/forge-prompt.py" --requirements "$tdir/prompt.md" --capsule "$tdir/baseline.capsule" --approach "$tdir/approach.md" --retry "$tdir/retry_findings.md" ${prompt_extra[@]+"${prompt_extra[@]}"} --memory "$tdir/dwarf.memory" --memory "$tdir/qa.memory"
903
937
  python3 "$SKILL_DIR/scripts/forge-contract.py" qa --part head --kind task
904
938
  echo
905
939
  if [ -s "$tdir/failfirst.txt" ]; then cat "$tdir/failfirst.txt"; echo; fi
@@ -915,9 +949,10 @@ do_task_body() (
915
949
  echo
916
950
  fi
917
951
  if [ -s "$tdir/guard.txt" ]; then
918
- # Facts forge observed about how the implementer worked. The reviewer never sees the
919
- # implementer's own report, so this is the only way an unfinished measurement or a
920
- # stray process reaches the review.
952
+ # Facts forge observed about how the implementer worked. On a first attempt the reviewer
953
+ # never sees the implementer's own report (a retry's reviewer gets the reply, to check
954
+ # against the code), so this is how an unfinished measurement or a stray process
955
+ # reaches the review.
921
956
  echo "Implementer guard notes (observed by forge, not claimed by the implementer):"
922
957
  sed 's/^/ - /' "$tdir/guard.txt"
923
958
  echo "A promise of later work means a check may never have run; judge the diff on its own merits."
@@ -949,9 +984,45 @@ do_task_body() (
949
984
  shadow_pid=$!
950
985
  fi
951
986
  forge_metric_phase dispatch
952
- /bin/bash "$DISPATCH" qa "$qa" --repo "$review" --run-dir "$tdir" \
953
- --review-base "$(cat "$tdir/review.base")" --ripwire-query-file "$tdir/prompt.md" --prompt-file "$tdir/qa.input" $yq --output "${FORGE_OUTPUT:-summary}" >"$tdir/qa.out" 2>&1
987
+ # Whether this round continues the reviewer's session. Every answer but "continued" is a
988
+ # fresh dispatch, and nothing here can abort the round: a doctor that fails means fresh.
989
+ local rs_mode=fresh rs_reason="" rs_id="" rs_row=1 rs_cont=()
990
+ if [ "$rs_on" = 1 ]; then
991
+ if [ -n "${FORGE_FRACTAL_RUN:-}" ] || forge_plan_is_fractal "$PLAN"; then
992
+ rs_reason=fractal
993
+ elif [ ! -s "$tdir/retry_findings.md" ]; then
994
+ rs_reason=first-round
995
+ else
996
+ FORGE_CAPABILITY_CACHE="${FORGE_CAPABILITY_CACHE:-$PLAN/capabilities}" \
997
+ /bin/bash "$DISPATCH" doctor --spec "$qa" --role qa $yq > "$tdir/qa.doctor" 2>&1 || : > "$tdir/qa.doctor"
998
+ IFS=$'\t' read -r rs_mode rs_reason rs_id < <(review_session_decide \
999
+ "$tdir/retry_findings.session" "$tdir/retry_findings.resolved" "$tdir/qa.doctor")
1000
+ [ "$rs_mode" = continued ] && rs_cont=(--continue-session "$rs_id")
1001
+ fi
1002
+ fi
1003
+ local qa_cmd=(/bin/bash "$DISPATCH" qa "$qa" --repo "$review" --run-dir "$tdir" \
1004
+ --review-base "$(cat "$tdir/review.base")" --ripwire-query-file "$tdir/prompt.md" --prompt-file "$tdir/qa.input" ${yq:+"$yq"} --output "${FORGE_OUTPUT:-summary}")
1005
+ "${qa_cmd[@]}" ${rs_cont[@]+"${rs_cont[@]}"} >"$tdir/qa.out" 2>&1
954
1006
  rc=$?
1007
+ if [ "$rc" = 9 ] && [ "$rs_mode" = continued ]; then
1008
+ # The session was not continued (the harness cannot, or it failed). Same input, fresh: the
1009
+ # fresh dispatch decides the task. Why comes from the first dispatch's record, which the
1010
+ # second one clears.
1011
+ if [ "$(sed -n 's/^reason=//p' "$tdir/qa.continue" 2>/dev/null | head -1)" = unsupported ]; then
1012
+ rs_reason=continue-unsupported
1013
+ else
1014
+ rs_reason=continue-failed
1015
+ fi
1016
+ rs_mode=fresh
1017
+ mv "$tdir/qa.out" "$tdir/qa.continue.out" 2>/dev/null || true
1018
+ "${qa_cmd[@]}" >"$tdir/qa.out" 2>&1
1019
+ rc=$?
1020
+ fi
1021
+ if [ "$rs_on" = 1 ]; then
1022
+ # One row per round, describing the dispatch whose result counts.
1023
+ [ ! -f "$tdir/review_sessions.tsv" ] || rs_row=$(( $(wc -l < "$tdir/review_sessions.tsv") + 1 ))
1024
+ printf '%s\t%s\t%s\t%s\t%s\n' "$rs_row" "$attempt" "$rs_mode" "$rs_reason" "$rs_id" >> "$tdir/review_sessions.tsv"
1025
+ fi
955
1026
  if [ -n "$shadow_pid" ]; then
956
1027
  # On every path below, before any status is written: the shadow learns the primary is
957
1028
  # done (which bounds how much longer it may take), and no shadow outlives its task, so
@@ -989,8 +1060,9 @@ do_task_body() (
989
1060
  *) echo UNKNOWN > "$tdir/status" ;;
990
1061
  esac
991
1062
  # With an explicit --qa-threshold a FAIL made only of tolerated findings becomes an
992
- # accepted PASS-with-known-issues. No threshold, no change.
993
- qa_gate "$PLAN" "$id" "$tdir" "$verdict"
1063
+ # accepted PASS-with-known-issues. No threshold, no change. qa_thr is the value the review
1064
+ # prompt was built with, so the prompt and the gate of one attempt agree.
1065
+ qa_gate "$PLAN" "$id" "$tdir" "$verdict" "$qa_thr"
994
1066
 
995
1067
  # Annotate a FAIL that looks unsound. Note what this does NOT do: the status file is
996
1068
  # already written above and nothing below rewrites it. A model that could talk a
@@ -1035,7 +1107,8 @@ do_task() {
1035
1107
  record_attempt "$1" "$2"
1036
1108
  # The retry policy's state (escalation.next or escalation.stop), decided now rather than
1037
1109
  # at the next retry so a spent retry budget still reports it and the scheduler sees it.
1038
- python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$1" --task "$2" --check >/dev/null 2>&1 || true
1110
+ python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$1" --task "$2" --check \
1111
+ --qa-only-next "$(qa_only_next_flag "$1" "$2")" >/dev/null 2>&1 || true
1039
1112
  return $rc
1040
1113
  }
1041
1114
 
@@ -1428,13 +1501,23 @@ write_results() { # write_results <plan>
1428
1501
  done
1429
1502
  }
1430
1503
 
1504
+ # forecast_report PLAN: `forge dataset forecast --run`, as note lines. Whatever it does
1505
+ # (no dataset, a broken store, a slow read), the run goes on and its exit code is its own.
1506
+ forecast_report() {
1507
+ local line
1508
+ python3 "$SKILL_DIR/scripts/forge-dataset.py" forecast "$1" --run 2>/dev/null \
1509
+ | while IFS= read -r line; do note "$line"; done
1510
+ return 0
1511
+ }
1512
+
1431
1513
  # --- run ----------------------------------------------------------------------
1432
1514
  do_run() (
1433
1515
  local PLAN="${1:?run needs a plan dir}"; shift
1434
1516
  local fractal_pipeline_args=("$@")
1435
- local MAXP=3 DRY=0 jev_rc=0
1517
+ local MAXP=3 DRY=0 jev_rc=0 FORECAST=1
1436
1518
  while [ $# -gt 0 ]; do
1437
1519
  case "$1" in
1520
+ --no-forecast) FORECAST=0; shift ;;
1438
1521
  --fractal) forge_fractal_flag on || exit $?; shift ;;
1439
1522
  --fractal-auto-decompose) forge_fractal_flag on || exit $?; FRACTAL_ARGS+=("$1"); shift ;;
1440
1523
  --no-fractal) forge_fractal_flag off || exit $?; shift ;;
@@ -1537,6 +1620,9 @@ do_run() (
1537
1620
  fi
1538
1621
  git -C "$int_wt" rev-parse HEAD > "$PLAN/accepted.integration"
1539
1622
  local id failed=0
1623
+ # Also before any model is paid for: what this plan is likely to cost, and which tasks
1624
+ # history says may bounce. Advisory, budgeted and silent without history; it never stops a run.
1625
+ [ "$FORECAST" = 1 ] && forecast_report "$PLAN"
1540
1626
  # Say now, before any model is paid for, which suites the verify command will not run.
1541
1627
  verify_coverage_report "$REPO" "$(verify_command_for "$PLAN" "$REPO")" "$PLAN/verification.coverage.txt"
1542
1628
  local sched_rc=0 paused=0 sched_args=()
@@ -5,20 +5,72 @@ from __future__ import annotations
5
5
  import argparse
6
6
  from pathlib import Path
7
7
 
8
+ DIFF_INLINE_CAP = 100 * 1024
9
+ REPLY_CAP = 8 * 1024
10
+
11
+ # What a reviewer is asked to do with the previous round's report. The labelled-line rule
12
+ # matters: the severity gate, `forge issues` and forge-ui count every `- [Px][...]` line as open.
13
+ QA_ROUND_NOTE = """\
14
+ Check each finding above first: is it fixed, still present, or validly rejected by the
15
+ implementer (see their reply below)? Report a finding that is still present again, in the
16
+ usual labelled format with its severity. Mention a fixed or rejected finding only in a plain
17
+ sentence, never as a `- [Px][CONFIRMED]` or `- [Px][PLAUSIBLE]` line: every labelled line is
18
+ counted as an open finding. This round's threshold, in the contract below, governs; it may
19
+ differ from the last round's. Then review the full task diff below: the changes since that
20
+ round are where new problems are most likely."""
21
+
22
+
23
+ def read(path: str | None, errors: str = 'strict') -> str:
24
+ if path and Path(path).is_file():
25
+ with Path(path).open(newline="", errors=errors) as source:
26
+ return source.read()
27
+ return ''
28
+
29
+
30
+ def carried(text: str) -> str:
31
+ # The dataset and forge-ui take the LAST ```diff fence of a QA prompt as the reviewed
32
+ # diff, so nothing carried from an earlier round may open one.
33
+ return text.replace('```diff', '```text')
34
+
35
+
36
+ def qa_round(findings: str, diff: str | None, span: str | None, stat: str | None, reply: str | None) -> str:
37
+ parts = ['## Findings from the previous review round\n' + carried(findings).rstrip('\n') + '\n\n' + QA_ROUND_NOTE]
38
+ # A git diff carries raw source bytes, so a file that is not UTF-8 must not abort the prompt.
39
+ patch = read(diff, 'replace')
40
+ if patch:
41
+ label = ' (' + span.strip() + ')' if span and span.strip() else ''
42
+ if len(patch.encode()) <= DIFF_INLINE_CAP:
43
+ body = '```patch\n' + patch.rstrip('\n') + '\n```'
44
+ else:
45
+ body = ('The diff since that review is too large to inline; its stat:\n\n```text\n'
46
+ + read(stat, 'replace').rstrip('\n') + '\n```')
47
+ parts.append('### Changes since that review' + label + '\n' + body)
48
+ said = read(reply, 'replace')
49
+ if said.strip():
50
+ raw = said.encode()
51
+ if len(raw) > REPLY_CAP:
52
+ said = raw[:REPLY_CAP].decode(errors='ignore') + '\n[... the reply continues; cut here ...]\n'
53
+ parts.append("### The implementer's reply (its own account; verify every claim against the code)\n"
54
+ + carried(said).rstrip('\n'))
55
+ return '\n\n'.join(parts) + '\n'
56
+
8
57
 
9
58
  def context(
10
59
  requirements: str, approach: str | None = None, capsule: str | None = None,
11
60
  retry: str | None = None, goal: str | None = None, memories: tuple[str, ...] = (),
61
+ role: str = 'dwarf', retry_diff: str | None = None, retry_range: str | None = None,
62
+ retry_stat: str | None = None, reply: str | None = None,
12
63
  ) -> str:
13
64
  chunks = []
14
65
  for title, path in [('Ownership and baseline', capsule), ('Overall goal', goal),
15
66
  ('Complete implementation requirements', requirements),
16
67
  ('Intended approach', approach),
17
68
  ('Your previous attempt was reviewed and rejected', retry)]:
18
- if path and Path(path).is_file():
19
- with Path(path).open(newline="") as source:
20
- data = source.read()
21
- if data:
69
+ data = read(path) if path and Path(path).is_file() else ''
70
+ if data:
71
+ if role == 'qa' and path == retry:
72
+ chunks.append(qa_round(data, retry_diff, retry_range, retry_stat, reply).rstrip('\n'))
73
+ else:
22
74
  chunks.append('## ' + title + '\n' + data)
23
75
  # Memory injection emits headings and prose around bullet facts. Union only
24
76
  # exact fact lines, preserving distinct facts even when they share a key.
@@ -40,6 +92,11 @@ if __name__ == '__main__':
40
92
  for key in ('requirements', 'approach', 'capsule', 'retry', 'goal'):
41
93
  parser.add_argument('--' + key, required=key == 'requirements')
42
94
  parser.add_argument('--memory', action='append', default=[])
95
+ parser.add_argument('--role', choices=('dwarf', 'qa'), default='dwarf')
96
+ parser.add_argument('--retry-diff')
97
+ parser.add_argument('--retry-range')
98
+ parser.add_argument('--retry-stat')
99
+ parser.add_argument('--reply')
43
100
  args = vars(parser.parse_args())
44
101
  args['memories'] = args.pop('memory')
45
102
  print(context(**args), end='')
@@ -16,13 +16,30 @@ bias (inverse-propensity weighting needs the probability each choice had).
16
16
  rewritten) and marks a changed pick in tasks/<id>/route.explored for the table. A row
17
17
  with no neighbour is not explored and records nothing.
18
18
 
19
- forge-route.py escalation --plan P --task ID [--check]
19
+ forge-route.py escalation --plan P --task ID [--check] [--qa-only-next yes|no]
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
23
  tasks/<id>/escalation.stop and split.suggested and exits 3. `--check` (run after every
24
24
  attempt) also records a due escalation in escalation.next, and clears the markers once
25
- the streak no longer holds.
25
+ the streak no longer holds. A pending tasks/<id>/escalation.pin (a human's one-shot
26
+ choice, applied by prepare_retry) is what `--check` reports as the next model instead
27
+ (escalation.next = its spec), whatever escalate_after says, unless the next retry is a
28
+ QA-only resume or the plan is Fractal (pins never apply there). `--qa-only-next` is
29
+ that fact as the caller measured it; without the option, a resume marker or an
30
+ unreviewed commit is taken as QA-only. Nothing has applied the pin yet, so the policy's
31
+ own stop and split markers stay as the policy decided them beneath it: they are what
32
+ holds again if the pin is withdrawn. The marker writes take <plan>/control.lock and
33
+ re-read escalate_after inside it: a policy decision made before a human switched the
34
+ policy off writes nothing.
35
+
36
+ forge-route.py record --plan P --task ID (--probe | --line TEXT)
37
+ Internal. Probe or append tasks/<id>/escalations.tsv through forge-control.py, which
38
+ walks from the plan one directory at a time and refuses a symlink at any component.
39
+ `--probe` exits 0 when every component below the plan is a real directory and the file
40
+ is absent or a regular file. `--line` appends one record under those same conditions.
41
+ A missing parent exits 1 and writes nothing: the walk has no directory descriptor then,
42
+ and opening the leaf would create it in the process's current directory.
26
43
 
27
44
  The propensity of the chosen spec is (1-E)*[chosen == SPEC] + E/2. Forge never picks a
28
45
  model the user did not name: both helpers read only the pools `plan` stored in
@@ -34,12 +51,17 @@ the draws reproducible (tests only). Any failure prints SPEC (explore) or nothin
34
51
  from __future__ import annotations
35
52
 
36
53
  import argparse
54
+ import contextlib
55
+ import errno
56
+ import fcntl
37
57
  import hashlib
38
58
  import json
39
59
  import os
40
60
  import random
41
61
  import secrets
62
+ import stat
42
63
  import sys
64
+ import tempfile
43
65
  import time
44
66
  from pathlib import Path
45
67
 
@@ -49,6 +71,7 @@ REGISTRY = SCRIPTS.parent / 'registry.tsv'
49
71
  FAILED = ('FAIL', 'TIMEOUT') # a judgment against the spec that ran
50
72
  NEUTRAL = ('INFRA', 'UNKNOWN', 'ERROR', 'RUNNING', 'INTERRUPTED') # say nothing about it
51
73
  DEFAULT_ESCALATE_AFTER = 3
74
+ LOCK_WAIT = 5.0 # seconds to wait for P/control.lock
52
75
 
53
76
 
54
77
  def rng_for(task: str):
@@ -275,46 +298,230 @@ def _fractal(plan: Path) -> bool:
275
298
  return False
276
299
 
277
300
 
301
+ # --- live-control files ------------------------------------------------------------
302
+ # Markers are shared with `forge control` and the scheduler, so they are written the way
303
+ # every writer of the plan's control files writes: a task id the scheduler would accept, a
304
+ # real directory chain under the canonical plan dir, a regular file as the target, a temp
305
+ # file plus rename, and P/control.lock held around the check-and-write.
306
+
307
+ class Busy(Exception):
308
+ """P/control.lock stayed held for LOCK_WAIT seconds."""
309
+
310
+
311
+ def _task_dir(plan: Path, task: str):
312
+ """tasks/<task> under the canonical plan dir, or None when it is not a real directory."""
313
+ if not task or task in ('.', '..') or '/' in task or '\0' in task:
314
+ return None
315
+ tdir = plan / 'tasks' / task
316
+ try:
317
+ if not stat.S_ISDIR(os.lstat(tdir).st_mode) or os.path.realpath(tdir) != str(tdir):
318
+ return None
319
+ except OSError:
320
+ return None
321
+ return tdir
322
+
323
+
324
+ @contextlib.contextmanager
325
+ def _control_lock(plan: Path):
326
+ fd = os.open(str(plan / 'control.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o644)
327
+ try:
328
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
329
+ raise OSError(errno.EINVAL, 'control.lock is not a regular file')
330
+ deadline = time.monotonic() + LOCK_WAIT
331
+ while True:
332
+ try:
333
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
334
+ break
335
+ except OSError as exc:
336
+ if exc.errno not in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK):
337
+ raise
338
+ if time.monotonic() >= deadline:
339
+ raise Busy('control.lock is held') from None
340
+ time.sleep(0.05)
341
+ try:
342
+ yield
343
+ finally:
344
+ fcntl.flock(fd, fcntl.LOCK_UN)
345
+ finally:
346
+ os.close(fd)
347
+
348
+
349
+ def _regular(path: Path) -> bool:
350
+ try:
351
+ return stat.S_ISREG(os.lstat(path).st_mode)
352
+ except OSError:
353
+ return False
354
+
355
+
356
+ MARKERS = ('escalation.stop', 'split.suggested', 'escalation.next')
357
+
358
+
278
359
  def _clear(tdir: Path, *names) -> None:
279
- for name in names or ('escalation.stop', 'split.suggested', 'escalation.next'):
360
+ for name in names:
361
+ if _regular(tdir / name):
362
+ try:
363
+ (tdir / name).unlink()
364
+ except OSError:
365
+ pass
366
+
367
+
368
+ def _write_marker(tdir: Path, name: str, text: str) -> None:
369
+ target = tdir / name
370
+ if os.path.lexists(target) and not _regular(target):
371
+ raise OSError(errno.EINVAL, '%s is not a regular file' % name)
372
+ fd, tmp = tempfile.mkstemp(dir=str(tdir), prefix='.%s.' % name)
373
+ try:
374
+ with os.fdopen(fd, 'w') as out:
375
+ out.write(text)
376
+ os.chmod(tmp, 0o644)
377
+ os.replace(tmp, target)
378
+ except BaseException:
280
379
  try:
281
- (tdir / name).unlink()
380
+ os.unlink(tmp)
282
381
  except OSError:
283
382
  pass
383
+ raise
284
384
 
285
385
 
286
- def escalation(args) -> int:
287
- plan = Path(args.plan)
288
- tdir = plan / 'tasks' / args.task
289
- after = _setting(plan)
290
- # Fractal plans freeze their eligible set at selection; a QA-only resume spends no dwarf
291
- # attempt, so neither is a moment to change the implementer.
292
- if after <= 0 or _fractal(plan) or (tdir / 'resume').exists():
293
- if after <= 0:
294
- _clear(tdir)
295
- return 0
386
+ def _pin(tdir: Path):
387
+ """The pending pin's spec, or None. One line, one token: it goes into a TSV column."""
388
+ path = tdir / 'escalation.pin'
389
+ if not _regular(path):
390
+ return None
391
+ try:
392
+ lines = path.read_text().splitlines()
393
+ except (OSError, UnicodeDecodeError):
394
+ return None
395
+ spec = lines[0].strip() if lines else ''
396
+ if not spec or spec in ('-', 'UNASSIGNED') or any(c.isspace() or ord(c) < 32 for c in spec):
397
+ return None
398
+ return spec
399
+
400
+
401
+ def _qa_next(tdir: Path) -> bool:
402
+ """Fallback when `--qa-only-next` is absent: a resume marker, or an unreviewed commit.
403
+
404
+ This does not look at the worktree. Callers that measured it (qa_only_next) pass
405
+ `--qa-only-next` instead, so a marker the worktree no longer matches is a full retry.
406
+ """
407
+ if (tdir / 'resume').exists():
408
+ return True
409
+ try:
410
+ return (tdir / 'status').read_text().strip() == 'UNKNOWN' and (tdir / 'reviewed.commit').stat().st_size > 0
411
+ except OSError:
412
+ return False
413
+
414
+
415
+ def _apply(plan: Path, tdir: Path, clear=(), writes=(), policy=True, pin=None):
416
+ """Clear and write markers under the control lock. Returns (policy stood, pin reported).
417
+
418
+ The policy re-reads escalate_after inside the lock (a human may have switched it off since
419
+ the decision was made): it then writes nothing and its markers go, as `--escalate-after 0`
420
+ clears them. A pin is reported as escalation.next only while it is still pending; the stop
421
+ and split markers beneath it stay the policy's, because the pin has not been applied.
422
+ """
423
+ with _control_lock(plan):
424
+ stood = policy and _setting(plan) > 0
425
+ if policy and not stood:
426
+ clear, writes = MARKERS, ()
427
+ reported = pin is not None and _pin(tdir) == pin
428
+ _clear(tdir, *clear)
429
+ for name, text in writes:
430
+ _write_marker(tdir, name, text)
431
+ if reported:
432
+ _write_marker(tdir, 'escalation.next', pin + '\n')
433
+ return stood, reported
434
+
435
+
436
+ def _decide(plan: Path, tdir: Path, args, after: int):
437
+ """What the policy wants done to the markers: (clear, writes, spec to print, exit code)."""
296
438
  current = _dwarf_of(plan, args.task)
297
439
  if not current or current in ('-', 'UNASSIGNED'):
298
- return 0
440
+ return (), (), '', 0
299
441
  registry = registry_models()
300
442
  streak = failed_streak(attempt_rows(tdir), current, registry)
301
443
  if streak < after:
302
- _clear(tdir)
303
- return 0
444
+ return MARKERS, (), '', 0
304
445
  target, why = stronger(current, load_pools(plan / 'fractal-routing.json'), registry)
305
446
  if target:
306
- _clear(tdir, 'escalation.stop', 'split.suggested')
307
- if args.check:
308
- (tdir / 'escalation.next').write_text(target + '\n')
309
- print(target)
447
+ writes = (('escalation.next', target + '\n'),) if args.check else ()
448
+ return ('escalation.stop', 'split.suggested'), writes, target, 0
449
+ split = ('%s failed %d attempts on %s, and %s. Another attempt on the same model rarely passes, '
450
+ 'so split the task into smaller ones, or retry it with an explicit --dwarf <spec>.\n'
451
+ % (args.task, streak, current, why))
452
+ return ('escalation.next',), (('escalation.stop', '%d\t%s\n' % (streak, current)), ('split.suggested', split)), '', 3
453
+
454
+
455
+ def escalation(args) -> int:
456
+ plan = Path(os.path.realpath(args.plan))
457
+ tdir = _task_dir(plan, args.task)
458
+ if tdir is None:
310
459
  return 0
311
- _clear(tdir, 'escalation.next')
312
- (tdir / 'escalation.stop').write_text('%d\t%s\n' % (streak, current))
313
- (tdir / 'split.suggested').write_text(
314
- '%s failed %d attempts on %s, and %s. Another attempt on the same model rarely passes, '
315
- 'so split the task into smaller ones, or retry it with an explicit --dwarf <spec>.\n'
316
- % (args.task, streak, current, why))
317
- return 3
460
+ after = _setting(plan)
461
+ fractal = _fractal(plan)
462
+ # Fractal plans freeze their eligible set at selection; a QA-only resume spends no dwarf
463
+ # attempt, so neither is a moment to change the implementer.
464
+ idle = after <= 0 or fractal or (tdir / 'resume').exists()
465
+ # A pin is a human's choice and outranks the policy, so `--check` reports it as the next
466
+ # model whatever escalate_after says; prepare_retry applies it and claims the file. The
467
+ # policy is still decided underneath it and its markers kept current. The policy's own
468
+ # idle test above is separate and stays on the raw resume marker.
469
+ qa_only = _qa_next(tdir) if args.qa_only_next is None else args.qa_only_next == 'yes'
470
+ pin = _pin(tdir) if args.check and not fractal and not qa_only else None
471
+ clear, writes, shown, rc = (MARKERS if after <= 0 else ()), (), '', 0
472
+ if not idle:
473
+ clear, writes, shown, rc = _decide(plan, tdir, args, after)
474
+ if not (writes or shown or pin):
475
+ _clear(tdir, *clear)
476
+ return 0
477
+ stood, reported = _apply(plan, tdir, clear, writes, policy=not idle, pin=pin)
478
+ if reported:
479
+ print(pin)
480
+ return 0
481
+ if not stood:
482
+ return 0
483
+ if shown:
484
+ print(shown)
485
+ return rc
486
+
487
+
488
+ def _load_control():
489
+ """forge-control.py, by path: probe and append_line refuse a symlink at any component."""
490
+ import importlib.util
491
+ spec = importlib.util.spec_from_file_location(
492
+ 'forge_control', Path(__file__).resolve().with_name('forge-control.py'))
493
+ module = importlib.util.module_from_spec(spec)
494
+ sys.modules['forge_control'] = module
495
+ spec.loader.exec_module(module)
496
+ return module
497
+
498
+
499
+ def record_escalation(args) -> int:
500
+ """Probe or append tasks/<id>/escalations.tsv. 0 when that is safe; 1 when it is not.
501
+
502
+ A missing parent is a refusal on both paths. probe() reports that as the file being
503
+ absent, so the check has to look at the walk itself. append_line refuses the same case
504
+ before it opens the leaf: with no directory descriptor that open would use the process
505
+ cwd, including when the task directory disappears between this check and the append.
506
+ """
507
+ if not args.task or args.task in ('.', '..') or '/' in args.task or '\0' in args.task:
508
+ return 1
509
+ try:
510
+ control = _load_control()
511
+ plan = os.path.realpath(args.plan)
512
+ rel = 'tasks/%s/escalations.tsv' % args.task
513
+ parent, _leaf = control._open_parent(plan, rel)
514
+ if parent is None:
515
+ return 1
516
+ os.close(parent)
517
+ if args.probe:
518
+ control.probe(plan, rel)
519
+ else:
520
+ line = args.line if args.line.endswith('\n') else args.line + '\n'
521
+ control.append_line(plan, rel, line)
522
+ except Exception:
523
+ return 1
524
+ return 0
318
525
 
319
526
 
320
527
  def main(argv=None) -> int:
@@ -327,6 +534,14 @@ def main(argv=None) -> int:
327
534
  p.add_argument('--plan', required=True)
328
535
  p.add_argument('--task', required=True)
329
536
  p.add_argument('--check', action='store_true')
537
+ p.add_argument('--qa-only-next', choices=('yes', 'no'), default=None,
538
+ help='whether the next entry only re-runs QA, as the caller measured it')
539
+ p = sub.add_parser('record', help=argparse.SUPPRESS)
540
+ p.add_argument('--plan', required=True)
541
+ p.add_argument('--task', required=True)
542
+ mode = p.add_mutually_exclusive_group(required=True)
543
+ mode.add_argument('--probe', action='store_true')
544
+ mode.add_argument('--line')
330
545
  args = ap.parse_args(argv)
331
546
  if args.command == 'explore':
332
547
  try:
@@ -334,6 +549,8 @@ def main(argv=None) -> int:
334
549
  except Exception:
335
550
  print(args.default)
336
551
  return 0
552
+ if args.command == 'record':
553
+ return record_escalation(args)
337
554
  try:
338
555
  return escalation(args)
339
556
  except Exception: