@giamme/forge 0.0.0-stage → 0.9.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 +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,1717 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# forge-parallel.sh — decomposed forge runs: many dwarves working concurrently in
|
|
3
|
+
# isolated git worktrees, each task reviewed by its own QA pass, only passing work
|
|
4
|
+
# merged. Bash 3.2 entrypoints keep the task and merge contracts; a Python 3
|
|
5
|
+
# standard-library coordinator schedules ready tasks without wave barriers.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# forge-parallel.sh plan <plan-dir> --repo <dir> [routing flags] [--decompose-level low|medium|high] [--ui]
|
|
9
|
+
# forge-parallel.sh run <plan-dir> [--max-parallel N] [--yolo-dwarf] [--yolo-qa]
|
|
10
|
+
# [--verify <command>] [--setup <command>] [--dry-run]
|
|
11
|
+
# [--timeout S] [--retry-failed N] [--infra-retries M]
|
|
12
|
+
# [--qa-threshold P0|P1|P2|P3] [--verify-retries N]
|
|
13
|
+
# forge-parallel.sh retry <plan-dir> <task-id> [--dwarf <spec>] [--setup <command>]
|
|
14
|
+
# [--timeout S] [--qa-threshold Pn] [--verify-retries N]
|
|
15
|
+
# forge-parallel.sh integrate <plan-dir> --approved [--verify-retries N] [--final-review <qa-spec>]
|
|
16
|
+
# forge-parallel.sh review <plan-dir> [--qa <spec>] [--yolo-qa] [--qa-threshold Pn]
|
|
17
|
+
# forge-parallel.sh accept <plan-dir> <task-id> --reason "<why>" --approved
|
|
18
|
+
# forge-parallel.sh split <plan-dir> <new-plan> --tasks <id,id> [--dry-run]
|
|
19
|
+
# forge-parallel.sh combine <new-plan> <plan-a> <plan-b> [<plan>...] [--verify <command>] [--dry-run]
|
|
20
|
+
# forge-parallel.sh _task <plan-dir> <task-id> (internal; coordinator target)
|
|
21
|
+
#
|
|
22
|
+
# Routing flags (plan):
|
|
23
|
+
# --dwarf <spec> fallback for any difficulty tier not named below
|
|
24
|
+
# --dwarf-high <spec> spec (or comma-list, pooled) for high-difficulty tasks
|
|
25
|
+
# --dwarf-medium <spec>
|
|
26
|
+
# --dwarf-low <spec>
|
|
27
|
+
# --qa, --qa-high, --qa-medium, --qa-low identical grammar
|
|
28
|
+
# --decompose-level L the --decompose-level the goal was split at, kept as a record
|
|
29
|
+
# --shadow-qa <spec[,..]> opt-in: a second review of a sampled share of reviewed attempts,
|
|
30
|
+
# by a listed reviewer of another model family, recorded and
|
|
31
|
+
# never read (measures reviewer agreement). `none` turns it off
|
|
32
|
+
# --shadow-rate R that share, 0..1 (default 0.1)
|
|
33
|
+
# --explore E on by default at 0.2 (0 turns it off, and is kept): a row left
|
|
34
|
+
# to forge gets, with probability E/2, its neighbouring model
|
|
35
|
+
# (one tier away in the given pools) instead of its pool's turn;
|
|
36
|
+
# each decision and its odds go to tasks/<id>/route.jsonl
|
|
37
|
+
# (precedence: flag, this plan's earlier choice, FORGE_ROUTE_EXPLORE)
|
|
38
|
+
#
|
|
39
|
+
# Opt-in run behaviour (all off unless asked):
|
|
40
|
+
# --retry-failed N re-queue a task whose reviewer said FAIL up to N more times,
|
|
41
|
+
# inside the same run, while the other tasks keep going
|
|
42
|
+
# --infra-retries M wait out quota/auth/network stops up to M times instead of pausing
|
|
43
|
+
# --qa-threshold Pn a reviewer FAIL made only of findings less severe than Pn is
|
|
44
|
+
# accepted as PASS-with-known-issues (P0 worst ... P3 mildest)
|
|
45
|
+
#
|
|
46
|
+
# Exit codes: 0 ok | 2 usage/validation | 3 precondition (not a git repo, etc.)
|
|
47
|
+
# 5 some task failed QA or verification failed | 6 integration conflict
|
|
48
|
+
# 8 paused on an infrastructure failure (quota, auth, network): no attempt was
|
|
49
|
+
# spent, fix the cause and re-run `run`
|
|
50
|
+
set -uo pipefail
|
|
51
|
+
|
|
52
|
+
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
|
53
|
+
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
54
|
+
DISPATCH="$SKILL_DIR/scripts/forge-dispatch.sh"
|
|
55
|
+
MEMORY="$SKILL_DIR/scripts/forge-memory.sh"
|
|
56
|
+
source "$SKILL_DIR/scripts/forge-artifact.sh"
|
|
57
|
+
source "$SKILL_DIR/scripts/forge-metrics.sh"
|
|
58
|
+
source "$SKILL_DIR/scripts/forge-fractal-options.sh"
|
|
59
|
+
source "$SKILL_DIR/scripts/forge-ui-options.sh"
|
|
60
|
+
# Beside fractal's, and for the same reason: an option parser sourced from inside
|
|
61
|
+
# a function, after that function has already walked its arguments, cannot parse
|
|
62
|
+
# anything. Every --jev flag was rejected as "unknown option" until this line.
|
|
63
|
+
JEV_OPTS=0
|
|
64
|
+
[ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ] \
|
|
65
|
+
&& source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && JEV_OPTS=1
|
|
66
|
+
# Flags shared by several subcommands, then the helper libraries. Functions only, resolved
|
|
67
|
+
# at call time, so they may use die/note/field and friends defined below.
|
|
68
|
+
source "$SKILL_DIR/scripts/forge-parallel-options.sh"
|
|
69
|
+
for forge_lib in task verify plans review; do
|
|
70
|
+
[ -f "$SKILL_DIR/scripts/forge-lib-$forge_lib.sh" ] && source "$SKILL_DIR/scripts/forge-lib-$forge_lib.sh"
|
|
71
|
+
done
|
|
72
|
+
OUTPUT=summary
|
|
73
|
+
|
|
74
|
+
die() { printf 'forge: %s\n' "$1" >&2; exit "${2:-2}"; }
|
|
75
|
+
note() { printf 'forge: %s\n' "$*" >&2; }
|
|
76
|
+
|
|
77
|
+
# --- tsv helpers --------------------------------------------------------------
|
|
78
|
+
# awk rather than `read`, because tab is IFS-whitespace: `IFS=$'\t' read` collapses
|
|
79
|
+
# consecutive tabs and would silently shift every column after an empty field.
|
|
80
|
+
TASKS_COLS="id deps difficulty files dwarf qa title"
|
|
81
|
+
col_no() { # col_no <name> -> 1-based index
|
|
82
|
+
local want="$1" i=1 c
|
|
83
|
+
for c in $TASKS_COLS; do [ "$c" = "$want" ] && { echo "$i"; return; }; i=$((i+1)); done
|
|
84
|
+
echo 0
|
|
85
|
+
}
|
|
86
|
+
field() { # field <tasks.tsv> <id> <colname>
|
|
87
|
+
awk -F'\t' -v id="$2" -v n="$(col_no "$3")" '!/^#/ && NF && $1==id {print $n; exit}' "$1"
|
|
88
|
+
}
|
|
89
|
+
all_ids() { awk -F'\t' '!/^#/ && NF {print $1}' "$1"; }
|
|
90
|
+
|
|
91
|
+
list_has() { # list_has <needle> <comma-list>
|
|
92
|
+
local n="$1" l="$2" w
|
|
93
|
+
[ "$l" = "-" ] && return 1
|
|
94
|
+
for w in $(printf '%s' "$l" | tr ',' ' '); do [ "$w" = "$n" ] && return 0; done
|
|
95
|
+
return 1
|
|
96
|
+
}
|
|
97
|
+
lists_overlap() { # lists_overlap <comma-list-a> <space-list-b>
|
|
98
|
+
local a="$1" b="$2" w v
|
|
99
|
+
[ "$a" = "-" ] && return 1
|
|
100
|
+
for w in $(printf '%s' "$a" | tr ',' ' '); do
|
|
101
|
+
w="${w#./}"; w="${w%/}"
|
|
102
|
+
for v in $b; do
|
|
103
|
+
v="${v#./}"; v="${v%/}"
|
|
104
|
+
case "$w/" in "$v/"*) return 0 ;; esac
|
|
105
|
+
case "$v/" in "$w/"*) return 0 ;; esac
|
|
106
|
+
[ "$w" = . ] || [ "$v" = . ] && return 0
|
|
107
|
+
done
|
|
108
|
+
done
|
|
109
|
+
return 1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# --- plan ---------------------------------------------------------------------
|
|
113
|
+
# Resolves each task's difficulty into a concrete dwarf/qa spec and writes it back
|
|
114
|
+
# into tasks.tsv, so `run` never needs to know the routing rules. An explicit value
|
|
115
|
+
# already in the column always wins — that is how an approval-gate override for one
|
|
116
|
+
# row survives a re-plan.
|
|
117
|
+
pool_pick() { # pool_pick <comma-list> <index>
|
|
118
|
+
local list="$1" idx="$2" n=0 w last=""
|
|
119
|
+
for w in $(printf '%s' "$list" | tr ',' ' '); do n=$((n+1)); done
|
|
120
|
+
[ "$n" -eq 0 ] && { echo ""; return; }
|
|
121
|
+
idx=$((idx % n)); n=0
|
|
122
|
+
for w in $(printf '%s' "$list" | tr ',' ' '); do
|
|
123
|
+
[ "$n" -eq "$idx" ] && { echo "$w"; return; }
|
|
124
|
+
n=$((n+1)); last="$w"
|
|
125
|
+
done
|
|
126
|
+
echo "$last"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
do_plan() {
|
|
130
|
+
local PLAN="${1:?plan needs a plan dir}"; shift
|
|
131
|
+
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=""
|
|
133
|
+
while [ $# -gt 0 ]; do
|
|
134
|
+
case "$1" in
|
|
135
|
+
--repo) REPO="${2:?}"; shift 2 ;;
|
|
136
|
+
--planner) PLANNER="${2:?}"; shift 2 ;;
|
|
137
|
+
--decompose-level)
|
|
138
|
+
case "${2:-}" in low|medium|high) DLEVEL="$2"; shift 2 ;;
|
|
139
|
+
*) die "--decompose-level must be low, medium or high" ;; esac ;;
|
|
140
|
+
--no-ripwire) export FORGE_RIPWIRE=off; shift ;;
|
|
141
|
+
--no-memory) NOMEM=1; shift ;;
|
|
142
|
+
--shadow-qa)
|
|
143
|
+
[ -n "${2:-}" ] || die "--shadow-qa needs a QA spec, a comma list of specs, or none"
|
|
144
|
+
SHADOW_QA="$2"; shift 2 ;;
|
|
145
|
+
--shadow-rate)
|
|
146
|
+
printf '%s' "${2:-}" | grep -Eq '^(0?\.[0-9]+|0|1|1\.0+)$' || die "--shadow-rate needs a share between 0 and 1"
|
|
147
|
+
SHADOW_RATE="$2"; shift 2 ;;
|
|
148
|
+
--explore) EXPLORE="${2:?--explore needs a share between 0 and 0.5}"; shift 2 ;;
|
|
149
|
+
--dwarf) D_ANY="${2:?}"; shift 2 ;;
|
|
150
|
+
--dwarf-high) D_HI="${2:?}"; shift 2 ;;
|
|
151
|
+
--dwarf-medium) D_MD="${2:?}"; shift 2 ;;
|
|
152
|
+
--dwarf-low) D_LO="${2:?}"; shift 2 ;;
|
|
153
|
+
--qa) Q_ANY="${2:?}"; shift 2 ;;
|
|
154
|
+
--qa-high) Q_HI="${2:?}"; shift 2 ;;
|
|
155
|
+
--qa-medium) Q_MD="${2:?}"; shift 2 ;;
|
|
156
|
+
--qa-low) Q_LO="${2:?}"; shift 2 ;;
|
|
157
|
+
--ui|--no-ui) forge_ui_flag "$1"; shift ;;
|
|
158
|
+
--fail-first) FAIL_FIRST=on; shift ;;
|
|
159
|
+
--no-fail-first) FAIL_FIRST=off; shift ;;
|
|
160
|
+
*)
|
|
161
|
+
# `case` is not a loop, so `continue` here belongs to the enclosing while.
|
|
162
|
+
if [ "$JEV_OPTS" = 1 ]; then
|
|
163
|
+
forge_jev_flag "$1"; jev_rc=$?
|
|
164
|
+
case "$jev_rc" in
|
|
165
|
+
0) shift "$JEV_SHIFT"; continue ;;
|
|
166
|
+
2) exit 2 ;;
|
|
167
|
+
esac
|
|
168
|
+
fi
|
|
169
|
+
die "plan: unknown option '$1'"
|
|
170
|
+
;;
|
|
171
|
+
esac
|
|
172
|
+
done
|
|
173
|
+
[ "$JEV_OPTS" = 1 ] && forge_jev_export
|
|
174
|
+
[ -d "$PLAN" ] || die "plan dir '$PLAN' does not exist"
|
|
175
|
+
# Freeze the selection next to the task table, so run and retry restore it rather
|
|
176
|
+
# than re-deciding. Written on every plan: a re-plan is a new decision.
|
|
177
|
+
[ "$JEV_OPTS" = 1 ] && forge_jev_record "$PLAN"
|
|
178
|
+
local T="$PLAN/tasks.tsv"
|
|
179
|
+
[ -r "$T" ] || die "no tasks.tsv in '$PLAN' — decompose the goal first"
|
|
180
|
+
# The table as the planner or orchestrator wrote it, before Jev or route resolution
|
|
181
|
+
# rewrites any column: which tier a task was DECLARED is a label of its own.
|
|
182
|
+
cp "$T" "$PLAN/tasks.declared.tsv"
|
|
183
|
+
[ -z "$DLEVEL" ] || printf '%s\n' "$DLEVEL" > "$PLAN/decompose_level"
|
|
184
|
+
# Shadow reviews are part of the plan, like its routing: frozen here, shown in the table
|
|
185
|
+
# and read by every task process. `none` turns them off again.
|
|
186
|
+
case "$SHADOW_QA" in
|
|
187
|
+
'') ;;
|
|
188
|
+
none|off) rm -f "$PLAN/shadow_qa" "$PLAN/shadow_rate" "$PLAN/shadow_qa.ok" ;;
|
|
189
|
+
*) printf '%s\n' "$SHADOW_QA" > "$PLAN/shadow_qa" ;;
|
|
190
|
+
esac
|
|
191
|
+
[ -z "$SHADOW_RATE" ] || [ "$SHADOW_QA" = none ] || [ "$SHADOW_QA" = off ] \
|
|
192
|
+
|| printf '%s\n' "$SHADOW_RATE" > "$PLAN/shadow_rate"
|
|
193
|
+
# Fail-first evidence (opt-in, advisory) is part of the plan too: every task's review gets it.
|
|
194
|
+
case "$FAIL_FIRST" in on) : > "$PLAN/fail_first" ;; off) rm -f "$PLAN/fail_first" ;; esac
|
|
195
|
+
# Exploration is decided here and only here, so the table and preflight see what will run.
|
|
196
|
+
# On by default: choosing models from measured pass rates needs some randomized picks, and
|
|
197
|
+
# history had almost none (9 tasks ever ran on more than one spec). The flag wins, then
|
|
198
|
+
# this plan's earlier choice, then FORGE_ROUTE_EXPLORE, then 0.2. A 0 is written down too,
|
|
199
|
+
# so a later re-plan never quietly turns exploration back on.
|
|
200
|
+
if [ -z "$EXPLORE" ]; then
|
|
201
|
+
if [ -f "$PLAN/explore" ]; then EXPLORE="$(cat "$PLAN/explore")"
|
|
202
|
+
elif [ -n "${FORGE_ROUTE_EXPLORE:-}" ]; then EXPLORE="$FORGE_ROUTE_EXPLORE"
|
|
203
|
+
else EXPLORE=0.2; fi
|
|
204
|
+
fi
|
|
205
|
+
awk -v v="$EXPLORE" 'BEGIN { exit !(v ~ /^(0|0?\.[0-9]+)$/ && v + 0 <= 0.5) }' \
|
|
206
|
+
|| die "--explore (or FORGE_ROUTE_EXPLORE) needs a share between 0 and 0.5"
|
|
207
|
+
printf '%s\n' "$EXPLORE" > "$PLAN/explore"
|
|
208
|
+
if awk -v v="$EXPLORE" 'BEGIN { exit !(v + 0 == 0) }'; then EXPLORE=""; fi
|
|
209
|
+
|
|
210
|
+
# repo: from flag on first plan, remembered afterwards
|
|
211
|
+
if [ -n "$REPO" ]; then
|
|
212
|
+
[ -d "$REPO" ] || die "--repo '$REPO' is not a directory"
|
|
213
|
+
(cd "$REPO" && git rev-parse --git-dir >/dev/null 2>&1) \
|
|
214
|
+
|| die "'$REPO' is not a git repository. Decomposed runs need worktrees, and there is no honest fallback without them." 3
|
|
215
|
+
(cd "$REPO" && pwd) > "$PLAN/repo"
|
|
216
|
+
fi
|
|
217
|
+
[ -s "$PLAN/repo" ] || die "plan needs --repo the first time"
|
|
218
|
+
REPO="$(cat "$PLAN/repo")"
|
|
219
|
+
[ -s "$PLAN/run_id" ] || basename "$PLAN" | sed 's/^forge-//' > "$PLAN/run_id"
|
|
220
|
+
# Every plan, --ui or not: forge-ui's view of this repo's runs reads the registry.
|
|
221
|
+
forge_ui_register "$PLAN"
|
|
222
|
+
[ "${FORGE_RIPWIRE:-}" != off ] || touch "$PLAN/no_ripwire"
|
|
223
|
+
# Persist plan choices for later run and retry processes.
|
|
224
|
+
[ "$NOMEM" = 1 ] && : > "$PLAN/no_memory"
|
|
225
|
+
[ -n "$PLANNER" ] && printf '%s\n' "$PLANNER" > "$PLAN/planner"
|
|
226
|
+
python3 - "$PLAN/fractal-routing.json" "$D_ANY" "$D_LO" "$D_MD" "$D_HI" <<'PY'
|
|
227
|
+
import json,sys
|
|
228
|
+
from pathlib import Path
|
|
229
|
+
p=Path(sys.argv[1]); data=json.loads(p.read_text()) if p.exists() else {}
|
|
230
|
+
for key,value in zip(('any','low','medium','high'),sys.argv[2:]):
|
|
231
|
+
if value: data[key]=[s.strip() for s in value.split(',') if s.strip()]
|
|
232
|
+
p.write_text(json.dumps(data))
|
|
233
|
+
PY
|
|
234
|
+
|
|
235
|
+
# Uncommitted work is invisible to every dwarf, because worktrees branch from a
|
|
236
|
+
# commit. That is the likeliest way to get a confusingly wrong result, so it is
|
|
237
|
+
# stated loudly rather than left for the user to deduce from an odd diff.
|
|
238
|
+
if [ -n "$(cd "$REPO" && git status --porcelain 2>/dev/null)" ]; then
|
|
239
|
+
note "WARNING: '$REPO' has uncommitted changes. Task worktrees branch from HEAD,"
|
|
240
|
+
note " so those edits will NOT be visible to any dwarf. Commit or stash first"
|
|
241
|
+
note " if the tasks depend on them."
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
# --- jev: advisory difficulty scoring ------------------------------------------
|
|
245
|
+
# Advisory only: a suggested tier, and gate warnings about the task text. Writing a Jev
|
|
246
|
+
# tier into the difficulty column (`--jev-act`, gated on a per-repo calibration) was
|
|
247
|
+
# removed on 2026-10-17 without ever having acted; models are chosen from measured pass
|
|
248
|
+
# rates instead (plan --explore, `forge dataset routing`).
|
|
249
|
+
local jev_loaded=0
|
|
250
|
+
if [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
|
|
251
|
+
source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded=1
|
|
252
|
+
fi
|
|
253
|
+
if [ "$jev_loaded" = 1 ] && forge_jev_active routing; then
|
|
254
|
+
local jev_scores jev_rc
|
|
255
|
+
jev_scores="$(python3 "$SKILL_DIR/scripts/forge-jev.py" score-plan \
|
|
256
|
+
--plan "$PLAN" --repo "$REPO" 2>/dev/null)"
|
|
257
|
+
jev_rc=$?
|
|
258
|
+
if [ "$jev_rc" -eq 0 ] && [ -n "$jev_scores" ]; then
|
|
259
|
+
# Shadow mode records the judgment and prints nothing per task.
|
|
260
|
+
if [ "${FORGE_JEV_SHADOW:-}" = on ]; then
|
|
261
|
+
note "jev: scored $(printf '%s\n' "$jev_scores" | grep -c . ) task(s) in shadow mode (no change)"
|
|
262
|
+
else
|
|
263
|
+
local jid jtier jconf jcomp jesc jdecl jwarn jdrift
|
|
264
|
+
# The columns score-plan prints: id, tier, confidence, composite, escalated,
|
|
265
|
+
# declared, warnings, drift. Read in lockstep with cmd_score_plan.
|
|
266
|
+
while IFS="$(printf '\t')" read -r jid jtier jconf jcomp jesc jdecl jwarn jdrift; do
|
|
267
|
+
[ -n "$jid" ] || continue
|
|
268
|
+
# score-plan writes "-" for "not escalated"; only a real reason is worth showing.
|
|
269
|
+
[ "$jesc" = "-" ] && jesc=""
|
|
270
|
+
note "jev: $jid suggests $jtier (confidence $jconf${jesc:+, escalated: $jesc}) — declared $jdecl"
|
|
271
|
+
# Gates are warnings: they are about the task text and its declared files, not
|
|
272
|
+
# about which model runs it.
|
|
273
|
+
if [ -n "$jwarn" ] && [ "$jwarn" != "-" ]; then
|
|
274
|
+
case "$jwarn" in
|
|
275
|
+
*prompt_adequacy*) note "jev: $jid — the prompt may be too vague for QA to judge correctness against" ;;
|
|
276
|
+
esac
|
|
277
|
+
case "$jwarn" in
|
|
278
|
+
*independently_verifiable*) note "jev: $jid — may not be verifiable on its own (decompose.md forbids splitting this finely)" ;;
|
|
279
|
+
esac
|
|
280
|
+
fi
|
|
281
|
+
if [ -n "$jdrift" ] && [ "$jdrift" != "-" ]; then
|
|
282
|
+
note "jev: $jid may also edit undeclared files: $jdrift"
|
|
283
|
+
note " undeclared files break wave disjointness — add them to the files column or split the task"
|
|
284
|
+
fi
|
|
285
|
+
done <<EOF
|
|
286
|
+
$jev_scores
|
|
287
|
+
EOF
|
|
288
|
+
fi
|
|
289
|
+
fi
|
|
290
|
+
fi
|
|
291
|
+
|
|
292
|
+
# --- validate + resolve routing ---
|
|
293
|
+
local tmp="$PLAN/.tasks.tsv.new"; : > "$tmp"
|
|
294
|
+
local hi_i=0 md_i=0 lo_i=0 an_i=0 qhi_i=0 qmd_i=0 qlo_i=0 qan_i=0 unassigned=0
|
|
295
|
+
printf '# id\tdeps\tdifficulty\tfiles\tdwarf\tqa\ttitle\n' > "$tmp"
|
|
296
|
+
local id deps diff files dw qa title line
|
|
297
|
+
while IFS= read -r line; do
|
|
298
|
+
case "$line" in ''|'#'*) continue ;; esac
|
|
299
|
+
id="$(printf '%s' "$line" | awk -F'\t' '{print $1}')"
|
|
300
|
+
deps="$(printf '%s' "$line" | awk -F'\t' '{print $2}')"
|
|
301
|
+
diff="$(printf '%s' "$line" | awk -F'\t' '{print $3}')"
|
|
302
|
+
files="$(printf '%s' "$line" | awk -F'\t' '{print $4}')"
|
|
303
|
+
dw="$(printf '%s' "$line" | awk -F'\t' '{print $5}')"
|
|
304
|
+
qa="$(printf '%s' "$line" | awk -F'\t' '{print $6}')"
|
|
305
|
+
title="$(printf '%s' "$line" | awk -F'\t' '{print $7}')"
|
|
306
|
+
[ -n "$id" ] || die "tasks.tsv has a row with no id"
|
|
307
|
+
case "$diff" in low|medium|high) ;; *) die "task '$id' has difficulty '$diff' (expected low, medium or high)" ;; esac
|
|
308
|
+
# deps must exist
|
|
309
|
+
if [ "$deps" != "-" ]; then
|
|
310
|
+
local d
|
|
311
|
+
for d in $(printf '%s' "$deps" | tr ',' ' '); do
|
|
312
|
+
all_ids "$T" | grep -qx "$d" || die "task '$id' depends on unknown task '$d'"
|
|
313
|
+
done
|
|
314
|
+
fi
|
|
315
|
+
# resolve dwarf. UNASSIGNED is a marker from a previous plan, not a user
|
|
316
|
+
# choice, so it must be re-resolved rather than preserved like a real override.
|
|
317
|
+
[ "$dw" = "UNASSIGNED" ] && dw="-"
|
|
318
|
+
if [ "$dw" = "-" ] || [ -z "$dw" ]; then
|
|
319
|
+
case "$diff" in
|
|
320
|
+
high) if [ -n "$D_HI" ]; then dw="$(pool_pick "$D_HI" "$hi_i")"; hi_i=$((hi_i+1)); fi ;;
|
|
321
|
+
medium) if [ -n "$D_MD" ]; then dw="$(pool_pick "$D_MD" "$md_i")"; md_i=$((md_i+1)); fi ;;
|
|
322
|
+
low) if [ -n "$D_LO" ]; then dw="$(pool_pick "$D_LO" "$lo_i")"; lo_i=$((lo_i+1)); fi ;;
|
|
323
|
+
esac
|
|
324
|
+
if [ "$dw" = "-" ] || [ -z "$dw" ]; then
|
|
325
|
+
if [ -n "$D_ANY" ]; then dw="$(pool_pick "$D_ANY" "$an_i")"; an_i=$((an_i+1));
|
|
326
|
+
else dw="UNASSIGNED"; unassigned=$((unassigned+1)); fi
|
|
327
|
+
fi
|
|
328
|
+
# Exploration (--explore): now and then the neighbouring tier's model instead, with the
|
|
329
|
+
# odds recorded, so `forge dataset routing` can compare two models on tasks neither was
|
|
330
|
+
# routed to by design. The pool's turn above was still taken, so other rows are unchanged.
|
|
331
|
+
if [ -n "$EXPLORE" ] && [ "$dw" != UNASSIGNED ]; then
|
|
332
|
+
local explored
|
|
333
|
+
explored="$(python3 "$SKILL_DIR/scripts/forge-route.py" explore --plan "$PLAN" --task "$id" \
|
|
334
|
+
--tier "$diff" --epsilon "$EXPLORE" --default "$dw" --pools "$PLAN/fractal-routing.json" 2>/dev/null)"
|
|
335
|
+
[ -z "$explored" ] || dw="$explored"
|
|
336
|
+
fi
|
|
337
|
+
fi
|
|
338
|
+
# resolve qa: the same grammar as the dwarf, a comma-list round-robins within its tier.
|
|
339
|
+
# Written verbatim, a list reached dispatch as one spec ('opus,sol' is not a known
|
|
340
|
+
# alias) and every review of the run failed before a model was called.
|
|
341
|
+
if [ "$qa" = "-" ] || [ -z "$qa" ]; then
|
|
342
|
+
case "$diff" in
|
|
343
|
+
high) if [ -n "$Q_HI" ]; then qa="$(pool_pick "$Q_HI" "$qhi_i")"; qhi_i=$((qhi_i+1)); fi ;;
|
|
344
|
+
medium) if [ -n "$Q_MD" ]; then qa="$(pool_pick "$Q_MD" "$qmd_i")"; qmd_i=$((qmd_i+1)); fi ;;
|
|
345
|
+
low) if [ -n "$Q_LO" ]; then qa="$(pool_pick "$Q_LO" "$qlo_i")"; qlo_i=$((qlo_i+1)); fi ;;
|
|
346
|
+
esac
|
|
347
|
+
if [ "$qa" = "-" ] || [ -z "$qa" ]; then
|
|
348
|
+
if [ -n "$Q_ANY" ]; then qa="$(pool_pick "$Q_ANY" "$qan_i")"; qan_i=$((qan_i+1)); else qa="opus"; fi
|
|
349
|
+
fi
|
|
350
|
+
fi
|
|
351
|
+
# A column runs one spec; a list there would only fail later, at its first dispatch.
|
|
352
|
+
case "$dw" in *,*) die "task '$id': the dwarf column names one spec, not a list ('$dw'); pool models with a --dwarf flag" ;; esac
|
|
353
|
+
case "$qa" in *,*) die "task '$id': the qa column names one spec, not a list ('$qa'); pool reviewers with a --qa flag" ;; esac
|
|
354
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$id" "$deps" "$diff" "$files" "$dw" "$qa" "$title" >> "$tmp"
|
|
355
|
+
done < "$T"
|
|
356
|
+
mv "$tmp" "$T"
|
|
357
|
+
|
|
358
|
+
compute_waves "$PLAN"
|
|
359
|
+
|
|
360
|
+
# --- clef: silent first-attempt predictions -------------------------------------
|
|
361
|
+
# Recorded only (tasks/<id>/clef.first_pass and the run dataset), so a later export can
|
|
362
|
+
# measure whether Clef tells which tasks a first attempt will fail. Nothing in a run
|
|
363
|
+
# reads them. Without a Cloudflare provider the command returns at once; with one it
|
|
364
|
+
# bounds itself (one deadline for the plan, an in-process watchdog).
|
|
365
|
+
if [ "${FORGE_JEV:-}" != off ]; then
|
|
366
|
+
python3 "$SKILL_DIR/scripts/forge-jev.py" first-pass --plan "$PLAN" --repo "$REPO" >/dev/null 2>&1 || true
|
|
367
|
+
fi
|
|
368
|
+
|
|
369
|
+
# --- jev: advisory wave coupling ------------------------------------------------
|
|
370
|
+
# Disjoint files bound parallelism structurally, but they cannot see the conflict
|
|
371
|
+
# where one task in a wave changes an interface another task in the SAME wave
|
|
372
|
+
# consumes -- both diffs are clean against their own baseline, and the merge is
|
|
373
|
+
# broken. This is advisory only: it never edits tasks.tsv, never adds a dep, and
|
|
374
|
+
# never changes waves.tsv, so it must run after compute_waves and before the table
|
|
375
|
+
# that reports the plan is printed.
|
|
376
|
+
if [ "$jev_loaded" = 1 ] && forge_jev_active gates; then
|
|
377
|
+
local jev_coupling jev_coupling_rc
|
|
378
|
+
jev_coupling="$(python3 "$SKILL_DIR/scripts/forge-jev.py" coupling \
|
|
379
|
+
--plan "$PLAN" --repo "$REPO" 2>/dev/null)"
|
|
380
|
+
jev_coupling_rc=$?
|
|
381
|
+
if [ "$jev_coupling_rc" -eq 0 ] && [ -n "$jev_coupling" ]; then
|
|
382
|
+
if [ "${FORGE_JEV_SHADOW:-}" = on ]; then
|
|
383
|
+
note "jev: found $(printf '%s\n' "$jev_coupling" | grep -c .) coupled pair(s) in shadow mode (no change)"
|
|
384
|
+
else
|
|
385
|
+
local ca cb cp ckind
|
|
386
|
+
while IFS="$(printf '\t')" read -r ca cb cp ckind; do
|
|
387
|
+
[ -n "$ca" ] || continue
|
|
388
|
+
# Two different failures, so two different sentences. same-wave is a broken
|
|
389
|
+
# merge between concurrent tasks; forward is an earlier task written against
|
|
390
|
+
# work that has not been built yet, which its own review cannot catch.
|
|
391
|
+
if [ "$ckind" = forward ]; then
|
|
392
|
+
note "jev: task '$ca' runs before '$cb' and may depend on what it builds ($cp) — consider a dep"
|
|
393
|
+
else
|
|
394
|
+
note "jev: tasks '$ca' and '$cb' may conflict despite disjoint files ($cp) — consider a dep"
|
|
395
|
+
fi
|
|
396
|
+
done <<EOF
|
|
397
|
+
$jev_coupling
|
|
398
|
+
EOF
|
|
399
|
+
fi
|
|
400
|
+
fi
|
|
401
|
+
fi
|
|
402
|
+
|
|
403
|
+
render_table "$PLAN"
|
|
404
|
+
# Before the UNASSIGNED return, so the URL prints under the table either way.
|
|
405
|
+
forge_ui_launch "$PLAN"
|
|
406
|
+
|
|
407
|
+
if [ "$unassigned" -gt 0 ]; then
|
|
408
|
+
note ""
|
|
409
|
+
note "$unassigned task(s) are UNASSIGNED: their difficulty tier has no --dwarf-<tier>"
|
|
410
|
+
note "rule and there is no plain --dwarf fallback. Assign them before running —"
|
|
411
|
+
note "forge will not pick a model that spends your quota on your behalf."
|
|
412
|
+
return 2
|
|
413
|
+
fi
|
|
414
|
+
return 0
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# --- waves --------------------------------------------------------------------
|
|
418
|
+
# A wave is the largest set of tasks whose deps are already satisfied AND whose
|
|
419
|
+
# file sets are pairwise disjoint. Disjointness is what actually bounds
|
|
420
|
+
# parallelism: two dwarves editing one file produce a conflict no reviewer can
|
|
421
|
+
# untangle, so they get separate waves no matter how the goal was decomposed.
|
|
422
|
+
compute_waves() {
|
|
423
|
+
local PLAN="$1" T="$1/tasks.tsv" W="$1/waves.tsv"
|
|
424
|
+
: > "$W"; : > "$PLAN/deferrals.txt"
|
|
425
|
+
local remaining done_ids wave n id deps files wave_files ok d progressed
|
|
426
|
+
remaining="$(all_ids "$T" | tr '\n' ' ')"
|
|
427
|
+
done_ids=" "; wave=0
|
|
428
|
+
while [ -n "${remaining// /}" ]; do
|
|
429
|
+
wave=$((wave+1)); wave_files=""; progressed=0
|
|
430
|
+
local next_remaining=""
|
|
431
|
+
for id in $remaining; do
|
|
432
|
+
deps="$(field "$T" "$id" deps)"
|
|
433
|
+
files="$(field "$T" "$id" files)"
|
|
434
|
+
ok=1
|
|
435
|
+
if [ "$deps" != "-" ]; then
|
|
436
|
+
for d in $(printf '%s' "$deps" | tr ',' ' '); do
|
|
437
|
+
case "$done_ids" in *" $d "*) ;; *) ok=0 ;; esac
|
|
438
|
+
done
|
|
439
|
+
fi
|
|
440
|
+
if [ "$ok" = 1 ] && lists_overlap "$files" "$wave_files"; then
|
|
441
|
+
ok=0
|
|
442
|
+
printf 'wave %s: %s deferred (file overlap with a task already in this wave)\n' \
|
|
443
|
+
"$wave" "$id" >> "$PLAN/deferrals.txt"
|
|
444
|
+
fi
|
|
445
|
+
if [ "$ok" = 1 ]; then
|
|
446
|
+
printf '%s\t%s\n' "$wave" "$id" >> "$W"
|
|
447
|
+
wave_files="$wave_files $(printf '%s' "$files" | tr ',' ' ')"
|
|
448
|
+
progressed=1
|
|
449
|
+
else
|
|
450
|
+
next_remaining="$next_remaining $id"
|
|
451
|
+
fi
|
|
452
|
+
done
|
|
453
|
+
[ "$progressed" = 1 ] || die "dependency cycle or unsatisfiable deps among:$next_remaining"
|
|
454
|
+
for id in $(awk -F'\t' -v w="$wave" '$1==w {print $2}' "$W"); do done_ids="$done_ids$id "; done
|
|
455
|
+
remaining="$next_remaining"
|
|
456
|
+
done
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
render_table() {
|
|
460
|
+
local PLAN="$1" T="$1/tasks.tsv" W="$1/waves.tsv"
|
|
461
|
+
local run_id; run_id="$(cat "$PLAN/run_id")"
|
|
462
|
+
local n; n="$(all_ids "$T" | wc -l | tr -d ' ')"
|
|
463
|
+
local waves; waves="$(awk -F'\t' '{print $1}' "$W" | sort -u | wc -l | tr -d ' ')"
|
|
464
|
+
echo "run forge/$run_id repo $(cat "$PLAN/repo")"
|
|
465
|
+
[ -s "$PLAN/goal.txt" ] && echo "goal: $(head -1 "$PLAN/goal.txt")"
|
|
466
|
+
local est=$((n*2)) extra=""
|
|
467
|
+
if [ -s "$PLAN/planner" ]; then
|
|
468
|
+
est=$((est+1)); extra=" + 1 planner ($(cat "$PLAN/planner"))"
|
|
469
|
+
fi
|
|
470
|
+
echo "tasks: $n waves: $waves estimated dispatches: $est ($n dwarf + $n qa$extra)"
|
|
471
|
+
if [ -s "$PLAN/shadow_qa" ]; then
|
|
472
|
+
local rate; rate="$(cat "$PLAN/shadow_rate" 2>/dev/null || echo 0.1)"
|
|
473
|
+
echo "shadow review: $(cat "$PLAN/shadow_qa") on a sampled $rate of reviewed attempts" \
|
|
474
|
+
"(about $(awk -v n="$n" -v r="$rate" 'BEGIN { x = n * r; printf "%d", (x == int(x)) ? x : int(x) + 1 }') extra reviews; recorded, never changes a result)"
|
|
475
|
+
fi
|
|
476
|
+
if [ -f "$PLAN/fail_first" ]; then
|
|
477
|
+
if [ -s "$(cat "$PLAN/repo")/.forge/verify-subset" ]; then
|
|
478
|
+
echo "fail-first: each task's new tests also run without its change (advice for the reviewer; never changes a result)"
|
|
479
|
+
else
|
|
480
|
+
echo "fail-first: on, but $(cat "$PLAN/repo")/.forge/verify-subset (a test command with {files}) is missing, so nothing will run"
|
|
481
|
+
fi
|
|
482
|
+
fi
|
|
483
|
+
echo
|
|
484
|
+
local w id dws starred=0
|
|
485
|
+
for w in $(awk -F'\t' '{print $1}' "$W" | sort -un); do
|
|
486
|
+
local cnt; cnt="$(awk -F'\t' -v w="$w" '$1==w' "$W" | wc -l | tr -d ' ')"
|
|
487
|
+
echo "wave $w ($cnt parallel)"
|
|
488
|
+
printf ' %-14s %-7s %-24s %-14s %s\n' id diff dwarf qa files
|
|
489
|
+
for id in $(awk -F'\t' -v w="$w" '$1==w {print $2}' "$W"); do
|
|
490
|
+
dws="$(field "$T" "$id" dwarf)"
|
|
491
|
+
if [ -s "$PLAN/tasks/$id/route.explored" ] && [ "$(cat "$PLAN/tasks/$id/route.explored")" = "$dws" ]; then
|
|
492
|
+
dws="$dws*"; starred=1
|
|
493
|
+
fi
|
|
494
|
+
printf ' %-14s %-7s %-24s %-14s %s\n' \
|
|
495
|
+
"$id" "$(field "$T" "$id" difficulty)" "$dws" \
|
|
496
|
+
"$(field "$T" "$id" qa)" "$(field "$T" "$id" files)"
|
|
497
|
+
# The approach is shown here because this gate is the only moment a human can
|
|
498
|
+
# change the plan for free. After dispatch, changing it costs a whole run.
|
|
499
|
+
if [ -s "$PLAN/tasks/$id/approach.md" ]; then
|
|
500
|
+
sed 's/^/ | /' "$PLAN/tasks/$id/approach.md"
|
|
501
|
+
fi
|
|
502
|
+
done
|
|
503
|
+
echo
|
|
504
|
+
done
|
|
505
|
+
if [ "$starred" = 1 ]; then
|
|
506
|
+
echo "* picked by exploratory routing (--explore $(cat "$PLAN/explore" 2>/dev/null)) instead of the pool's turn;"
|
|
507
|
+
echo " to pin another model, put its spec in the dwarf column of tasks.tsv and re-plan"
|
|
508
|
+
echo
|
|
509
|
+
fi
|
|
510
|
+
local explore drawn=0 decisions; explore="$(cat "$PLAN/explore" 2>/dev/null)"
|
|
511
|
+
# Said only when a row was actually drawn (route.jsonl): with every row named, or no tier
|
|
512
|
+
# with a neighbour, there is no spend to show. LC_ALL=C: a comma-decimal locale prints "0,1".
|
|
513
|
+
for decisions in "$PLAN"/tasks/*/route.jsonl; do [ -e "$decisions" ] && { drawn=1; break; }; done
|
|
514
|
+
if [ -n "$explore" ] && [ "$drawn" = 1 ] && LC_ALL=C awk -v v="$explore" 'BEGIN { exit !(v + 0 > 0) }'; then
|
|
515
|
+
echo "exploration on (--explore $explore): a row left to forge gets its neighbouring tier's model with"
|
|
516
|
+
echo " probability $(LC_ALL=C awk -v v="$explore" 'BEGIN { print v / 2 }'), odds logged for \`forge dataset routing\`; --explore 0 turns it off for this plan"
|
|
517
|
+
echo
|
|
518
|
+
fi
|
|
519
|
+
report_size "$PLAN"
|
|
520
|
+
if [ -s "$PLAN/deferrals.txt" ]; then
|
|
521
|
+
echo "deferrals (serialized to protect the merge):"
|
|
522
|
+
sed 's/^/ /' "$PLAN/deferrals.txt"
|
|
523
|
+
echo
|
|
524
|
+
fi
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
# --- capsule ------------------------------------------------------------------
|
|
528
|
+
# Every dispatch is a clean slate, which keeps one bad turn from poisoning another
|
|
529
|
+
# task but leaves each dwarf blind to the wider run. The capsule is the fix, and it
|
|
530
|
+
# is regenerated per dispatch because status changes as waves land. Two failures it
|
|
531
|
+
# exists to prevent: reimplementing something an earlier task already merged, and
|
|
532
|
+
# editing a file another dwarf currently owns.
|
|
533
|
+
task_status() { # task_status <plan> <id>
|
|
534
|
+
local PLAN="$1" id="$2"
|
|
535
|
+
[ -f "$PLAN/tasks/$id/merged" ] && { echo MERGED; return; }
|
|
536
|
+
[ -f "$PLAN/tasks/$id/status" ] && { cat "$PLAN/tasks/$id/status"; return; }
|
|
537
|
+
echo PENDING
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
dependencies_ready() {
|
|
541
|
+
local plan="$1" id="$2" dep
|
|
542
|
+
for dep in $(field "$plan/tasks.tsv" "$id" deps | tr ',' ' '); do
|
|
543
|
+
[ "$dep" = - ] && continue
|
|
544
|
+
if [ "$(task_status "$plan" "$dep")" != MERGED ]; then
|
|
545
|
+
echo BLOCKED > "$plan/tasks/$id/status"
|
|
546
|
+
note "$id: BLOCKED by $dep ($(task_status "$plan" "$dep"))"
|
|
547
|
+
return 1
|
|
548
|
+
fi
|
|
549
|
+
done
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
preflight_plan() {
|
|
553
|
+
local plan="$1" id role flags
|
|
554
|
+
export FORGE_CAPABILITY_CACHE="$plan/capabilities"
|
|
555
|
+
for id in $(all_ids "$plan/tasks.tsv"); do
|
|
556
|
+
for role in dwarf qa; do
|
|
557
|
+
flags=""; [ -f "$plan/yolo_$role" ] && flags=--yolo
|
|
558
|
+
/bin/bash "$DISPATCH" doctor --spec "$(field "$plan/tasks.tsv" "$id" "$role")" --role "$role" $flags || return $?
|
|
559
|
+
done
|
|
560
|
+
done
|
|
561
|
+
if [ -s "$plan/planner" ]; then
|
|
562
|
+
/bin/bash "$DISPATCH" doctor --spec "$(cat "$plan/planner")" --role planner || return $?
|
|
563
|
+
fi
|
|
564
|
+
# A shadow reviewer that cannot run here is dropped with a note, never a failed run.
|
|
565
|
+
if [ -s "$plan/shadow_qa" ]; then
|
|
566
|
+
python3 "$SKILL_DIR/scripts/forge-shadow.py" preflight --plan "$plan" || true
|
|
567
|
+
fi
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
write_capsule() { # write_capsule <plan> <id> <role>
|
|
571
|
+
local PLAN="$1" me="$2" role="$3" T="$1/tasks.tsv"
|
|
572
|
+
local run_id; run_id="$(cat "$PLAN/run_id")"
|
|
573
|
+
local n; n="$(all_ids "$T" | wc -l | tr -d ' ')"
|
|
574
|
+
# Per task, NOT one shared file. Tasks in a wave run concurrently, so a single
|
|
575
|
+
# $PLAN/capsule.md is a race: each task overwrites it and then reads back
|
|
576
|
+
# whichever sibling wrote last. That silently hands a dwarf someone else's task
|
|
577
|
+
# description — observed in testing before this was split per task.
|
|
578
|
+
local out="$PLAN/tasks/$me/capsule.md" id st
|
|
579
|
+
{
|
|
580
|
+
echo "# forge run $run_id"
|
|
581
|
+
[ -s "$PLAN/goal.txt" ] && { echo; echo "Overall goal: $(cat "$PLAN/goal.txt")"; }
|
|
582
|
+
echo
|
|
583
|
+
echo "This run is split into $n tasks worked by different models. You are handling ONE of"
|
|
584
|
+
echo "them. Do not do the other tasks' work."
|
|
585
|
+
echo
|
|
586
|
+
if [ "$role" = qa ]; then
|
|
587
|
+
echo "## Under review"
|
|
588
|
+
else
|
|
589
|
+
echo "## Your task"
|
|
590
|
+
fi
|
|
591
|
+
echo "$me — $(field "$T" "$me" title) (difficulty: $(field "$T" "$me" difficulty))"
|
|
592
|
+
echo "Files this task owns: $(field "$T" "$me" files)"
|
|
593
|
+
echo
|
|
594
|
+
echo "## Run status"
|
|
595
|
+
printf '%-14s %-7s %-9s %s\n' id diff status files
|
|
596
|
+
for id in $(all_ids "$T"); do
|
|
597
|
+
if [ "$id" = "$me" ]; then st="THIS ONE"
|
|
598
|
+
elif [ -s "$PLAN/tasks/$id/reviewed.commit" ] && git -C "$(cat "$PLAN/repo")" merge-base --is-ancestor "$(cat "$PLAN/tasks/$id/reviewed.commit")" "$(cat "$PLAN/tasks/$me/base_ref")"; then st=MERGED
|
|
599
|
+
else st="NOT IN BASE"; fi
|
|
600
|
+
printf '%-14s %-7s %-9s %s\n' "$id" "$(field "$T" "$id" difficulty)" "$st" "$(field "$T" "$id" files)"
|
|
601
|
+
done
|
|
602
|
+
echo
|
|
603
|
+
echo "## Ground rules"
|
|
604
|
+
echo "- Your branch already contains every MERGED task's work. Do not reimplement it."
|
|
605
|
+
echo "- MERGED means present in this task's pinned baseline; later merges are not included."
|
|
606
|
+
echo "- The review diff is only this task's work against that baseline."
|
|
607
|
+
echo "- Implementation must respect other tasks' ownership; report necessary scope changes."
|
|
608
|
+
|
|
609
|
+
} > "$out"
|
|
610
|
+
echo "$out"
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
# --- one task -----------------------------------------------------------------
|
|
614
|
+
# Idempotent by construction: it may be entered on a fresh task, on a resumed run
|
|
615
|
+
# whose worktree already exists, or on a retry of a task that already failed once.
|
|
616
|
+
# The original version created the worktree unconditionally, which made a
|
|
617
|
+
# decomposed run a one-shot — a failed task's preserved branch had nothing that
|
|
618
|
+
# could act on it, and an interrupted run could not be continued.
|
|
619
|
+
# A fresh worktree is a bare checkout: no node_modules, no venv, no build output,
|
|
620
|
+
# nothing a package manager put there. Every dwarf in the run then rediscovers that
|
|
621
|
+
# the same way — by running the test command, watching it fail, and working out the
|
|
622
|
+
# install step — and pays it again. In one observed 6-task run that tax showed up as
|
|
623
|
+
# four separate "this worktree has no node_modules" entries in project memory and
|
|
624
|
+
# minutes per dispatch.
|
|
625
|
+
#
|
|
626
|
+
# Forge deliberately does NOT guess the fix. The obvious guess, symlinking the source
|
|
627
|
+
# repo's node_modules into the worktree, is actively wrong in a workspace monorepo:
|
|
628
|
+
# the workspace links inside it point back at the source checkout, so the worktree
|
|
629
|
+
# silently builds against the source repo's packages instead of its own, and the
|
|
630
|
+
# resulting type errors name files the task never touched. Copying is correct but can
|
|
631
|
+
# be gigabytes per task.
|
|
632
|
+
#
|
|
633
|
+
# So the repo says what its worktrees need, and forge runs it verbatim: `.forge/setup`
|
|
634
|
+
# (executable, or any file whose contents are a shell command), or `--setup <command>`
|
|
635
|
+
# to override. It runs once per worktree, from the worktree root, only on creation —
|
|
636
|
+
# a resumed run or a retry reuses the existing worktree and does not pay it again.
|
|
637
|
+
# Always succeeds: "no setup configured" is the normal case, not a failure. Returning
|
|
638
|
+
# the status of the last test instead would make the common path report an error, and
|
|
639
|
+
# under a caller running `set -e` that aborts the task before the worktree is usable.
|
|
640
|
+
worktree_setup_command() { # worktree_setup_command <plan> <repo>
|
|
641
|
+
local PLAN="$1" REPO="$2"
|
|
642
|
+
if [ -s "$PLAN/setup_cmd" ]; then cat "$PLAN/setup_cmd"
|
|
643
|
+
elif [ -f "$REPO/.forge/setup" ]; then cat "$REPO/.forge/setup"
|
|
644
|
+
fi
|
|
645
|
+
return 0
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
# Non-fatal by design. A setup step that fails leaves a worktree the dwarf can still
|
|
649
|
+
# work in — it just has to install things itself, which is the status quo this exists
|
|
650
|
+
# to improve on. Failing the task instead would turn a slow run into a dead one.
|
|
651
|
+
run_worktree_setup() { # run_worktree_setup <plan> <repo> <wt> <tdir> <id>
|
|
652
|
+
local PLAN="$1" REPO="$2" wt="$3" tdir="$4" id="$5" cmd
|
|
653
|
+
cmd="$(worktree_setup_command "$PLAN" "$REPO")"
|
|
654
|
+
[ -n "$cmd" ] || return 0
|
|
655
|
+
if (cd "$wt" && eval "$cmd") >"$tdir/setup.out" 2>&1; then
|
|
656
|
+
note "$id: worktree setup ok"
|
|
657
|
+
else
|
|
658
|
+
note "$id: worktree setup failed (continuing) — see $tdir/setup.out"
|
|
659
|
+
fi
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
ensure_worktree() { # ensure_worktree <repo> <wt> <br> <base> <tdir> <plan> <id>
|
|
663
|
+
local REPO="$1" wt="$2" br="$3" base="$4" tdir="$5" PLAN="$6" id="$7"
|
|
664
|
+
# A worktree directory deleted by hand leaves its registration behind, and that
|
|
665
|
+
# registration then blocks `worktree add` with "already exists". Prune first.
|
|
666
|
+
(cd "$REPO" && git worktree prune >/dev/null 2>&1)
|
|
667
|
+
if [ -e "$wt/.git" ]; then
|
|
668
|
+
return 0
|
|
669
|
+
elif (cd "$REPO" && git rev-parse --verify --quiet "$br" >/dev/null 2>&1); then
|
|
670
|
+
(cd "$REPO" && git worktree add "$wt" "$br") >"$tdir/worktree.out" 2>&1
|
|
671
|
+
else
|
|
672
|
+
(cd "$REPO" && git worktree add -b "$br" "$wt" "$base") >"$tdir/worktree.out" 2>&1
|
|
673
|
+
fi || return 1
|
|
674
|
+
run_worktree_setup "$PLAN" "$REPO" "$wt" "$tdir" "$id"
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
dispatch_duration() { # dispatch_duration <tdir> <role>
|
|
678
|
+
sed -n 's/^duration_s=//p' "$1/$2.resolved" 2>/dev/null | tail -1
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
# `files` is documented as a promise, not a prediction: it decides which tasks may
|
|
682
|
+
# run concurrently and what the capsule declares off limits. Nothing used to check
|
|
683
|
+
# it, so an understated set surfaced much later as a merge CONFLICT with no stated
|
|
684
|
+
# cause. The answer is free right after the diff capture, so take it there.
|
|
685
|
+
#
|
|
686
|
+
# A warning, never a failure: a dwarf that genuinely had to touch one more file did
|
|
687
|
+
# the right thing, and failing the task for it would be worse than reporting it.
|
|
688
|
+
check_drift() { # check_drift <declared-comma-list> <tdir> <wt> <base>
|
|
689
|
+
local declared="$1" tdir="$2" wt="$3" base="$4" touched f d covered
|
|
690
|
+
: > "$tdir/drift.txt"
|
|
691
|
+
touched="$(cd "$wt" && git diff --cached --name-only "$base" -- . ":(exclude).forge" 2>/dev/null)"
|
|
692
|
+
[ -n "$touched" ] || return 0
|
|
693
|
+
for f in $touched; do
|
|
694
|
+
covered=0
|
|
695
|
+
if [ "$declared" != "-" ] && [ -n "$declared" ]; then
|
|
696
|
+
for d in $(printf '%s' "$declared" | tr ',' ' '); do
|
|
697
|
+
# Exact match, or a declared directory prefix: declaring src/routes covers
|
|
698
|
+
# src/routes/api.py, which is how the column is normally written.
|
|
699
|
+
case "$f" in
|
|
700
|
+
"$d"|"$d"/*) covered=1 ;;
|
|
701
|
+
esac
|
|
702
|
+
done
|
|
703
|
+
fi
|
|
704
|
+
[ "$covered" = 0 ] && printf '%s\n' "$f" >> "$tdir/drift.txt"
|
|
705
|
+
done
|
|
706
|
+
[ -s "$tdir/drift.txt" ] || rm -f "$tdir/drift.txt"
|
|
707
|
+
return 0
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
do_task_body() (
|
|
711
|
+
local PLAN="${1:?}" id="${2:?}"
|
|
712
|
+
local T="$PLAN/tasks.tsv" REPO run_id base wt br tdir attempt resume=""
|
|
713
|
+
local reviewed review source_fp review_fp
|
|
714
|
+
REPO="$(cat "$PLAN/repo")"; run_id="$(cat "$PLAN/run_id")"
|
|
715
|
+
tdir="$PLAN/tasks/$id"; mkdir -p "$tdir"
|
|
716
|
+
if [ -s "$PLAN/fractal-selection.json" ]; then
|
|
717
|
+
forge_fractal_select "$PLAN" "$REPO" parallel 0 || return $?
|
|
718
|
+
fi
|
|
719
|
+
forge_metric_begin "$tdir" task
|
|
720
|
+
dependencies_ready "$PLAN" "$id" || return 1
|
|
721
|
+
echo RUNNING > "$tdir/status"
|
|
722
|
+
rm -f "$tdir/noretry"
|
|
723
|
+
[ -f "$PLAN/no_memory" ] && export FORGE_MEMORY=off
|
|
724
|
+
# --timeout given to run/retry is kept in the plan and wins over the environment.
|
|
725
|
+
[ -s "$PLAN/timeout" ] && export FORGE_TIMEOUT="$(cat "$PLAN/timeout")"
|
|
726
|
+
wt="$(cat "$PLAN/wt_root")/$id"
|
|
727
|
+
br="forge/$run_id/$id"
|
|
728
|
+
# Every attempt gets a distinct number and commit message. A QA-only resume (the
|
|
729
|
+
# reviewer hit an infrastructure failure, or never reached a verdict) re-reviews work
|
|
730
|
+
# that is already committed, so it neither runs the dwarf nor spends an attempt.
|
|
731
|
+
attempt="$(cat "$tdir/attempt" 2>/dev/null || echo 0)"
|
|
732
|
+
if [ -f "$tdir/resume" ]; then
|
|
733
|
+
if resume_valid "$tdir" "$wt"; then
|
|
734
|
+
resume=qa
|
|
735
|
+
else
|
|
736
|
+
rm -f "$tdir/resume"
|
|
737
|
+
note "$id: the saved review state no longer matches the worktree — running the full task"
|
|
738
|
+
fi
|
|
739
|
+
fi
|
|
740
|
+
if [ -z "$resume" ]; then
|
|
741
|
+
attempt=$(( attempt + 1 ))
|
|
742
|
+
echo "$attempt" > "$tdir/attempt"
|
|
743
|
+
# Silent: Clef's guess at whether this retry passes with the findings it was handed.
|
|
744
|
+
# Only a real new dwarf attempt (not a QA-only resume); recorded in clef.retry_pass.<n>
|
|
745
|
+
# and the run dataset, never read by the run, and it can never fail the task.
|
|
746
|
+
if [ "$attempt" -gt 1 ] && [ -s "$tdir/retry_findings.md" ] && [ "${FORGE_JEV:-}" != off ]; then
|
|
747
|
+
python3 "$SKILL_DIR/scripts/forge-jev.py" retry-pass --plan "$PLAN" --repo "$REPO" --task "$id" \
|
|
748
|
+
--attempt "$attempt" >/dev/null 2>&1 || true
|
|
749
|
+
fi
|
|
750
|
+
fi
|
|
751
|
+
|
|
752
|
+
# The base is pinned per task on first creation and reused by every retry. The
|
|
753
|
+
# integration branch moves as later waves land, so re-reading the run-level
|
|
754
|
+
# base_ref on a retry would pull other tasks' merged code into this task's diff
|
|
755
|
+
# and put it in front of its reviewer as if this dwarf had written it.
|
|
756
|
+
if [ -s "$tdir/base_ref" ]; then
|
|
757
|
+
base="$(cat "$tdir/base_ref")"
|
|
758
|
+
else
|
|
759
|
+
base="$(git -C "$(cat "$PLAN/wt_root")/_integration" rev-parse HEAD)"; printf '%s\n' "$base" > "$tdir/base_ref"
|
|
760
|
+
fi
|
|
761
|
+
|
|
762
|
+
local dw qa yd yq rc
|
|
763
|
+
dw="$(field "$T" "$id" dwarf)"; qa="$(field "$T" "$id" qa)"
|
|
764
|
+
yd=""; yq=""
|
|
765
|
+
[ -f "$PLAN/yolo_dwarf" ] && yd="--yolo"
|
|
766
|
+
[ -f "$PLAN/yolo_qa" ] && yq="--yolo"
|
|
767
|
+
|
|
768
|
+
if ! ensure_worktree "$REPO" "$wt" "$br" "$base" "$tdir" "$PLAN" "$id"; then
|
|
769
|
+
echo ERROR > "$tdir/status"
|
|
770
|
+
note "$id: could not create worktree — $(tail -1 "$tdir/worktree.out")"
|
|
771
|
+
return 1
|
|
772
|
+
fi
|
|
773
|
+
|
|
774
|
+
if [ -z "$resume" ]; then
|
|
775
|
+
# Persist each source separately; QA receives complete context once.
|
|
776
|
+
write_capsule "$PLAN" "$id" dwarf >/dev/null
|
|
777
|
+
cp "$tdir/capsule.md" "$tdir/baseline.capsule"
|
|
778
|
+
# --task lets Jev narrow the injected slice to facts that bear on THIS task. Without
|
|
779
|
+
# it, or with Jev off, the full role slice is injected exactly as before.
|
|
780
|
+
/bin/bash "$MEMORY" inject "$REPO" dwarf --task "$tdir/prompt.md" \
|
|
781
|
+
--run-dir "$tdir" > "$tdir/dwarf.memory"
|
|
782
|
+
{
|
|
783
|
+
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"
|
|
784
|
+
echo "Implement this task in the repository. Follow the intended approach unless it is wrong; explain deviations. Run the task's requested verification and report results."
|
|
785
|
+
echo "On retry, fix the findings in the existing work; preserve parts not criticised. Explain any finding you reject."
|
|
786
|
+
echo; python3 "$SKILL_DIR/scripts/forge-contract.py" dwarf
|
|
787
|
+
echo; /bin/bash "$MEMORY" note dwarf
|
|
788
|
+
} > "$tdir/dwarf.input"
|
|
789
|
+
guard_before_dwarf "$wt" "$tdir"
|
|
790
|
+
forge_metric_phase dispatch
|
|
791
|
+
/bin/bash "$DISPATCH" dwarf "$dw" --repo "$wt" --run-dir "$tdir" \
|
|
792
|
+
--ripwire-query-file "$tdir/prompt.md" --prompt-file "$tdir/dwarf.input" $yd --output "${FORGE_OUTPUT:-summary}" >"$tdir/dwarf.out" 2>&1
|
|
793
|
+
rc=$?
|
|
794
|
+
if [ "${FORGE_OUTPUT:-summary}" = full ]; then cat "$tdir/dwarf.out"; fi
|
|
795
|
+
if [ "$rc" -ne 0 ]; then
|
|
796
|
+
case "$rc" in
|
|
797
|
+
7) echo TIMEOUT > "$tdir/status"; note "$id: dwarf hit the dispatch timeout — see $tdir/dwarf.out" ;;
|
|
798
|
+
8) infra_stop "$tdir" "$id" dwarf "$attempt"; return 8 ;;
|
|
799
|
+
*) echo ERROR > "$tdir/status"; note "$id: dwarf dispatch failed — see $tdir/dwarf.out" ;;
|
|
800
|
+
esac
|
|
801
|
+
return 1
|
|
802
|
+
fi
|
|
803
|
+
|
|
804
|
+
/bin/bash "$MEMORY" record "$REPO" --last "$tdir/dwarf.last" --role dwarf \
|
|
805
|
+
--run-id "$run_id" --task "$id" --model "$dw" --run-dir "$tdir" \
|
|
806
|
+
--duration "$(dispatch_duration "$tdir" dwarf)" >/dev/null 2>&1
|
|
807
|
+
|
|
808
|
+
guard_after_dwarf "$wt" "$tdir" "$id"
|
|
809
|
+
forge_metric_phase snapshot
|
|
810
|
+
# Capture the real diff, including new files, then commit on the task branch.
|
|
811
|
+
# Diffed against the task's pinned base rather than the branch head, so a retry
|
|
812
|
+
# hands QA the task's CUMULATIVE work. Reviewing only the fix would let the
|
|
813
|
+
# first attempt's code through unread.
|
|
814
|
+
# .forge/ is forge's own memory, rewritten in the user's tree after every run.
|
|
815
|
+
# Left in, it would reach QA as if a dwarf had written it, and any task whose
|
|
816
|
+
# files touched it would collide with every other task in the wave planner.
|
|
817
|
+
if [ -z "${FORGE_FRACTAL_RUN:-}" ] || [ "${FORGE_FRACTAL_RETRY:-}" = 1 ]; then
|
|
818
|
+
[ -s "$tdir/changes.diff" ] && mv "$tdir/changes.diff" "$tdir/changes.prev.diff"
|
|
819
|
+
fi
|
|
820
|
+
(cd "$wt" && git add -A -- . ":(exclude).forge" \
|
|
821
|
+
&& git diff --cached --binary "$base" -- . ":(exclude).forge") > "$tdir/changes.diff" 2>/dev/null || { echo ERROR > "$tdir/status"; return 1; }
|
|
822
|
+
if [ ! -s "$tdir/changes.diff" ]; then
|
|
823
|
+
# A harness that exited 0 with an empty final message and changed nothing did not
|
|
824
|
+
# decline the task; it never really ran (login, quota, a dropped connection).
|
|
825
|
+
if grep -qx 'class=empty' "$tdir/dwarf.infra" 2>/dev/null; then
|
|
826
|
+
infra_stop "$tdir" "$id" dwarf "$attempt"; return 8
|
|
827
|
+
fi
|
|
828
|
+
echo FAIL > "$tdir/status"
|
|
829
|
+
: > "$tdir/noretry"
|
|
830
|
+
echo "The dwarf produced no changes at all." > "$tdir/qa.last"
|
|
831
|
+
note "$id: dwarf produced an empty diff"; return 1
|
|
832
|
+
fi
|
|
833
|
+
if [ -s "$tdir/changes.prev.diff" ] && cmp -s "$tdir/changes.diff" "$tdir/changes.prev.diff"; then
|
|
834
|
+
echo FAIL > "$tdir/status"
|
|
835
|
+
: > "$tdir/noretry"
|
|
836
|
+
echo "The retry changed nothing: the diff is byte-identical to the previous attempt." \
|
|
837
|
+
> "$tdir/qa.last"
|
|
838
|
+
note "$id: retry produced no new changes — not re-reviewing identical code"; return 1
|
|
839
|
+
fi
|
|
840
|
+
|
|
841
|
+
if ! git -C "$wt" diff --cached --quiet "$base" -- .forge; then
|
|
842
|
+
echo FAIL > "$tdir/status"; note "$id: changed excluded Forge memory; cannot accept an unreviewed change"; return 1
|
|
843
|
+
fi
|
|
844
|
+
check_drift "$(field "$T" "$id" files)" "$tdir" "$wt" "$base"
|
|
845
|
+
if [ -s "$tdir/drift.txt" ]; then
|
|
846
|
+
note "$id: touched undeclared file(s): $(tr '\n' ' ' < "$tdir/drift.txt")"
|
|
847
|
+
fi
|
|
848
|
+
|
|
849
|
+
(cd "$wt" && git -c user.name=forge -c user.email=forge@local \
|
|
850
|
+
commit --allow-empty -q -m "forge($id) attempt $attempt: $(field "$T" "$id" title)") >/dev/null 2>&1 || {
|
|
851
|
+
echo ERROR > "$tdir/status"; return 1;
|
|
852
|
+
}
|
|
853
|
+
reviewed="$(git -C "$wt" rev-parse HEAD)" || return 1
|
|
854
|
+
git -C "$wt" diff --binary "$base" "$reviewed" -- . ':(exclude).forge' > "$tdir/committed.diff" || return 1
|
|
855
|
+
if ! cmp -s "$tdir/changes.diff" "$tdir/committed.diff" ||
|
|
856
|
+
[ "$(forge_tree "$wt")" != "$(git -C "$wt" rev-parse "$reviewed^{tree}")" ]; then
|
|
857
|
+
echo INVALIDATED > "$tdir/status"; return 1
|
|
858
|
+
fi
|
|
859
|
+
echo "$reviewed" > "$tdir/reviewed.commit"
|
|
860
|
+
else
|
|
861
|
+
reviewed="$(cat "$tdir/reviewed.commit")"
|
|
862
|
+
fi
|
|
863
|
+
review="$tdir/review-$attempt-$$"
|
|
864
|
+
forge_review_snapshot "$wt" "$base" "$reviewed" "$review" || { echo ERROR > "$tdir/status"; return 1; }
|
|
865
|
+
source_fp="$(forge_fingerprint "$wt")" || return 1
|
|
866
|
+
review_fp="$(forge_fingerprint "$review")" || return 1
|
|
867
|
+
echo "$source_fp" > "$tdir/source.fingerprint"
|
|
868
|
+
echo "$review_fp" > "$tdir/review.fingerprint"
|
|
869
|
+
|
|
870
|
+
forge_metric_phase preparation
|
|
871
|
+
local jev_loaded_qa=0 subset_cmd="" qa_thr
|
|
872
|
+
qa_thr="$(qa_threshold_for "$PLAN" "$id")"
|
|
873
|
+
|
|
874
|
+
# A SEPARATE file from .forge/verify, and opt-in. It must contain {files}, because
|
|
875
|
+
# only the project knows how its runner takes a file list -- `pytest -q {files}` works,
|
|
876
|
+
# `npm test -- {files}` needs the dashes, and `go test ./...` cannot do it at all.
|
|
877
|
+
# Absent, this whole feature stays off and nothing extra runs.
|
|
878
|
+
[ -s "$REPO/.forge/verify-subset" ] && subset_cmd="$(cat "$REPO/.forge/verify-subset")"
|
|
879
|
+
if [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
|
|
880
|
+
source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded_qa=1
|
|
881
|
+
fi
|
|
882
|
+
# Early feedback for the reviewer: run the tests this change most likely broke, in a
|
|
883
|
+
# throwaway export. Requires a configured verify command -- without one there is no
|
|
884
|
+
# runner to point at a file list, and inventing one is not this feature's job.
|
|
885
|
+
if [ -n "$subset_cmd" ] && [ "$jev_loaded_qa" = 1 ] && forge_jev_active tests; then
|
|
886
|
+
git -C "$wt" diff --name-only "$(cat "$tdir/review.base")" "$reviewed" \
|
|
887
|
+
> "$tdir/changed.txt" 2>/dev/null || : > "$tdir/changed.txt"
|
|
888
|
+
python3 "$SKILL_DIR/scripts/forge-jev.py" test-subset --repo "$wt" \
|
|
889
|
+
--commit "$reviewed" --task "$tdir/prompt.md" --command "$subset_cmd" \
|
|
890
|
+
--changed "$tdir/changed.txt" --base "$(cat "$tdir/review.base")" \
|
|
891
|
+
--run-dir "$tdir" > "$tdir/subset.jev.txt" 2>/dev/null \
|
|
892
|
+
|| : > "$tdir/subset.jev.txt"
|
|
893
|
+
fi
|
|
894
|
+
# Opt-in (plan --fail-first): do this attempt's new tests fail without its change? Read-only
|
|
895
|
+
# on the review snapshot, like the subset run above; the reviewer gets the evidence.
|
|
896
|
+
rm -f "$tdir/failfirst.json" "$tdir/failfirst.txt"
|
|
897
|
+
[ ! -f "$PLAN/fail_first" ] || forge_fail_first "$review" "$tdir" "$REPO"
|
|
898
|
+
# QA uses the dispatch-time ownership and baseline, never later merge state.
|
|
899
|
+
/bin/bash "$MEMORY" inject "$REPO" qa --task "$tdir/prompt.md" \
|
|
900
|
+
--run-dir "$tdir" > "$tdir/qa.memory"
|
|
901
|
+
{
|
|
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"
|
|
903
|
+
python3 "$SKILL_DIR/scripts/forge-contract.py" qa --part head --kind task
|
|
904
|
+
echo
|
|
905
|
+
if [ -s "$tdir/failfirst.txt" ]; then cat "$tdir/failfirst.txt"; echo; fi
|
|
906
|
+
# A subset failure is the one thing a reviewer most wants to know and cannot see
|
|
907
|
+
# from the diff. It runs in a throwaway export of the reviewed commit, so the task
|
|
908
|
+
# worktree is untouched and no fingerprint can change. It never replaces
|
|
909
|
+
# verification -- the full suite still runs at integration exactly as before.
|
|
910
|
+
if [ -s "$tdir/subset.jev.txt" ]; then
|
|
911
|
+
cat "$tdir/subset.jev.txt"
|
|
912
|
+
echo
|
|
913
|
+
echo "That is a subset chosen by relevance, not the full suite, so a pass would"
|
|
914
|
+
echo "prove nothing and is not reported. A failure here is real."
|
|
915
|
+
echo
|
|
916
|
+
fi
|
|
917
|
+
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.
|
|
921
|
+
echo "Implementer guard notes (observed by forge, not claimed by the implementer):"
|
|
922
|
+
sed 's/^/ - /' "$tdir/guard.txt"
|
|
923
|
+
echo "A promise of later work means a check may never have run; judge the diff on its own merits."
|
|
924
|
+
echo
|
|
925
|
+
fi
|
|
926
|
+
if [ -s "$tdir/drift.txt" ]; then
|
|
927
|
+
# The reviewer is already asked about scope and had no way to know the answer.
|
|
928
|
+
echo "Scope note: this task declared it would touch $(field "$T" "$id" files), and the"
|
|
929
|
+
echo "diff also changes files it did not declare:"
|
|
930
|
+
sed 's/^/ - /' "$tdir/drift.txt"
|
|
931
|
+
echo "That is not automatically wrong — judge whether each was actually necessary. It"
|
|
932
|
+
echo "matters because another task may own those files."
|
|
933
|
+
echo
|
|
934
|
+
fi
|
|
935
|
+
python3 "$SKILL_DIR/scripts/forge-contract.py" qa --part tail --kind task ${qa_thr:+--threshold "$qa_thr"}
|
|
936
|
+
echo
|
|
937
|
+
echo '```diff'; cat "$tdir/changes.diff"; echo '```'
|
|
938
|
+
echo; /bin/bash "$MEMORY" note qa
|
|
939
|
+
echo "Place any learning notes before the final FORGE_VERDICT: PASS or FORGE_VERDICT: FAIL line."
|
|
940
|
+
} > "$tdir/qa.input"
|
|
941
|
+
# Opt-in (plan --shadow-qa): a second, independent review of this same diff, to measure
|
|
942
|
+
# how often two reviewers agree. It reviews its own snapshot from its own dir under
|
|
943
|
+
# $PLAN/shadow/, and nothing in this script ever reads its result.
|
|
944
|
+
local shadow_pid=""
|
|
945
|
+
if [ -s "$PLAN/shadow_qa" ] && [ -z "${FORGE_FRACTAL_RUN:-}" ]; then
|
|
946
|
+
python3 "$SKILL_DIR/scripts/forge-shadow.py" run --plan "$PLAN" --task "$id" --worktree "$wt" \
|
|
947
|
+
--base "$base" --reviewed "$reviewed" --attempt "$attempt" --qa "$qa" --tdir "$tdir" ${yq:+--yolo} \
|
|
948
|
+
</dev/null >/dev/null 2>&1 &
|
|
949
|
+
shadow_pid=$!
|
|
950
|
+
fi
|
|
951
|
+
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
|
|
954
|
+
rc=$?
|
|
955
|
+
if [ -n "$shadow_pid" ]; then
|
|
956
|
+
# On every path below, before any status is written: the shadow learns the primary is
|
|
957
|
+
# done (which bounds how much longer it may take), and no shadow outlives its task, so
|
|
958
|
+
# a retry can never start while one is still reviewing. It can delay a task, never change it.
|
|
959
|
+
kill -USR1 "$shadow_pid" 2>/dev/null || true
|
|
960
|
+
wait "$shadow_pid" 2>/dev/null || true
|
|
961
|
+
fi
|
|
962
|
+
if [ "${FORGE_OUTPUT:-summary}" = full ]; then cat "$tdir/qa.out"; fi
|
|
963
|
+
if [ "$rc" -ne 0 ]; then
|
|
964
|
+
case "$rc" in
|
|
965
|
+
7) echo TIMEOUT > "$tdir/status"; note "$id: qa hit the dispatch timeout — see $tdir/qa.out" ;;
|
|
966
|
+
8) infra_stop "$tdir" "$id" qa "$attempt"; return 8 ;;
|
|
967
|
+
*) echo ERROR > "$tdir/status"; note "$id: qa dispatch failed — see $tdir/qa.out" ;;
|
|
968
|
+
esac
|
|
969
|
+
return 1
|
|
970
|
+
fi
|
|
971
|
+
rm -f "$tdir/resume"
|
|
972
|
+
|
|
973
|
+
forge_metric_phase verification
|
|
974
|
+
if [ "$(forge_fingerprint "$wt")" != "$source_fp" ] || [ "$(forge_fingerprint "$review")" != "$review_fp" ]; then
|
|
975
|
+
echo INVALIDATED > "$tdir/status"; note "$id: artifact changed during review"; return 1
|
|
976
|
+
fi
|
|
977
|
+
local verdict
|
|
978
|
+
verdict="$(forge_verdict "$tdir/qa.last")"
|
|
979
|
+
/bin/bash "$MEMORY" record "$REPO" --last "$tdir/qa.last" --role qa \
|
|
980
|
+
--run-id "$run_id" --task "$id" --model "$qa" --verdict "${verdict:-UNKNOWN}" \
|
|
981
|
+
--run-dir "$tdir" \
|
|
982
|
+
--duration "$(dispatch_duration "$tdir" qa)" >/dev/null 2>&1
|
|
983
|
+
|
|
984
|
+
case "$verdict" in
|
|
985
|
+
PASS) echo PASS > "$tdir/status" ;;
|
|
986
|
+
FAIL) echo FAIL > "$tdir/status" ;;
|
|
987
|
+
# No verdict means the reviewer never reached a conclusion. Treating that as a
|
|
988
|
+
# pass would merge unreviewed code, so it is fail-safe by construction.
|
|
989
|
+
*) echo UNKNOWN > "$tdir/status" ;;
|
|
990
|
+
esac
|
|
991
|
+
# 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"
|
|
994
|
+
|
|
995
|
+
# Annotate a FAIL that looks unsound. Note what this does NOT do: the status file is
|
|
996
|
+
# already written above and nothing below rewrites it. A model that could talk a
|
|
997
|
+
# reviewer out of a FAIL would produce confident PASSes on code nobody checked, which
|
|
998
|
+
# is worth less than having no reviewer at all. Only a human acts on this line.
|
|
999
|
+
if [ "$verdict" = FAIL ] && [ "$(cat "$tdir/status")" = FAIL ]; then
|
|
1000
|
+
# do_task is a subshell and does not inherit do_plan's copy, so source it here.
|
|
1001
|
+
# A missing or broken options file leaves jev_loaded=0 and this block does nothing.
|
|
1002
|
+
local jev_loaded=0 jev_rc
|
|
1003
|
+
if [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
|
|
1004
|
+
source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded=1
|
|
1005
|
+
fi
|
|
1006
|
+
if [ "$jev_loaded" = 1 ] && forge_jev_active gates; then
|
|
1007
|
+
# One request, not two: asking the same question twice costs twice and can come
|
|
1008
|
+
# back with two different answers, and the record must match what was printed.
|
|
1009
|
+
python3 "$SKILL_DIR/scripts/forge-jev.py" review-triage --review "$tdir/qa.last" \
|
|
1010
|
+
--diff "$tdir/changes.diff" --run-dir "$tdir" --json > "$tdir/qa.jev.json" 2>/dev/null
|
|
1011
|
+
jev_rc=$?
|
|
1012
|
+
if [ "$jev_rc" -eq 0 ]; then
|
|
1013
|
+
note "$id: FAIL stands, but jev flags the review — $(python3 -c '
|
|
1014
|
+
import json, sys
|
|
1015
|
+
try:
|
|
1016
|
+
data = json.load(open(sys.argv[1]))
|
|
1017
|
+
except Exception:
|
|
1018
|
+
sys.exit(0)
|
|
1019
|
+
print("; ".join(data.get("reasons") or []))
|
|
1020
|
+
' "$tdir/qa.jev.json" 2>/dev/null)"
|
|
1021
|
+
else
|
|
1022
|
+
# Exit 1 means the review looked sound; there is nothing to say and no file to keep.
|
|
1023
|
+
rm -f "$tdir/qa.jev.json" 2>/dev/null || true
|
|
1024
|
+
fi
|
|
1025
|
+
fi
|
|
1026
|
+
fi
|
|
1027
|
+
return 0
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
# Entry point for both the scheduler's `_task` and `retry`: runs the pipeline, then logs
|
|
1031
|
+
# the attempt (including infrastructure stops, which spend nothing) for later audit.
|
|
1032
|
+
do_task() {
|
|
1033
|
+
local rc
|
|
1034
|
+
do_task_body "$@"; rc=$?
|
|
1035
|
+
record_attempt "$1" "$2"
|
|
1036
|
+
# The retry policy's state (escalation.next or escalation.stop), decided now rather than
|
|
1037
|
+
# 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
|
|
1039
|
+
return $rc
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
# Merge one passing task onto the integration branch. Shared by `run` and `retry`
|
|
1044
|
+
# so a retried task lands exactly the way a first-attempt task does.
|
|
1045
|
+
merge_task() { # merge_task <plan> <id> -> 0 merged, 1 conflicted
|
|
1046
|
+
local PLAN="$1" id="$2" REPO run_id int_wt
|
|
1047
|
+
REPO="$(cat "$PLAN/repo")"; run_id="$(cat "$PLAN/run_id")"
|
|
1048
|
+
int_wt="$(cat "$PLAN/wt_root")/_integration"
|
|
1049
|
+
if [ -s "$PLAN/accepted.integration" ] && [ "$(git -C "$int_wt" rev-parse HEAD)" != "$(cat "$PLAN/accepted.integration")" ]; then
|
|
1050
|
+
echo INVALIDATED > "$PLAN/tasks/$id/status"
|
|
1051
|
+
forge_dataset_event merge --dir "$PLAN" --task "$id" --set status=INVALIDATED --set reason=integration-moved
|
|
1052
|
+
return 1
|
|
1053
|
+
fi
|
|
1054
|
+
[ -z "$(git -C "$int_wt" status --porcelain -- . ':(exclude).forge')" ] || {
|
|
1055
|
+
echo INVALIDATED > "$PLAN/tasks/$id/status"
|
|
1056
|
+
forge_dataset_event merge --dir "$PLAN" --task "$id" --set status=INVALIDATED --set reason=integration-dirty
|
|
1057
|
+
return 1;
|
|
1058
|
+
}
|
|
1059
|
+
local tdir="$PLAN/tasks/$id" reviewed wt
|
|
1060
|
+
dependencies_ready "$PLAN" "$id" || return 1
|
|
1061
|
+
reviewed="$(cat "$tdir/reviewed.commit")" || return 1
|
|
1062
|
+
wt="$(cat "$PLAN/wt_root")/$id"
|
|
1063
|
+
if [ "$(forge_fingerprint "$wt")" != "$(cat "$tdir/source.fingerprint")" ] ||
|
|
1064
|
+
[ "$(git -C "$REPO" rev-parse "forge/$run_id/$id")" != "$reviewed" ]; then
|
|
1065
|
+
echo INVALIDATED > "$tdir/status"
|
|
1066
|
+
forge_dataset_event merge --dir "$PLAN" --task "$id" --set status=INVALIDATED --set reason=task-moved
|
|
1067
|
+
return 1
|
|
1068
|
+
fi
|
|
1069
|
+
if (cd "$int_wt" && git merge --no-ff -m "forge: merge $id" "$reviewed" >/dev/null 2>&1); then
|
|
1070
|
+
git -C "$int_wt" rev-parse HEAD > "$PLAN/accepted.integration"
|
|
1071
|
+
touch "$PLAN/tasks/$id/merged"
|
|
1072
|
+
rm -f "$tdir/conflict.files"
|
|
1073
|
+
forge_dataset_event merge --dir "$PLAN" --task "$id" --set status=MERGED --set commit="$reviewed" \
|
|
1074
|
+
--set integration="$(cat "$PLAN/accepted.integration")"
|
|
1075
|
+
return 0
|
|
1076
|
+
fi
|
|
1077
|
+
# Which files collided is the label for whether two tasks were coupled, and the abort
|
|
1078
|
+
# below erases it, so it is kept first.
|
|
1079
|
+
(cd "$int_wt" && git diff --name-only --diff-filter=U) > "$tdir/conflict.files" 2>/dev/null
|
|
1080
|
+
(cd "$int_wt" && git merge --abort >/dev/null 2>&1)
|
|
1081
|
+
echo CONFLICT > "$PLAN/tasks/$id/status"
|
|
1082
|
+
forge_dataset_event merge --dir "$PLAN" --task "$id" --set status=CONFLICT --set commit="$reviewed" \
|
|
1083
|
+
--set conflicts="$(tr '\n' ' ' < "$tdir/conflict.files" 2>/dev/null)"
|
|
1084
|
+
return 1
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
# --- retry --------------------------------------------------------------------
|
|
1088
|
+
# A failed task keeps its branch and worktree "so there is something to inspect and
|
|
1089
|
+
# retry from" — this is the retry. It is a command a human types, not an automatic
|
|
1090
|
+
# repair loop, which is what keeps forge's one-dwarf-then-QA rule intact.
|
|
1091
|
+
do_retry() {
|
|
1092
|
+
local PLAN="${1:?retry needs a plan dir}" id="${2:?retry needs a task id}"; shift 2
|
|
1093
|
+
local NEWDWARF="" jev_rc=0
|
|
1094
|
+
while [ $# -gt 0 ]; do
|
|
1095
|
+
case "$1" in
|
|
1096
|
+
--no-ripwire) export FORGE_RIPWIRE=off; shift ;;
|
|
1097
|
+
--output) OUTPUT="${2:?}"; shift 2 ;;
|
|
1098
|
+
--dwarf) NEWDWARF="${2:?--dwarf needs a spec}"; shift 2 ;;
|
|
1099
|
+
--yolo-dwarf) touch "$PLAN/yolo_dwarf"; shift ;;
|
|
1100
|
+
--yolo-qa) touch "$PLAN/yolo_qa"; shift ;;
|
|
1101
|
+
--verify) printf '%s' "${2:?--verify needs a command}" > "$PLAN/verify_cmd"; shift 2 ;;
|
|
1102
|
+
--setup) printf '%s' "${2:?--setup needs a command}" > "$PLAN/setup_cmd"; shift 2 ;;
|
|
1103
|
+
*)
|
|
1104
|
+
# `case` is not a loop, so `continue` here belongs to the enclosing while.
|
|
1105
|
+
if forge_pp_flag retry "$PLAN" "$@"; then shift "$PP_SHIFT"; continue; fi
|
|
1106
|
+
if [ "$JEV_OPTS" = 1 ]; then
|
|
1107
|
+
forge_jev_flag "$1"; jev_rc=$?
|
|
1108
|
+
case "$jev_rc" in
|
|
1109
|
+
0) shift "$JEV_SHIFT"; continue ;;
|
|
1110
|
+
2) exit 2 ;;
|
|
1111
|
+
esac
|
|
1112
|
+
fi
|
|
1113
|
+
die "retry: unknown option '$1'"
|
|
1114
|
+
;;
|
|
1115
|
+
esac
|
|
1116
|
+
done
|
|
1117
|
+
[ "$JEV_OPTS" = 1 ] && forge_jev_settle "$PLAN"
|
|
1118
|
+
case "$OUTPUT" in summary|full) ;; *) die "--output must be summary or full" ;; esac
|
|
1119
|
+
export FORGE_OUTPUT="$OUTPUT"
|
|
1120
|
+
local T="$PLAN/tasks.tsv" tdir="$PLAN/tasks/$id"
|
|
1121
|
+
[ -s "$T" ] || die "no tasks.tsv in '$PLAN'"
|
|
1122
|
+
all_ids "$T" | grep -qx "$id" || die "no task '$id' in this plan"
|
|
1123
|
+
[ -s "$PLAN/wt_root" ] || die "this plan has never been run — use 'run', not 'retry'"
|
|
1124
|
+
local st; st="$(task_status "$PLAN" "$id")"
|
|
1125
|
+
[ "$st" = "MERGED" ] && die "task '$id' already merged — nothing to retry"
|
|
1126
|
+
|
|
1127
|
+
# Findings carry-forward, the optional --dwarf escalation and the QA-only resume
|
|
1128
|
+
# decision are shared with the scheduler's automatic retries.
|
|
1129
|
+
# A refusal (the retry policy found no stronger model, or the escalation could not be
|
|
1130
|
+
# written) must stop here: past this point the status is cleared and a dwarf dispatched.
|
|
1131
|
+
local prc=0
|
|
1132
|
+
prepare_retry "$PLAN" "$id" "$NEWDWARF" || prc=$?
|
|
1133
|
+
if [ "$prc" != 0 ]; then
|
|
1134
|
+
write_results "$PLAN"
|
|
1135
|
+
[ "$prc" = 3 ] && return 2
|
|
1136
|
+
return "$prc"
|
|
1137
|
+
fi
|
|
1138
|
+
[ "${FORGE_RIPWIRE:-}" != off ] || touch "$PLAN/no_ripwire"
|
|
1139
|
+
dependencies_ready "$PLAN" "$id" || { write_results "$PLAN"; return 5; }
|
|
1140
|
+
[ ! -f "$PLAN/no_ripwire" ] || export FORGE_RIPWIRE=off
|
|
1141
|
+
preflight_plan "$PLAN" || return $?
|
|
1142
|
+
/bin/bash "$SKILL_DIR/scripts/forge-install-ripwire.sh" || true
|
|
1143
|
+
rm -f "$tdir/status"
|
|
1144
|
+
|
|
1145
|
+
note "retrying $id with dwarf $(field "$T" "$id" dwarf)"
|
|
1146
|
+
export FORGE_FRACTAL_RETRY=1
|
|
1147
|
+
export FORGE_DATASET_TRIGGER=human
|
|
1148
|
+
do_task "$PLAN" "$id"
|
|
1149
|
+
st="$(task_status "$PLAN" "$id")"
|
|
1150
|
+
if [ "$st" = "INFRA" ]; then
|
|
1151
|
+
# Nothing was judged and no attempt was spent: skip verification, say how to resume.
|
|
1152
|
+
write_results "$PLAN"
|
|
1153
|
+
echo
|
|
1154
|
+
report_infra "$PLAN"
|
|
1155
|
+
return 8
|
|
1156
|
+
fi
|
|
1157
|
+
if [ "$st" = "PASS" ]; then
|
|
1158
|
+
if merge_task "$PLAN" "$id"; then
|
|
1159
|
+
note "$id PASS -> merged"
|
|
1160
|
+
else
|
|
1161
|
+
note "$id PASS but conflicted on merge — branch preserved"
|
|
1162
|
+
fi
|
|
1163
|
+
fi
|
|
1164
|
+
verify_result "$PLAN" "$(cat "$PLAN/wt_root")/_integration" "$PLAN" || { write_results "$PLAN"; run_report_extras "$PLAN"; return 5; }
|
|
1165
|
+
write_results "$PLAN"
|
|
1166
|
+
echo
|
|
1167
|
+
printf ' %-14s %-9s %s\n' id status branch
|
|
1168
|
+
awk -F'\t' '{printf " %-14s %-9s %s\n", $1, $2, $3}' "$PLAN/results.tsv"
|
|
1169
|
+
forge_jev_review_flags "$PLAN"
|
|
1170
|
+
run_report_extras "$PLAN"
|
|
1171
|
+
[ "$(task_status "$PLAN" "$id")" = "MERGED" ] || return 5
|
|
1172
|
+
return 0
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
# Print any Jev review-triage flags under the results table.
|
|
1176
|
+
#
|
|
1177
|
+
# do_task's notes go to tasks/<id>/task.out, which the scheduler only echoes when
|
|
1178
|
+
# FORGE_OUTPUT=full -- the right call for parallel dispatch, where interleaved output is
|
|
1179
|
+
# unreadable. But it meant this particular note was written where nobody would read it,
|
|
1180
|
+
# and it is the only signal that a FAIL may not be sound. A human has to see it to act
|
|
1181
|
+
# on it, and the default output mode is `summary`.
|
|
1182
|
+
#
|
|
1183
|
+
# qa.jev.json exists only when the review was flagged: review-triage deletes it when the
|
|
1184
|
+
# review looks sound, so its presence is the signal and no new file is needed.
|
|
1185
|
+
forge_jev_review_flags() {
|
|
1186
|
+
local plan="$1" id notice printed=0
|
|
1187
|
+
for id in $(all_ids "$plan/tasks.tsv"); do
|
|
1188
|
+
[ -s "$plan/tasks/$id/qa.jev.json" ] || continue
|
|
1189
|
+
notice="$(python3 -c '
|
|
1190
|
+
import json, sys
|
|
1191
|
+
try:
|
|
1192
|
+
data = json.load(open(sys.argv[1]))
|
|
1193
|
+
except Exception:
|
|
1194
|
+
sys.exit(0)
|
|
1195
|
+
print("; ".join(data.get("reasons") or []))
|
|
1196
|
+
' "$plan/tasks/$id/qa.jev.json" 2>/dev/null)"
|
|
1197
|
+
[ -n "$notice" ] || continue
|
|
1198
|
+
[ "$printed" = 0 ] && { echo; printed=1; }
|
|
1199
|
+
printf ' jev: %s — the FAIL stands; %s\n' "$id" "$notice"
|
|
1200
|
+
done
|
|
1201
|
+
[ "$printed" = 1 ] && printf ' A flagged review is not an overturned one. Read it before retrying.\n'
|
|
1202
|
+
return 0
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
# One row per execution of the verify command, with its own copy of the log: the
|
|
1206
|
+
# deterministic rerun and Jev's retry both write verification.retry.log, so without this
|
|
1207
|
+
# only the last of them would survive.
|
|
1208
|
+
verify_run_note() { # verify_run_note <out> <kind> <rc> <before> <after> <log> <t0>
|
|
1209
|
+
local out="$1" n=1
|
|
1210
|
+
[ -f "$out/verification.runs.tsv" ] && n=$(( $(wc -l < "$out/verification.runs.tsv") + 1 ))
|
|
1211
|
+
cp "$out/$6" "$out/verification.run-$n.log" 2>/dev/null || :
|
|
1212
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$n" "$2" "${3:-}" "$([ "$4" = "$5" ] && echo 0 || echo 1)" \
|
|
1213
|
+
"$(( $(date +%s) - $7 ))" "verification.run-$n.log" >> "$out/verification.runs.tsv"
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
verify_result() ( # plan, checkout, artifact directory
|
|
1217
|
+
local plan="$1" wt="$2" out="$3" cmd="" before after rc=0 repo jev_loaded=0 vretries
|
|
1218
|
+
mkdir -p "$out"
|
|
1219
|
+
forge_metric_begin "$out" verification
|
|
1220
|
+
forge_metric_phase verification
|
|
1221
|
+
repo="$(cat "$plan/repo")"
|
|
1222
|
+
rm -f "$out/verification.flaky" "$out/verification.retry.log" "$out/verification.coverage.txt" \
|
|
1223
|
+
"$out/verification.runs.tsv" "$out"/verification.run-*.log "$out/verification.commit"
|
|
1224
|
+
# The commit being verified, named before any early return, so every outcome (FAIL and
|
|
1225
|
+
# UNVERIFIED included) can be joined to the code it judged.
|
|
1226
|
+
git -C "$wt" rev-parse HEAD > "$out/verification.commit" 2>/dev/null || rm -f "$out/verification.commit"
|
|
1227
|
+
# A failed command is rerun this many times before it counts, so one flaky lane does not
|
|
1228
|
+
# cost a whole run. The source fingerprint still has to be unchanged, and a rerun that
|
|
1229
|
+
# passes is recorded loudly (verification.flaky) rather than passed off as clean.
|
|
1230
|
+
vretries="$(cat "$plan/verify_retries" 2>/dev/null)"
|
|
1231
|
+
case "$vretries" in ''|*[!0-9]*) vretries=1 ;; esac
|
|
1232
|
+
# Sourced unconditionally (cheap: local function/var definitions, no network) so
|
|
1233
|
+
# forge_jev_active is available below. verify_result is itself a subshell, so this
|
|
1234
|
+
# never leaks state into the caller. A missing or broken options file just leaves
|
|
1235
|
+
# jev_loaded=0 and every Jev branch below falls through to today's behaviour.
|
|
1236
|
+
if [ -f "$SKILL_DIR/scripts/forge-jev-options.sh" ]; then
|
|
1237
|
+
source "$SKILL_DIR/scripts/forge-jev-options.sh" 2>/dev/null && jev_loaded=1
|
|
1238
|
+
fi
|
|
1239
|
+
if [ -s "$plan/verify_cmd" ]; then cmd="$(cat "$plan/verify_cmd")"
|
|
1240
|
+
elif [ -s "$repo/.forge/verify" ]; then cmd="$(cat "$repo/.forge/verify")"; fi
|
|
1241
|
+
if [ "$(forge_tree "$wt")" != "$(git -C "$wt" rev-parse HEAD^{tree})" ]; then
|
|
1242
|
+
echo FAIL > "$out/verification.status"
|
|
1243
|
+
note "verification checkout has source changes outside its commit"; return 1
|
|
1244
|
+
fi
|
|
1245
|
+
# Only when nothing is configured: ask Jev to name a command that actually exists
|
|
1246
|
+
# in the repo. Its exit code, not its stdout text, decides whether a judgment was
|
|
1247
|
+
# produced — any non-zero exit (no key, disabled, nothing suitable, a traceback)
|
|
1248
|
+
# falls straight through to the exact UNVERIFIED behaviour below. A Jev failure
|
|
1249
|
+
# must never change the outcome of a run.
|
|
1250
|
+
if [ -z "$cmd" ] && [ "$jev_loaded" = 1 ] && forge_jev_active tests; then
|
|
1251
|
+
local jev_json jev_rc jev_cmd
|
|
1252
|
+
# --json rather than the bare form: the record exists so a human can audit why a
|
|
1253
|
+
# command ran, and "confidence: null" answers nothing. The exit code still decides.
|
|
1254
|
+
jev_json="$(python3 "$SKILL_DIR/scripts/forge-jev.py" verify-discover --repo "$repo" --json --run-dir "$out" 2>/dev/null)"
|
|
1255
|
+
jev_rc=$?
|
|
1256
|
+
jev_cmd=""
|
|
1257
|
+
if [ "$jev_rc" -eq 0 ] && [ -n "$jev_json" ]; then
|
|
1258
|
+
jev_cmd="$(printf '%s' "$jev_json" | python3 -c '
|
|
1259
|
+
import json, sys
|
|
1260
|
+
try:
|
|
1261
|
+
print((json.load(sys.stdin) or {}).get("command") or "")
|
|
1262
|
+
except ValueError:
|
|
1263
|
+
print("")
|
|
1264
|
+
' 2>/dev/null)"
|
|
1265
|
+
fi
|
|
1266
|
+
if [ -n "$jev_cmd" ]; then
|
|
1267
|
+
cmd="$jev_cmd"
|
|
1268
|
+
# Loud on purpose: an unreviewed command is about to run on the user's behalf,
|
|
1269
|
+
# and they must never discover that later from a log instead of right here.
|
|
1270
|
+
note "jev chose a verification command (no --verify or .forge/verify configured): $cmd"
|
|
1271
|
+
printf '%s' "$jev_json" | python3 -c '
|
|
1272
|
+
import json, sys
|
|
1273
|
+
out = sys.argv[1]
|
|
1274
|
+
try:
|
|
1275
|
+
found = json.load(sys.stdin) or {}
|
|
1276
|
+
except ValueError:
|
|
1277
|
+
found = {}
|
|
1278
|
+
with open(out + "/verification.jev.json", "w") as fh:
|
|
1279
|
+
json.dump({"discovered": found}, fh, indent=2, sort_keys=True)
|
|
1280
|
+
fh.write("\n")
|
|
1281
|
+
' "$out" 2>/dev/null || true
|
|
1282
|
+
fi
|
|
1283
|
+
fi
|
|
1284
|
+
verify_coverage_report "$wt" "$cmd" "$out/verification.coverage.txt"
|
|
1285
|
+
if [ -z "$cmd" ]; then
|
|
1286
|
+
echo UNVERIFIED > "$out/verification.status"
|
|
1287
|
+
note "combined result UNVERIFIED: no --verify command or .forge/verify configured"
|
|
1288
|
+
return 0
|
|
1289
|
+
fi
|
|
1290
|
+
printf '%s\n' "$cmd" > "$out/verification.command"
|
|
1291
|
+
before="$(forge_fingerprint "$wt")" || return 1
|
|
1292
|
+
local vt0; vt0="$(date +%s)"
|
|
1293
|
+
(cd "$wt" && /bin/bash -c "$cmd") > "$out/verification.log" 2>&1 || rc=$?
|
|
1294
|
+
echo "$rc" > "$out/verification.exit"
|
|
1295
|
+
after="$(forge_fingerprint "$wt")" || return 1
|
|
1296
|
+
verify_run_note "$out" initial "$rc" "$before" "$after" verification.log "$vt0"
|
|
1297
|
+
echo "$before" > "$out/verification.fingerprint"
|
|
1298
|
+
if [ "$rc" != 0 ] || [ "$before" != "$after" ]; then
|
|
1299
|
+
local flaked=0 det_retried=0 n_retry=0 rc3 b3 a3
|
|
1300
|
+
# Deterministic rerun first: the rerun itself is the evidence, so unlike Jev's triage
|
|
1301
|
+
# below it needs no model and no recorded trap. A tree change is never retried.
|
|
1302
|
+
if [ "$rc" != 0 ] && [ "$before" = "$after" ] && [ "$vretries" -gt 0 ]; then
|
|
1303
|
+
det_retried=1
|
|
1304
|
+
while [ "$n_retry" -lt "$vretries" ] && [ "$flaked" = 0 ]; do
|
|
1305
|
+
n_retry=$((n_retry+1)); rc3=0
|
|
1306
|
+
note "verification failed (exit $rc); rerunning to tell a flaky lane from a regression ($n_retry/$vretries)"
|
|
1307
|
+
b3="$(forge_fingerprint "$wt")" || return 1
|
|
1308
|
+
vt0="$(date +%s)"
|
|
1309
|
+
(cd "$wt" && /bin/bash -c "$cmd") > "$out/verification.retry.log" 2>&1 || rc3=$?
|
|
1310
|
+
a3="$(forge_fingerprint "$wt")" || return 1
|
|
1311
|
+
verify_run_note "$out" rerun "$rc3" "$b3" "$a3" verification.retry.log "$vt0"
|
|
1312
|
+
if [ "$rc3" = 0 ] && [ "$b3" = "$a3" ]; then
|
|
1313
|
+
flaked=1
|
|
1314
|
+
{
|
|
1315
|
+
echo "first_exit=$rc"; echo "retry_exit=0"; echo "retries_used=$n_retry"
|
|
1316
|
+
echo "first_log=$out/verification.log"; echo "retry_log=$out/verification.retry.log"
|
|
1317
|
+
} > "$out/verification.flaky"
|
|
1318
|
+
echo 0 > "$out/verification.exit"
|
|
1319
|
+
note "FLAKY: verification failed once (exit $rc) and passed on rerun — the first failure is in $out/verification.log; this is not a clean pass"
|
|
1320
|
+
fi
|
|
1321
|
+
done
|
|
1322
|
+
fi
|
|
1323
|
+
# A tree change is never a flake — it stays FAIL immediately, no triage. Beyond
|
|
1324
|
+
# that, act on a "known_flake" verdict ONLY when Forge's own memory already
|
|
1325
|
+
# records a `trap` for this repo: a model alone deciding a failure was a flake
|
|
1326
|
+
# is exactly how a real regression ships, so the verdict needs corroboration
|
|
1327
|
+
# from Forge's own records, not just the model's word.
|
|
1328
|
+
if [ "$rc" != 0 ] && [ "$before" = "$after" ] && [ "$jev_loaded" = 1 ] \
|
|
1329
|
+
&& [ "$det_retried" = 0 ] \
|
|
1330
|
+
&& forge_jev_active tests \
|
|
1331
|
+
&& [ -s "$repo/.forge/memory.md" ] \
|
|
1332
|
+
&& awk '/^## Known traps$/{f=1;next} /^## /{f=0} f && NF{c++} END{exit !(c>0)}' "$repo/.forge/memory.md"
|
|
1333
|
+
then
|
|
1334
|
+
local triage_json triage_rc verdict="" confidence="" flake_act="0.90" parsed corroborated=0 retried=0 rc2="" before2 after2
|
|
1335
|
+
triage_json="$(python3 "$SKILL_DIR/scripts/forge-jev.py" verify-triage --repo "$repo" \
|
|
1336
|
+
--command "$cmd" --log "$out/verification.log" --json --run-dir "$out" 2>/dev/null)"
|
|
1337
|
+
triage_rc=$?
|
|
1338
|
+
if [ "$triage_rc" -eq 0 ] && [ -n "$triage_json" ]; then
|
|
1339
|
+
parsed="$(printf '%s' "$triage_json" | python3 -c '
|
|
1340
|
+
import json, sys
|
|
1341
|
+
try:
|
|
1342
|
+
data = json.load(sys.stdin)
|
|
1343
|
+
except ValueError:
|
|
1344
|
+
data = {}
|
|
1345
|
+
print(data.get("verdict") or "")
|
|
1346
|
+
c = data.get("confidence")
|
|
1347
|
+
print(c if c is not None else "")
|
|
1348
|
+
print(bool(data.get("corroborated")))
|
|
1349
|
+
' 2>/dev/null)"
|
|
1350
|
+
verdict="$(printf '%s\n' "$parsed" | sed -n 1p)"
|
|
1351
|
+
confidence="$(printf '%s\n' "$parsed" | sed -n 2p)"
|
|
1352
|
+
# triage() reports whether a recorded trap shares a distinctive token with this
|
|
1353
|
+
# failure's output. Without that, one flaky test noted months ago would excuse
|
|
1354
|
+
# every future failure in the repo.
|
|
1355
|
+
[ "$(printf '%s\n' "$parsed" | sed -n 3p)" = "True" ] && corroborated=1 || corroborated=0
|
|
1356
|
+
if [ -n "$FORGE_JEV_STATUS_JSON" ]; then
|
|
1357
|
+
local threshold_read
|
|
1358
|
+
threshold_read="$(printf '%s' "$FORGE_JEV_STATUS_JSON" | python3 -c '
|
|
1359
|
+
import json, sys
|
|
1360
|
+
try:
|
|
1361
|
+
data = json.load(sys.stdin)
|
|
1362
|
+
except ValueError:
|
|
1363
|
+
data = {}
|
|
1364
|
+
v = (data.get("thresholds") or {}).get("flake_act")
|
|
1365
|
+
print(v if v is not None else "0.90")
|
|
1366
|
+
' 2>/dev/null)"
|
|
1367
|
+
[ -n "$threshold_read" ] && flake_act="$threshold_read"
|
|
1368
|
+
fi
|
|
1369
|
+
if [ "$verdict" = known_flake ] && [ -n "$confidence" ] && [ "$corroborated" = 1 ] \
|
|
1370
|
+
&& awk -v a="$confidence" -v b="$flake_act" 'BEGIN{exit !(a+0 >= b+0)}'
|
|
1371
|
+
then
|
|
1372
|
+
retried=1
|
|
1373
|
+
note "jev: known_flake (confidence $confidence >= $flake_act), corroborated by a recorded trap — retrying once"
|
|
1374
|
+
before2="$(forge_fingerprint "$wt")" || return 1
|
|
1375
|
+
vt0="$(date +%s)"
|
|
1376
|
+
(cd "$wt" && /bin/bash -c "$cmd") > "$out/verification.retry.log" 2>&1 || rc2=$?
|
|
1377
|
+
rc2="${rc2:-0}"
|
|
1378
|
+
after2="$(forge_fingerprint "$wt")" || return 1
|
|
1379
|
+
verify_run_note "$out" jev_retry "$rc2" "$before2" "$after2" verification.retry.log "$vt0"
|
|
1380
|
+
if [ "$rc2" = 0 ] && [ "$before2" = "$after2" ]; then
|
|
1381
|
+
flaked=1
|
|
1382
|
+
note "jev: retry passed — treating as a corroborated flake, not a regression"
|
|
1383
|
+
else
|
|
1384
|
+
note "jev: retry also failed (exit $rc2) — keeping FAIL"
|
|
1385
|
+
fi
|
|
1386
|
+
fi
|
|
1387
|
+
python3 -c '
|
|
1388
|
+
import json, sys
|
|
1389
|
+
out, verdict, confidence, threshold, corroborated, retried, first_rc, retry_rc = sys.argv[1:9]
|
|
1390
|
+
path = out + "/verification.jev.json"
|
|
1391
|
+
try:
|
|
1392
|
+
with open(path) as fh:
|
|
1393
|
+
data = json.load(fh)
|
|
1394
|
+
except (OSError, ValueError):
|
|
1395
|
+
data = {}
|
|
1396
|
+
data["triage"] = {
|
|
1397
|
+
"verdict": verdict or None,
|
|
1398
|
+
"confidence": float(confidence) if confidence else None,
|
|
1399
|
+
"flake_act_threshold": float(threshold),
|
|
1400
|
+
"trap_corroboration": corroborated == "1",
|
|
1401
|
+
"retried": retried == "1",
|
|
1402
|
+
"first_exit": int(first_rc),
|
|
1403
|
+
"retry_exit": int(retry_rc) if retry_rc != "" else None,
|
|
1404
|
+
}
|
|
1405
|
+
with open(path, "w") as fh:
|
|
1406
|
+
json.dump(data, fh, indent=2, sort_keys=True)
|
|
1407
|
+
fh.write("\n")
|
|
1408
|
+
' "$out" "$verdict" "$confidence" "$flake_act" "$corroborated" "$retried" "$rc" "$rc2" 2>/dev/null || true
|
|
1409
|
+
fi
|
|
1410
|
+
fi
|
|
1411
|
+
if [ "$flaked" = 1 ]; then
|
|
1412
|
+
echo PASS > "$out/verification.status"
|
|
1413
|
+
return 0
|
|
1414
|
+
fi
|
|
1415
|
+
echo FAIL > "$out/verification.status"
|
|
1416
|
+
note "combined verification failed or changed source; see $out/verification.log"
|
|
1417
|
+
return 1
|
|
1418
|
+
fi
|
|
1419
|
+
echo PASS > "$out/verification.status"
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
write_results() { # write_results <plan>
|
|
1423
|
+
local PLAN="$1" T="$1/tasks.tsv" run_id id
|
|
1424
|
+
run_id="$(cat "$PLAN/run_id")"
|
|
1425
|
+
: > "$PLAN/results.tsv"
|
|
1426
|
+
for id in $(all_ids "$T"); do
|
|
1427
|
+
printf '%s\t%s\tforge/%s/%s\n' "$id" "$(task_status "$PLAN" "$id")" "$run_id" "$id" >> "$PLAN/results.tsv"
|
|
1428
|
+
done
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
# --- run ----------------------------------------------------------------------
|
|
1432
|
+
do_run() (
|
|
1433
|
+
local PLAN="${1:?run needs a plan dir}"; shift
|
|
1434
|
+
local fractal_pipeline_args=("$@")
|
|
1435
|
+
local MAXP=3 DRY=0 jev_rc=0
|
|
1436
|
+
while [ $# -gt 0 ]; do
|
|
1437
|
+
case "$1" in
|
|
1438
|
+
--fractal) forge_fractal_flag on || exit $?; shift ;;
|
|
1439
|
+
--fractal-auto-decompose) forge_fractal_flag on || exit $?; FRACTAL_ARGS+=("$1"); shift ;;
|
|
1440
|
+
--no-fractal) forge_fractal_flag off || exit $?; shift ;;
|
|
1441
|
+
--fractal-*) FRACTAL_ARGS+=("$1" "${2:?}"); shift 2 ;;
|
|
1442
|
+
--no-ripwire) export FORGE_RIPWIRE=off; shift ;;
|
|
1443
|
+
--output) OUTPUT="${2:?}"; shift 2 ;;
|
|
1444
|
+
--max-parallel) MAXP="${2:?}"; shift 2 ;;
|
|
1445
|
+
--yolo-dwarf) touch "$PLAN/yolo_dwarf"; shift ;;
|
|
1446
|
+
--yolo-qa) touch "$PLAN/yolo_qa"; shift ;;
|
|
1447
|
+
--verify) printf '%s' "${2:?--verify needs a command}" > "$PLAN/verify_cmd"; shift 2 ;;
|
|
1448
|
+
--setup) printf '%s' "${2:?--setup needs a command}" > "$PLAN/setup_cmd"; shift 2 ;;
|
|
1449
|
+
--dry-run) DRY=1; shift ;;
|
|
1450
|
+
*)
|
|
1451
|
+
# `case` is not a loop, so `continue` here belongs to the enclosing while.
|
|
1452
|
+
if forge_pp_flag run "$PLAN" "$@"; then shift "$PP_SHIFT"; continue; fi
|
|
1453
|
+
if [ "$JEV_OPTS" = 1 ]; then
|
|
1454
|
+
forge_jev_flag "$1"; jev_rc=$?
|
|
1455
|
+
case "$jev_rc" in
|
|
1456
|
+
0) shift "$JEV_SHIFT"; continue ;;
|
|
1457
|
+
2) exit 2 ;;
|
|
1458
|
+
esac
|
|
1459
|
+
fi
|
|
1460
|
+
die "run: unknown option '$1'"
|
|
1461
|
+
;;
|
|
1462
|
+
esac
|
|
1463
|
+
done
|
|
1464
|
+
[ "$JEV_OPTS" = 1 ] && forge_jev_settle "$PLAN"
|
|
1465
|
+
case "$OUTPUT" in summary|full) ;; *) die "--output must be summary or full" ;; esac
|
|
1466
|
+
export FORGE_OUTPUT="$OUTPUT"
|
|
1467
|
+
case "$MAXP" in ''|*[!0-9]*) die "--max-parallel must be positive" ;; esac
|
|
1468
|
+
[ "$MAXP" -gt 0 ] || die "--max-parallel must be positive"
|
|
1469
|
+
local T="$PLAN/tasks.tsv" W="$PLAN/waves.tsv"
|
|
1470
|
+
[ -s "$W" ] || die "no waves.tsv — run the plan subcommand first"
|
|
1471
|
+
grep -q 'UNASSIGNED' "$T" && die "some tasks are UNASSIGNED — assign a dwarf before running"
|
|
1472
|
+
|
|
1473
|
+
compute_waves "$PLAN"
|
|
1474
|
+
local REPO run_id wt_root int_wt int_br
|
|
1475
|
+
REPO="$(cat "$PLAN/repo")"; run_id="$(cat "$PLAN/run_id")"
|
|
1476
|
+
if [ "$DRY" != 1 ]; then
|
|
1477
|
+
# The concurrency limit this run was given, for forge-ui to draw; a later run rewrites it.
|
|
1478
|
+
printf '%s\n' "$MAXP" > "$PLAN/max_parallel"
|
|
1479
|
+
# Plan registers too; this also picks up plans made before it did.
|
|
1480
|
+
forge_ui_register "$PLAN"
|
|
1481
|
+
fi
|
|
1482
|
+
forge_fractal_parallel_run "$PLAN" "$REPO" "$DRY" ${fractal_pipeline_args[@]+"${fractal_pipeline_args[@]}"} || return $?
|
|
1483
|
+
# Durable, beside the repo — never TMPDIR. A temp worktree root has destroyed
|
|
1484
|
+
# real work here before: isolated worktrees are never pushed, so when the root
|
|
1485
|
+
# goes, the only copy of that work goes with it.
|
|
1486
|
+
wt_root="$(cd "$REPO/.." && pwd)/.forge-worktrees/$run_id"
|
|
1487
|
+
echo "$wt_root" > "$PLAN/wt_root"
|
|
1488
|
+
# Sibling, not parent: git refs are files, so a branch `forge/<run>` makes
|
|
1489
|
+
# `forge/<run>/<task>` impossible ("cannot lock ref ... exists; cannot create").
|
|
1490
|
+
int_br="forge/$run_id-integration"; int_wt="$wt_root/_integration"
|
|
1491
|
+
|
|
1492
|
+
if [ "$DRY" = 1 ]; then
|
|
1493
|
+
echo "worktree root: $wt_root"
|
|
1494
|
+
echo "integration: $int_br"
|
|
1495
|
+
local id
|
|
1496
|
+
for id in $(awk -F'\t' '{print $2}' "$W"); do
|
|
1497
|
+
echo " $id -> $wt_root/$id (branch forge/$run_id/$id)"
|
|
1498
|
+
echo " dwarf: $DISPATCH dwarf $(field "$T" "$id" dwarf) --repo $wt_root/$id"
|
|
1499
|
+
echo " qa : $DISPATCH qa $(field "$T" "$id" qa) --repo $wt_root/$id"
|
|
1500
|
+
done
|
|
1501
|
+
echo "(dry run — nothing created or dispatched)"
|
|
1502
|
+
return 0
|
|
1503
|
+
fi
|
|
1504
|
+
|
|
1505
|
+
forge_metric_begin "$PLAN" parallel
|
|
1506
|
+
forge_metric_phase preflight
|
|
1507
|
+
[ "${FORGE_RIPWIRE:-}" != off ] || touch "$PLAN/no_ripwire"
|
|
1508
|
+
[ ! -f "$PLAN/no_ripwire" ] || export FORGE_RIPWIRE=off
|
|
1509
|
+
preflight_plan "$PLAN" || return $?
|
|
1510
|
+
/bin/bash "$SKILL_DIR/scripts/forge-install-ripwire.sh" || true
|
|
1511
|
+
forge_metric_phase preparation
|
|
1512
|
+
mkdir -p "$wt_root"
|
|
1513
|
+
# A branch named exactly forge/<run-id> would occupy refs/heads/forge/<run-id> as a
|
|
1514
|
+
# file and make every task branch below it impossible to create. Catch it here with
|
|
1515
|
+
# an actionable message rather than letting all N tasks fail identically at dispatch.
|
|
1516
|
+
if (cd "$REPO" && git rev-parse --verify --quiet "forge/$run_id" >/dev/null 2>&1); then
|
|
1517
|
+
die "branch 'forge/$run_id' blocks task branches 'forge/$run_id/<task>' (git refs are files, so a ref cannot also be a directory). Delete or rename it: git branch -D forge/$run_id" 3
|
|
1518
|
+
fi
|
|
1519
|
+
if ! (cd "$REPO" && git rev-parse --verify "$int_br" >/dev/null 2>&1); then
|
|
1520
|
+
(cd "$REPO" && git branch "$int_br" HEAD) || die "could not create $int_br" 3
|
|
1521
|
+
# Where the integration branch started, so a whole-run review can diff exactly what
|
|
1522
|
+
# this run added. Plans created before this file existed fall back to a merge-base.
|
|
1523
|
+
git -C "$REPO" rev-parse HEAD > "$PLAN/integration.base"
|
|
1524
|
+
fi
|
|
1525
|
+
if [ ! -d "$int_wt" ]; then
|
|
1526
|
+
(cd "$REPO" && git worktree add "$int_wt" "$int_br" >/dev/null 2>&1) \
|
|
1527
|
+
|| die "could not create integration worktree" 3
|
|
1528
|
+
# The integration worktree is where a merged result gets validated, so it needs
|
|
1529
|
+
# the same setup the task worktrees get. Without it, the one checkout holding
|
|
1530
|
+
# every task's merged work is the one checkout that cannot build or test it.
|
|
1531
|
+
mkdir -p "$PLAN/tasks/_integration"
|
|
1532
|
+
run_worktree_setup "$PLAN" "$REPO" "$int_wt" "$PLAN/tasks/_integration" _integration
|
|
1533
|
+
fi
|
|
1534
|
+
|
|
1535
|
+
if [ -s "$PLAN/accepted.integration" ] && [ "$(git -C "$int_wt" rev-parse HEAD)" != "$(cat "$PLAN/accepted.integration")" ]; then
|
|
1536
|
+
die "integration branch changed outside this run" 3
|
|
1537
|
+
fi
|
|
1538
|
+
git -C "$int_wt" rev-parse HEAD > "$PLAN/accepted.integration"
|
|
1539
|
+
local id failed=0
|
|
1540
|
+
# Say now, before any model is paid for, which suites the verify command will not run.
|
|
1541
|
+
verify_coverage_report "$REPO" "$(verify_command_for "$PLAN" "$REPO")" "$PLAN/verification.coverage.txt"
|
|
1542
|
+
local sched_rc=0 paused=0 sched_args=()
|
|
1543
|
+
[ "$PP_RETRY_FAILED" -gt 0 ] && sched_args+=(--retry-failed "$PP_RETRY_FAILED")
|
|
1544
|
+
[ "$PP_INFRA_RETRIES" -gt 0 ] && sched_args+=(--infra-retries "$PP_INFRA_RETRIES")
|
|
1545
|
+
forge_metric_phase dispatch
|
|
1546
|
+
python3 "$SKILL_DIR/scripts/forge-schedule.py" "$SELF" "$PLAN" "$MAXP" ${sched_args[@]+"${sched_args[@]}"} || sched_rc=$?
|
|
1547
|
+
[ "$sched_rc" != 0 ] && failed=1
|
|
1548
|
+
[ "$sched_rc" = 8 ] && paused=1
|
|
1549
|
+
|
|
1550
|
+
forge_metric_phase verification
|
|
1551
|
+
# An infrastructure pause leaves the run unfinished, so there is nothing yet worth
|
|
1552
|
+
# verifying; the next `run` resumes and verifies at its end.
|
|
1553
|
+
if [ "$paused" = 0 ]; then
|
|
1554
|
+
verify_result "$PLAN" "$int_wt" "$PLAN" || failed=1
|
|
1555
|
+
fi
|
|
1556
|
+
write_results "$PLAN"
|
|
1557
|
+
# Only prune worktrees for work that is safely merged, and only after the record
|
|
1558
|
+
# of it has been written and read back. A failed task keeps its worktree and
|
|
1559
|
+
# branch so there is something to inspect and retry from.
|
|
1560
|
+
if [ -s "$PLAN/results.tsv" ]; then
|
|
1561
|
+
for id in $(awk -F'\t' '$2=="MERGED" {print $1}' "$PLAN/results.tsv"); do
|
|
1562
|
+
(cd "$REPO" && git worktree remove --force "$wt_root/$id" >/dev/null 2>&1)
|
|
1563
|
+
done
|
|
1564
|
+
fi
|
|
1565
|
+
|
|
1566
|
+
echo
|
|
1567
|
+
echo "results (integration branch: $int_br)"
|
|
1568
|
+
echo "artifacts: $PLAN/tasks/<id>/{dwarf,qa}.{last,log}; results: $PLAN/results.tsv"
|
|
1569
|
+
printf ' %-14s %-9s %s\n' id status branch
|
|
1570
|
+
awk -F'\t' '{printf " %-14s %-9s %s\n", $1, $2, $3}' "$PLAN/results.tsv"
|
|
1571
|
+
forge_jev_review_flags "$PLAN"
|
|
1572
|
+
echo
|
|
1573
|
+
run_report_extras "$PLAN"
|
|
1574
|
+
|
|
1575
|
+
# Undeclared files are reported here rather than buried in a task log, because
|
|
1576
|
+
# this is the list that explains any CONFLICT above.
|
|
1577
|
+
local drift=0
|
|
1578
|
+
for id in $(all_ids "$T"); do
|
|
1579
|
+
if [ -s "$PLAN/tasks/$id/drift.txt" ]; then
|
|
1580
|
+
[ "$drift" = 0 ] && echo "scope drift (touched files the task did not declare):"
|
|
1581
|
+
drift=1
|
|
1582
|
+
printf ' %-14s %s\n' "$id" "$(tr '\n' ' ' < "$PLAN/tasks/$id/drift.txt")"
|
|
1583
|
+
fi
|
|
1584
|
+
done
|
|
1585
|
+
[ "$drift" = 1 ] && echo
|
|
1586
|
+
|
|
1587
|
+
# The retry hint is for tasks that actually failed; a run that only failed verification,
|
|
1588
|
+
# or only paused on infrastructure, has nothing to retry task by task.
|
|
1589
|
+
if [ "$(awk -F'\t' '$2!="MERGED" && $2!="INFRA" && $2!="PENDING" && $2!="BLOCKED" {n++} END {print n+0}' "$PLAN/results.tsv")" -gt 0 ]; then
|
|
1590
|
+
echo "retry a failed task with its reviewer's findings:"
|
|
1591
|
+
echo " forge-parallel.sh retry $PLAN <task-id> [--dwarf <spec>]"
|
|
1592
|
+
echo "or let a run retry them itself: forge-parallel.sh run $PLAN --retry-failed 1"
|
|
1593
|
+
echo
|
|
1594
|
+
fi
|
|
1595
|
+
if [ "$failed" = 1 ] && [ "$paused" = 0 ] && [ "$(cat "$PLAN/verification.status" 2>/dev/null)" = FAIL ]; then
|
|
1596
|
+
echo "combined verification failed: see $PLAN/verification.log"
|
|
1597
|
+
echo
|
|
1598
|
+
fi
|
|
1599
|
+
|
|
1600
|
+
if [ ! -f "$PLAN/no_memory" ]; then
|
|
1601
|
+
/bin/bash "$MEMORY" spend "$REPO" --run "$run_id" 2>/dev/null | sed 's/^/ /'
|
|
1602
|
+
echo
|
|
1603
|
+
fi
|
|
1604
|
+
if [ ! -f "$PLAN/no_memory" ] && [ -s "$REPO/.forge/memory.md" ]; then
|
|
1605
|
+
echo "project memory: $(grep -c '^- ' "$REPO/.forge/memory.md") fact(s) in .forge/ —"
|
|
1606
|
+
echo " uncommitted, and the only thing forge wrote outside its own branches."
|
|
1607
|
+
echo
|
|
1608
|
+
echo "Your branch and working tree were not touched, apart from .forge/ above."
|
|
1609
|
+
else
|
|
1610
|
+
echo "Your branch and working tree were not touched."
|
|
1611
|
+
fi
|
|
1612
|
+
[ "$paused" = 1 ] && return 8
|
|
1613
|
+
[ "$failed" = 1 ] && return 5
|
|
1614
|
+
return 0
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
# --- integrate ----------------------------------------------------------------
|
|
1618
|
+
do_integrate() {
|
|
1619
|
+
local PLAN="${1:?}"; shift
|
|
1620
|
+
local APPROVED=0
|
|
1621
|
+
while [ $# -gt 0 ]; do
|
|
1622
|
+
case "$1" in
|
|
1623
|
+
--approved) APPROVED=1; shift ;;
|
|
1624
|
+
*) if forge_pp_flag integrate "$PLAN" "$@"; then shift "$PP_SHIFT"; else die "integrate: unknown option '$1'"; fi ;;
|
|
1625
|
+
esac
|
|
1626
|
+
done
|
|
1627
|
+
[ "$APPROVED" = 1 ] || die "integrate needs --approved: it merges forge's work into YOUR current branch"
|
|
1628
|
+
report_unmerged "$PLAN"
|
|
1629
|
+
local REPO run_id int_br cur
|
|
1630
|
+
REPO="$(cat "$PLAN/repo")"; run_id="$(cat "$PLAN/run_id")"; int_br="forge/$run_id-integration"
|
|
1631
|
+
cur="$(cd "$REPO" && git rev-parse --abbrev-ref HEAD)"
|
|
1632
|
+
[ "$cur" = HEAD ] && die "integration requires a checked-out user branch" 3
|
|
1633
|
+
[ "$cur" = "$int_br" ] && die "you are already on $int_br"
|
|
1634
|
+
[ -n "$(cd "$REPO" && git status --porcelain)" ] && die "working tree is dirty — commit or stash before integrating"
|
|
1635
|
+
# Verify the actual combined candidate, including changes on the user's branch.
|
|
1636
|
+
local parent target candidate candidate_sha out
|
|
1637
|
+
parent="$(git -C "$REPO" rev-parse HEAD)" || return 3
|
|
1638
|
+
target="$(git -C "$REPO" rev-parse "$int_br")" || return 3
|
|
1639
|
+
[ -s "$PLAN/accepted.integration" ] && [ "$target" = "$(cat "$PLAN/accepted.integration")" ] || die "integration revision does not match the accepted run" 3
|
|
1640
|
+
out="$PLAN/integrate-$(date +%s)-$$"; mkdir -p "$out"
|
|
1641
|
+
candidate="$(cat "$PLAN/wt_root")/_candidate-$$"
|
|
1642
|
+
git -C "$REPO" worktree add --detach "$candidate" "$parent" > "$out/setup.log" 2>&1 || return 3
|
|
1643
|
+
if ! git -C "$candidate" merge --no-ff -m "forge: integrate run $run_id" "$target" > "$out/merge.log" 2>&1; then
|
|
1644
|
+
forge_dataset_event integrate_conflict --dir "$PLAN" --set branch="$cur" --set parent="$parent" \
|
|
1645
|
+
--set target="$target" --set conflicts="$(git -C "$candidate" diff --name-only --diff-filter=U 2>/dev/null | tr '\n' ' ')" \
|
|
1646
|
+
--file merge_log="$out/merge.log"
|
|
1647
|
+
note "candidate merge conflicted; inspect $candidate and $out/merge.log"; return 6
|
|
1648
|
+
fi
|
|
1649
|
+
run_worktree_setup "$PLAN" "$REPO" "$candidate" "$out" integration-candidate
|
|
1650
|
+
verify_result "$PLAN" "$candidate" "$out" || { report_verify_notes "$out"; return 5; }
|
|
1651
|
+
candidate_sha="$(git -C "$candidate" rev-parse HEAD)" || return 3
|
|
1652
|
+
report_verify_notes "$out"
|
|
1653
|
+
report_known_issues "$PLAN"
|
|
1654
|
+
# Opt-in whole-run review of the combined candidate, after verification and before the
|
|
1655
|
+
# user's branch moves. Per-task review only ever saw one slice at a time.
|
|
1656
|
+
if [ -n "$PP_FINAL_REVIEW" ]; then
|
|
1657
|
+
declare -F final_review_for_integrate >/dev/null || die "the final review is not available in this build" 3
|
|
1658
|
+
final_review_for_integrate "$PLAN" "$parent" "$candidate_sha" "$candidate" "$out" "$PP_FINAL_REVIEW" || return $?
|
|
1659
|
+
fi
|
|
1660
|
+
[ "$(git -C "$REPO" rev-parse --abbrev-ref HEAD)" = "$cur" ] &&
|
|
1661
|
+
[ "$(git -C "$REPO" rev-parse HEAD)" = "$parent" ] &&
|
|
1662
|
+
[ "$(git -C "$REPO" rev-parse "$int_br")" = "$target" ] &&
|
|
1663
|
+
[ -z "$(git -C "$REPO" status --porcelain)" ] || die "branch or working tree changed during verification" 3
|
|
1664
|
+
git -C "$REPO" merge --ff-only "$candidate_sha" || return 6
|
|
1665
|
+
# The user's branch has moved: the run shipped. Recorded now, because the cleanup below
|
|
1666
|
+
# can still fail and make the command's exit code say otherwise.
|
|
1667
|
+
forge_dataset_event integrate_advanced --dir "$PLAN" --set branch="$cur" --set parent="$parent" \
|
|
1668
|
+
--set candidate="$candidate_sha" --set target="$target" --set verification="$(cat "$out/verification.status" 2>/dev/null)"
|
|
1669
|
+
git -C "$REPO" worktree remove "$candidate" || return 3
|
|
1670
|
+
echo "merged candidate of $int_br into $cur (verification: $(cat "$out/verification.status"))"
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
# --- main ---------------------------------------------------------------------
|
|
1674
|
+
if [ "${1:-}" = --help ] || [ "${1:-}" = -h ]; then
|
|
1675
|
+
awk '/^# Usage:/ {on=1} /^set -uo pipefail/ {on=0} on {sub(/^# ?/, ""); print}' "$SELF"
|
|
1676
|
+
forge_fractal_help
|
|
1677
|
+
exit 0
|
|
1678
|
+
fi
|
|
1679
|
+
[ $# -ge 1 ] || die "usage: forge-parallel.sh <plan|run|retry|accept|review|integrate|split|combine> <plan-dir> [...]"
|
|
1680
|
+
CMD="$1"; shift
|
|
1681
|
+
[ ! -f "${1:-}/no_ripwire" ] || export FORGE_RIPWIRE=off
|
|
1682
|
+
if [ -d "${1:-}" ]; then
|
|
1683
|
+
PLAN_PATH="$(cd "$1" && pwd)"; shift; set -- "$PLAN_PATH" "$@"
|
|
1684
|
+
fi
|
|
1685
|
+
case "$CMD" in
|
|
1686
|
+
# `combine` takes a plan dir that does not exist yet and locks its sources itself.
|
|
1687
|
+
run|retry|integrate|accept|review|split)
|
|
1688
|
+
if [ "${FORGE_PLAN_LOCK:-}" != "${1:-}" ]; then
|
|
1689
|
+
exec python3 "$SKILL_DIR/scripts/forge-schedule.py" lock "$SELF" "$CMD" "$@"
|
|
1690
|
+
fi ;;
|
|
1691
|
+
esac
|
|
1692
|
+
# Every user-facing subcommand leaves one record in the run dataset, whatever its exit
|
|
1693
|
+
# path (die included). Installed after the lock re-exec, so it fires once, in the process
|
|
1694
|
+
# that did the work; `_task` and `_merge` are parts of a `run` and are not recorded alone.
|
|
1695
|
+
case "$CMD" in
|
|
1696
|
+
plan|run|retry|integrate|accept|review|split|combine)
|
|
1697
|
+
FORGE_DATASET_INVOCATION="inv-$(date +%s)-$$-$RANDOM"; export FORGE_DATASET_INVOCATION
|
|
1698
|
+
FORGE_DATASET_STARTED="$(date +%s)"
|
|
1699
|
+
FORGE_DATASET_ARGV=("$@")
|
|
1700
|
+
trap 'forge_dataset_rc=$?; trap - EXIT; forge_dataset_invocation "$CMD" "$forge_dataset_rc" "$FORGE_DATASET_STARTED" ${FORGE_DATASET_ARGV[@]+"${FORGE_DATASET_ARGV[@]}"}; exit "$forge_dataset_rc"' EXIT ;;
|
|
1701
|
+
esac
|
|
1702
|
+
# Subcommands implemented in the optional libraries fail clearly if the library is absent.
|
|
1703
|
+
need_fn() { declare -F "$1" >/dev/null || die "'$CMD' is not available: $1 is missing" 3; }
|
|
1704
|
+
case "$CMD" in
|
|
1705
|
+
plan) do_plan "$@"; rc=$?; forge_update_notice; exit "$rc" ;;
|
|
1706
|
+
run) do_run "$@"; rc=$?; forge_update_notice; exit "$rc" ;;
|
|
1707
|
+
retry) do_retry "$@" ;;
|
|
1708
|
+
integrate) do_integrate "$@" ;;
|
|
1709
|
+
accept) need_fn do_accept; do_accept "$@" ;;
|
|
1710
|
+
review) need_fn do_review; do_review "$@" ;;
|
|
1711
|
+
split) need_fn do_split; do_split "$@" ;;
|
|
1712
|
+
combine) need_fn do_combine; do_combine "$@" ;;
|
|
1713
|
+
_task) do_task "$@" ;;
|
|
1714
|
+
_merge) merge_task "$@" ;;
|
|
1715
|
+
_prepare_retry) retry_for_scheduler "$@" ;;
|
|
1716
|
+
*) die "unknown subcommand '$CMD'" ;;
|
|
1717
|
+
esac
|