@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.
- package/CHANGELOG.md +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- 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.
|
|
919
|
-
# implementer's own report
|
|
920
|
-
#
|
|
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
|
-
|
|
953
|
-
|
|
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
|
-
|
|
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
|
|
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=()
|
package/scripts/forge-prompt.py
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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='')
|
package/scripts/forge-route.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
380
|
+
os.unlink(tmp)
|
|
282
381
|
except OSError:
|
|
283
382
|
pass
|
|
383
|
+
raise
|
|
284
384
|
|
|
285
385
|
|
|
286
|
-
def
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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:
|