@giamme/forge 0.8.0 → 0.10.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 +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -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 +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -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 +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -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 +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -51,6 +51,96 @@ resume_valid() { # resume_valid <tdir> <wt>
|
|
|
51
51
|
[ "$(forge_fingerprint "$wt")" = "$(cat "$tdir/source.fingerprint")" ]
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
# The next entry's kind, decided before it starts with the test do_task_body applies
|
|
55
|
+
# (resume_valid): true when it only re-runs QA. A marker the worktree no longer matches is
|
|
56
|
+
# cleared here, so whoever prepares the entry and do_task_body agree that it is a full one.
|
|
57
|
+
resume_check() { # resume_check <plan> <id>
|
|
58
|
+
local tdir="$1/tasks/$2" root
|
|
59
|
+
[ -f "$tdir/resume" ] || return 1
|
|
60
|
+
root="$(cat "$1/wt_root" 2>/dev/null)"
|
|
61
|
+
[ -n "$root" ] && resume_valid "$tdir" "$root/$2" && return 0
|
|
62
|
+
rm -f "$tdir/resume"
|
|
63
|
+
note "$2: the saved review state no longer matches the worktree — the next attempt runs the full task"
|
|
64
|
+
return 1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# --- escalation pin -------------------------------------------------------------
|
|
68
|
+
# A human's one-shot choice of the model for a task's next full retry:
|
|
69
|
+
# tasks/<id>/escalation.pin holds one dwarf spec (written by `forge control pin`). The
|
|
70
|
+
# retry claims it with an atomic rename so a writer replacing it meanwhile never races the
|
|
71
|
+
# read, and settles the claim once tasks.tsv and escalations.tsv are written.
|
|
72
|
+
forge_plan_is_fractal() { # forge_plan_is_fractal <plan>
|
|
73
|
+
[ -s "$1/fractal-selection.json" ] || return 1
|
|
74
|
+
python3 -c 'import json, sys
|
|
75
|
+
try:
|
|
76
|
+
sys.exit(0 if json.load(open(sys.argv[1])).get("enabled") else 1)
|
|
77
|
+
except Exception:
|
|
78
|
+
sys.exit(1)' "$1/fractal-selection.json"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# The pin's spec: the first line, trimmed, a single token (it lands in a TSV column).
|
|
82
|
+
# Prints nothing when the file is not a regular file or holds no usable spec.
|
|
83
|
+
pin_spec() { # pin_spec <file>
|
|
84
|
+
local spec
|
|
85
|
+
[ -f "$1" ] && [ ! -L "$1" ] || return 0
|
|
86
|
+
spec="$(head -n 1 "$1" 2>/dev/null | tr -d '\r')"
|
|
87
|
+
spec="${spec#"${spec%%[![:space:]]*}"}"; spec="${spec%"${spec##*[![:space:]]}"}"
|
|
88
|
+
case "$spec" in ''|-|UNASSIGNED|*[[:space:][:cntrl:]]*) return 0 ;; esac
|
|
89
|
+
printf '%s\n' "$spec"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# claim_pin <tdir> -> prints the spec and returns 0 when a pin was claimed; the file is
|
|
93
|
+
# then escalation.pin.applying until settle_pin. A pin with no usable spec is dropped.
|
|
94
|
+
# The claim is a bare rename(2), not mv: mv moves a file INTO a destination that is a
|
|
95
|
+
# directory or a symlink to one (BSD mv has no -T), which could carry the pin out of the
|
|
96
|
+
# plan. rename replaces a stale file or symlink named .applying and fails on a directory,
|
|
97
|
+
# and then the pin stays where it is for a later retry.
|
|
98
|
+
claim_pin() {
|
|
99
|
+
local tdir="$1" spec
|
|
100
|
+
[ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] || return 1
|
|
101
|
+
if ! python3 -c 'import os, sys; os.rename(sys.argv[1], sys.argv[2])' \
|
|
102
|
+
"$tdir/escalation.pin" "$tdir/escalation.pin.applying" 2>/dev/null; then
|
|
103
|
+
[ -e "$tdir/escalation.pin" ] && \
|
|
104
|
+
note "$(basename "$tdir"): could not claim the escalation pin (escalation.pin.applying is in the way) — leaving it in place"
|
|
105
|
+
return 1
|
|
106
|
+
fi
|
|
107
|
+
spec="$(pin_spec "$tdir/escalation.pin.applying")"
|
|
108
|
+
if [ -z "$spec" ]; then
|
|
109
|
+
rm -f "$tdir/escalation.pin.applying"
|
|
110
|
+
note "$(basename "$tdir"): the escalation pin holds no usable spec — dropped"
|
|
111
|
+
return 1
|
|
112
|
+
fi
|
|
113
|
+
printf '%s\n' "$spec"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# settle_pin <tdir> done|restore. Done deletes the claim. Restore gives the pin back unless
|
|
117
|
+
# a new one appeared meanwhile (hard link: it fails if the target exists, so a pin written
|
|
118
|
+
# during the application is never overwritten); that newer pin is the one for the next retry.
|
|
119
|
+
# python3, like claim_pin's rename: forge already requires it, and `ln` is one tool more.
|
|
120
|
+
settle_pin() {
|
|
121
|
+
local tdir="$1"
|
|
122
|
+
[ "$2" = restore ] && python3 -c 'import os, sys; os.link(sys.argv[1], sys.argv[2])' \
|
|
123
|
+
"$tdir/escalation.pin.applying" "$tdir/escalation.pin" 2>/dev/null
|
|
124
|
+
rm -f "$tdir/escalation.pin.applying"
|
|
125
|
+
return 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# True when the task's next retry only re-runs QA: a `resume` marker (a QA-stage infrastructure
|
|
129
|
+
# stop), or a verdict-less review of committed work, which prepare_retry turns into the marker
|
|
130
|
+
# but has not yet when the run summary is printed. Such a retry leaves a pin in place.
|
|
131
|
+
qa_only_next() { # qa_only_next <plan> <id>
|
|
132
|
+
local tdir="$1/tasks/$2"
|
|
133
|
+
[ "$(cat "$tdir/resume" 2>/dev/null)" = qa ] && return 0
|
|
134
|
+
[ "$(task_status "$1" "$2")" = UNKNOWN ] && [ -s "$tdir/reviewed.commit" ]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Writes the task's implementer column in tasks.tsv: a temp file, then a rename.
|
|
138
|
+
set_dwarf() { # set_dwarf <plan> <id> <spec>
|
|
139
|
+
awk -F'\t' -v OFS='\t' -v id="$2" -v dw="$3" \
|
|
140
|
+
'$0 !~ /^#/ && $1==id { $5=dw } { print }' "$1/tasks.tsv" > "$1/.tasks.tsv.retry" \
|
|
141
|
+
&& mv "$1/.tasks.tsv.retry" "$1/tasks.tsv"
|
|
142
|
+
}
|
|
143
|
+
|
|
54
144
|
# --- retry preparation ----------------------------------------------------------
|
|
55
145
|
# Shared by `retry` (a human asked) and the scheduler's automatic retries. The findings
|
|
56
146
|
# are the whole point of retrying rather than re-running: the dwarf is told what was
|
|
@@ -66,9 +156,8 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
|
|
|
66
156
|
elif [ "$st" = UNKNOWN ] && [ -s "$tdir/reviewed.commit" ]; then
|
|
67
157
|
# The reviewer never reached a verdict, so the code was not rejected. Re-asking QA is
|
|
68
158
|
# cheaper and more honest than paying an implementer to rewrite unjudged work; the
|
|
69
|
-
# marker is
|
|
159
|
+
# marker is validated against the worktree below before it is believed.
|
|
70
160
|
echo qa > "$tdir/resume"
|
|
71
|
-
note "$id: the reviewer gave no verdict — re-running QA on the unchanged work"
|
|
72
161
|
# Only a real review replaces the findings. The runner also writes explanatory text
|
|
73
162
|
# into qa.last when it fails a task without dispatching QA ("produced no changes at
|
|
74
163
|
# all"), and copying that over would throw away the actual findings the previous
|
|
@@ -83,13 +172,36 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
|
|
|
83
172
|
note "$id: no previous QA findings to pass on (last status was $st)"
|
|
84
173
|
: > "$tdir/retry_findings.md"
|
|
85
174
|
fi
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
local
|
|
175
|
+
# A QA-only resume (an unreviewed verdict, or a QA-stage infrastructure stop) re-asks the
|
|
176
|
+
# reviewer about work that is already committed; the dwarf does not run, so it is no moment
|
|
177
|
+
# to change the implementer. Both branches above wrote the `resume` marker; it holds only
|
|
178
|
+
# while the worktree is still what was reviewed, which do_task_body checks again.
|
|
179
|
+
local qa_only=0
|
|
180
|
+
if resume_check "$PLAN" "$id"; then
|
|
181
|
+
qa_only=1
|
|
182
|
+
[ "$st" = UNKNOWN ] && note "$id: the reviewer gave no verdict — re-running QA on the unchanged work"
|
|
183
|
+
fi
|
|
184
|
+
# Who decides the next implementer, in this order: an explicit --dwarf (the human's call,
|
|
185
|
+
# source `retry`), a pin (the human's call, applied once, source `pin`), then the retry
|
|
186
|
+
# policy (forge-route.py escalation): after escalate_after failed attempts on one model
|
|
187
|
+
# (default 3) the next attempt goes a tier up in the user's own pools instead of repeating
|
|
188
|
+
# the dispatch whose fourth try passes least often, or the task stops with a split
|
|
189
|
+
# suggestion.
|
|
190
|
+
local cur next rc esc_source="" pinned="" has_pin=0
|
|
91
191
|
cur="$(field "$T" "$id" dwarf)"
|
|
92
|
-
|
|
192
|
+
[ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] && has_pin=1
|
|
193
|
+
if [ -n "$newdwarf" ]; then
|
|
194
|
+
esc_source=retry
|
|
195
|
+
rm -f "$tdir/escalation.stop" "$tdir/split.suggested" "$tdir/escalation.next"
|
|
196
|
+
elif [ "$has_pin" = 1 ] && [ "$qa_only" = 1 ]; then
|
|
197
|
+
note "$id: the escalation pin waits for the next full retry (this one only re-runs QA)"
|
|
198
|
+
elif [ "$has_pin" = 1 ] && forge_plan_is_fractal "$PLAN"; then
|
|
199
|
+
note "$id: an escalation pin is set, but Fractal plans never apply pins — leaving it in place"
|
|
200
|
+
elif [ "$has_pin" = 1 ] && pinned="$(claim_pin "$tdir")"; then
|
|
201
|
+
esc_source=pin
|
|
202
|
+
newdwarf="$pinned"
|
|
203
|
+
note "$id: applying the pinned escalation to $pinned"
|
|
204
|
+
else
|
|
93
205
|
next="$(python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$PLAN" --task "$id")"; rc=$?
|
|
94
206
|
if [ "$rc" = 3 ]; then
|
|
95
207
|
note "$(cat "$tdir/split.suggested" 2>/dev/null)"
|
|
@@ -98,36 +210,93 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
|
|
|
98
210
|
if [ -n "$next" ]; then
|
|
99
211
|
note "$id: escalating from $cur to $next after $(cat "$PLAN/escalate_after" 2>/dev/null || echo 3) failed attempts on one model (--escalate-after 0 turns this off)"
|
|
100
212
|
newdwarf="$next"
|
|
213
|
+
esc_source=policy
|
|
101
214
|
fi
|
|
102
|
-
else
|
|
103
|
-
rm -f "$tdir/escalation.stop" "$tdir/split.suggested" "$tdir/escalation.next"
|
|
104
215
|
fi
|
|
105
|
-
|
|
216
|
+
# The same spec again is no escalation, except a pin: a human asked for it, so the
|
|
217
|
+
# request is recorded and consumed.
|
|
218
|
+
if [ "$newdwarf" = "$cur" ] && [ "$esc_source" != pin ]; then newdwarf=""; fi
|
|
106
219
|
if [ -n "$newdwarf" ]; then
|
|
107
|
-
|
|
108
|
-
# model that will actually be spent, and plan's rule that an explicit column value
|
|
109
|
-
# survives a re-plan protects the escalation for free. Rewritten first, so a failed
|
|
110
|
-
# rewrite is never recorded as an escalation that happened.
|
|
111
|
-
awk -F'\t' -v OFS='\t' -v id="$id" -v dw="$newdwarf" \
|
|
112
|
-
'$0 !~ /^#/ && $1==id { $5=dw } { print }' "$T" > "$PLAN/.tasks.tsv.retry" \
|
|
113
|
-
&& mv "$PLAN/.tasks.tsv.retry" "$T" \
|
|
114
|
-
|| { note "$id: could not write $newdwarf into tasks.tsv; nothing was retried"; return 1; }
|
|
115
|
-
# The rewrite loses which model failed first, and an escalation is exactly the record
|
|
116
|
-
# of "this tier was too weak for this task".
|
|
117
|
-
printf '%s\t%s\t%s\t%s\n' "$(date +%s)" "$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
|
|
118
|
-
"$cur" "$newdwarf" >> "$tdir/escalations.tsv"
|
|
119
|
-
# Recorded at the rewrite itself: it happens before the retried attempt begins, so no
|
|
120
|
-
# attempt capture would ever see it as something that attempt produced.
|
|
121
|
-
forge_dataset_event escalation --dir "$PLAN" --task "$id" --set "from=$cur" \
|
|
122
|
-
--set "to=$newdwarf" --set "after_attempt=$(cat "$tdir/attempt" 2>/dev/null || echo 0)"
|
|
123
|
-
rm -f "$tdir/escalation.next"
|
|
220
|
+
write_escalation "$PLAN" "$id" "$cur" "$newdwarf" "$esc_source" || return 1
|
|
124
221
|
fi
|
|
125
222
|
rm -f "$tdir/noretry"
|
|
126
223
|
}
|
|
127
224
|
|
|
225
|
+
# Puts the next implementer in place: tasks.tsv, the escalations.tsv record and the dataset
|
|
226
|
+
# event. Returns 1, with tasks.tsv and a claimed pin given back, when nothing was changed.
|
|
227
|
+
write_escalation() { # write_escalation <plan> <id> <from> <to> <retry|pin|policy>
|
|
228
|
+
local PLAN="$1" id="$2" cur="$3" newdwarf="$4" esc_source="$5" tdir="$1/tasks/$2"
|
|
229
|
+
# A pin is held to its record: whether the record can be written is learned before
|
|
230
|
+
# anything is changed, so a failure there leaves tasks.tsv and the pin as they were.
|
|
231
|
+
if [ "$esc_source" = pin ] && ! : >> "$tdir/escalations.tsv" 2>/dev/null; then
|
|
232
|
+
note "$id: could not record the escalation in escalations.tsv; nothing was retried"
|
|
233
|
+
settle_pin "$tdir" restore
|
|
234
|
+
return 1
|
|
235
|
+
fi
|
|
236
|
+
# Written into tasks.tsv rather than kept in a side file: the table then shows the
|
|
237
|
+
# model that will actually be spent, and plan's rule that an explicit column value
|
|
238
|
+
# survives a re-plan protects the escalation for free. Rewritten first, so a failed
|
|
239
|
+
# rewrite is never recorded as an escalation that happened.
|
|
240
|
+
if [ "$newdwarf" != "$cur" ]; then
|
|
241
|
+
set_dwarf "$PLAN" "$id" "$newdwarf" || {
|
|
242
|
+
note "$id: could not write $newdwarf into tasks.tsv; nothing was retried"
|
|
243
|
+
[ "$esc_source" = pin ] && settle_pin "$tdir" restore
|
|
244
|
+
return 1
|
|
245
|
+
}
|
|
246
|
+
fi
|
|
247
|
+
# The rewrite loses which model failed first, and an escalation is exactly the record
|
|
248
|
+
# of "this tier was too weak for this task". The last column says who decided: forge's
|
|
249
|
+
# policy, a human's `retry --dwarf`, or a pin.
|
|
250
|
+
if ! printf '%s\t%s\t%s\t%s\t%s\n' "$(date +%s)" "$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
|
|
251
|
+
"$cur" "$newdwarf" "$esc_source" >> "$tdir/escalations.tsv" && [ "$esc_source" = pin ]; then
|
|
252
|
+
# The rewrite is undone with the record, so the pin given back is still a real
|
|
253
|
+
# escalation next time, not a same-spec one that hides the model it replaced.
|
|
254
|
+
if [ "$newdwarf" = "$cur" ] || set_dwarf "$PLAN" "$id" "$cur"; then
|
|
255
|
+
note "$id: could not record the escalation in escalations.tsv; nothing was retried"
|
|
256
|
+
settle_pin "$tdir" restore
|
|
257
|
+
return 1
|
|
258
|
+
fi
|
|
259
|
+
note "$id: could not record the escalation in escalations.tsv, and tasks.tsv could not be put back to $cur; it keeps $newdwarf, so the pin is consumed and the retry goes on"
|
|
260
|
+
fi
|
|
261
|
+
# Recorded at the rewrite itself: it happens before the retried attempt begins, so no
|
|
262
|
+
# attempt capture would ever see it as something that attempt produced.
|
|
263
|
+
forge_dataset_event escalation --dir "$PLAN" --task "$id" --set "from=$cur" \
|
|
264
|
+
--set "to=$newdwarf" --set "after_attempt=$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
|
|
265
|
+
--set "source=$esc_source"
|
|
266
|
+
# A pin, like an explicit --dwarf, settles the retry policy's verdicts about this task.
|
|
267
|
+
[ "$esc_source" = pin ] && rm -f "$tdir/escalation.stop" "$tdir/split.suggested"
|
|
268
|
+
rm -f "$tdir/escalation.next"
|
|
269
|
+
[ "$esc_source" = pin ] && settle_pin "$tdir" done
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# An infrastructure stop dispatched again by the scheduler. It is no retry: no attempt or
|
|
274
|
+
# budget is spent, the findings carried forward stay, and the retry policy is not asked. But
|
|
275
|
+
# unless the stop came at QA (and the reviewed work is intact), the entry is a full
|
|
276
|
+
# implementer attempt, so a pending pin applies here exactly as prepare_retry applies it.
|
|
277
|
+
# Returns 1 when a claimed pin could not be put in place; the task is then not resumed.
|
|
278
|
+
prepare_resume() { # prepare_resume <plan> <id>
|
|
279
|
+
local PLAN="$1" id="$2" tdir="$1/tasks/$2" pinned
|
|
280
|
+
[ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] || return 0
|
|
281
|
+
if resume_check "$PLAN" "$id"; then
|
|
282
|
+
note "$id: the escalation pin waits for the next full retry (this resume only re-runs QA)"
|
|
283
|
+
elif forge_plan_is_fractal "$PLAN"; then
|
|
284
|
+
note "$id: an escalation pin is set, but Fractal plans never apply pins — leaving it in place"
|
|
285
|
+
elif pinned="$(claim_pin "$tdir")"; then
|
|
286
|
+
note "$id: applying the pinned escalation to $pinned"
|
|
287
|
+
write_escalation "$PLAN" "$id" "$(field "$PLAN/tasks.tsv" "$id" dwarf)" "$pinned" pin || return 1
|
|
288
|
+
fi
|
|
289
|
+
return 0
|
|
290
|
+
}
|
|
291
|
+
|
|
128
292
|
# The scheduler's `_prepare_retry` target: prepare, then clear the status so the task
|
|
129
|
-
# reads as PENDING again. `retry` clears it itself, after its own preflight.
|
|
130
|
-
|
|
293
|
+
# reads as PENDING again. `retry` clears it itself, after its own preflight. With --resume it
|
|
294
|
+
# prepares an infrastructure stop's resume instead (prepare_resume), and the status stays.
|
|
295
|
+
retry_for_scheduler() { # retry_for_scheduler <plan> <id> [--resume]
|
|
296
|
+
if [ "${3:-}" = --resume ]; then
|
|
297
|
+
prepare_resume "$1" "$2"
|
|
298
|
+
return $?
|
|
299
|
+
fi
|
|
131
300
|
prepare_retry "$1" "$2" || return $?
|
|
132
301
|
rm -f "$1/tasks/$2/status"
|
|
133
302
|
}
|
|
@@ -173,9 +342,17 @@ guard_after_dwarf() { # guard_after_dwarf <wt> <tdir> <id>
|
|
|
173
342
|
|
|
174
343
|
# --- QA severity gate -----------------------------------------------------------
|
|
175
344
|
qa_threshold_for() { # per-task file wins over the plan-level default; prints nothing if unset
|
|
176
|
-
|
|
345
|
+
# A per-task "none" exempts the task from a plan-wide threshold (prints nothing, no fallback).
|
|
346
|
+
# A plan-level "none" is the same as no file. Anything but P0-P3 or none is ignored, so a
|
|
347
|
+
# garbage per-task value falls back to the plan file.
|
|
348
|
+
local f v
|
|
177
349
|
for f in "$1/tasks/$2/qa_threshold" "$1/qa_threshold"; do
|
|
178
|
-
[ -s "$f" ]
|
|
350
|
+
[ -s "$f" ] || continue
|
|
351
|
+
v="$(head -1 "$f" | tr -d ' \r' | tr 'a-z' 'A-Z')"
|
|
352
|
+
case "$v" in
|
|
353
|
+
P0|P1|P2|P3) printf '%s\n' "$v"; return 0 ;;
|
|
354
|
+
NONE) [ "$f" = "$1/qa_threshold" ] || return 0 ;;
|
|
355
|
+
esac
|
|
179
356
|
done
|
|
180
357
|
return 0
|
|
181
358
|
}
|
|
@@ -184,10 +361,13 @@ qa_threshold_for() { # per-task file wins over the plan-level default; prints no
|
|
|
184
361
|
# and less severe than the threshold is accepted as PASS-with-known-issues. The literal
|
|
185
362
|
# status stays PASS, so the merge path, the scheduler and Fractal need no new vocabulary.
|
|
186
363
|
# Anything unlabelled stays blocking: a finding the gate cannot place is not tolerated.
|
|
187
|
-
|
|
364
|
+
# The optional 5th argument is the threshold the caller already used for this attempt's review
|
|
365
|
+
# prompt; when given (even empty) it is used as-is, so a change to the files between the prompt
|
|
366
|
+
# and the gate cannot split one attempt across two thresholds. Absent, the files are read.
|
|
367
|
+
qa_gate() { # qa_gate <plan> <id> <tdir> <verdict> [threshold]
|
|
188
368
|
local plan="$1" id="$2" tdir="$3" verdict="$4" thr line
|
|
189
369
|
rm -f "$tdir/qa.gate" "$tdir/known_issues.md"
|
|
190
|
-
thr="$(qa_threshold_for "$plan" "$id")"
|
|
370
|
+
if [ "$#" -ge 5 ]; then thr="$5"; else thr="$(qa_threshold_for "$plan" "$id")"; fi
|
|
191
371
|
[ -n "$thr" ] || return 0
|
|
192
372
|
line="$(python3 "$SKILL_DIR/scripts/forge-contract.py" gate --verdict "${verdict:-UNKNOWN}" \
|
|
193
373
|
--qa-last "$tdir/qa.last" --threshold "$thr" --known-issues "$tdir/known_issues.md" 2>/dev/null)" || return 0
|
|
@@ -265,10 +445,29 @@ report_size() { # report_size <plan>
|
|
|
265
445
|
|
|
266
446
|
# The retry policy's verdicts: a task that stopped with a split suggestion, or whose next
|
|
267
447
|
# retry would go a tier up. Decided after every attempt, so a spent budget still says it.
|
|
448
|
+
# A pending pin is read from the file itself: it can be set between two attempts, and it
|
|
449
|
+
# outranks both verdicts (the retry applies it first). The converse needs care too: a pin
|
|
450
|
+
# withdrawn since the last attempt leaves the `escalation.next` that `--check` wrote for it,
|
|
451
|
+
# so a marker found with no pin pending is decided again rather than trusted.
|
|
268
452
|
report_escalations() { # report_escalations <plan>
|
|
269
|
-
local plan="$1" id n=0 head
|
|
453
|
+
local plan="$1" id n=0 head pin
|
|
270
454
|
head="retry policy (after $(cat "$plan/escalate_after" 2>/dev/null || echo 3) failed attempts on one model):"
|
|
271
455
|
for id in $(all_ids "$plan/tasks.tsv"); do
|
|
456
|
+
pin="$(pin_spec "$plan/tasks/$id/escalation.pin")"
|
|
457
|
+
[ -n "$pin" ] && forge_plan_is_fractal "$plan" && pin=""
|
|
458
|
+
if [ -n "$pin" ] && [ "$(task_status "$plan" "$id")" != MERGED ]; then
|
|
459
|
+
[ "$n" = 0 ] && echo "$head"
|
|
460
|
+
n=$((n+1))
|
|
461
|
+
if qa_only_next "$plan" "$id"; then
|
|
462
|
+
printf ' %-14s its next retry goes to %s (pinned; after the QA-only re-run)\n' "$id" "$pin"
|
|
463
|
+
else
|
|
464
|
+
printf ' %-14s its next retry goes to %s (pinned)\n' "$id" "$pin"
|
|
465
|
+
fi
|
|
466
|
+
continue
|
|
467
|
+
fi
|
|
468
|
+
if [ -s "$plan/tasks/$id/escalation.next" ] && [ "$(task_status "$plan" "$id")" != MERGED ]; then
|
|
469
|
+
python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$plan" --task "$id" --check >/dev/null 2>&1 || true
|
|
470
|
+
fi
|
|
272
471
|
if [ -s "$plan/tasks/$id/split.suggested" ]; then
|
|
273
472
|
[ "$n" = 0 ] && echo "$head"
|
|
274
473
|
n=$((n+1)); printf ' %-14s split suggested: %s\n' "$id" "$(cat "$plan/tasks/$id/split.suggested")"
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
# Persisted options become files in the plan dir, like --verify and --setup, so a later
|
|
15
15
|
# `retry` or the scheduler's task processes see them. Cost-bearing options that change
|
|
16
16
|
# what a run is allowed to spend (--retry-failed, --infra-retries) are deliberately NOT
|
|
17
|
-
# persisted: they apply to the invocation that asked for them.
|
|
17
|
+
# persisted: they apply to the invocation that asked for them. While the scheduler runs
|
|
18
|
+
# they are mirrored to $PLAN/retry_budget and $PLAN/infra_budget (plus scheduler.live) so
|
|
19
|
+
# `forge control` can change them live; the scheduler removes those files when it exits.
|
|
18
20
|
#
|
|
19
21
|
# --timeout S run retry $PLAN/timeout dispatch timeout, seconds (0 = none)
|
|
20
22
|
# --retry-failed N run (this run only) automatic reviewer-driven retries
|
|
@@ -989,8 +989,9 @@ do_task_body() (
|
|
|
989
989
|
*) echo UNKNOWN > "$tdir/status" ;;
|
|
990
990
|
esac
|
|
991
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
|
-
|
|
992
|
+
# accepted PASS-with-known-issues. No threshold, no change. qa_thr is the value the review
|
|
993
|
+
# prompt was built with, so the prompt and the gate of one attempt agree.
|
|
994
|
+
qa_gate "$PLAN" "$id" "$tdir" "$verdict" "$qa_thr"
|
|
994
995
|
|
|
995
996
|
# Annotate a FAIL that looks unsound. Note what this does NOT do: the status file is
|
|
996
997
|
# already written above and nothing below rewrites it. A model that could talk a
|
package/scripts/forge-route.py
CHANGED
|
@@ -22,7 +22,14 @@ bias (inverse-propensity weighting needs the probability each choice had).
|
|
|
22
22
|
repeating the same dispatch. Prints that spec when one is due. When none exists, writes
|
|
23
23
|
tasks/<id>/escalation.stop and split.suggested and exits 3. `--check` (run after every
|
|
24
24
|
attempt) also records a due escalation in escalation.next, and clears the markers once
|
|
25
|
-
the streak no longer holds.
|
|
25
|
+
the streak no longer holds. A pending tasks/<id>/escalation.pin (a human's one-shot
|
|
26
|
+
choice, applied by prepare_retry) is what `--check` reports as the next model instead
|
|
27
|
+
(escalation.next = its spec), whatever escalate_after says, unless the next retry is a
|
|
28
|
+
QA-only resume or the plan is Fractal (pins never apply there). Nothing has applied the
|
|
29
|
+
pin yet, so the policy's own stop and split markers stay as the policy decided them
|
|
30
|
+
beneath it: they are what holds again if the pin is withdrawn.
|
|
31
|
+
The marker writes take <plan>/control.lock and re-read escalate_after inside it: a
|
|
32
|
+
policy decision made before a human switched the policy off writes nothing.
|
|
26
33
|
|
|
27
34
|
The propensity of the chosen spec is (1-E)*[chosen == SPEC] + E/2. Forge never picks a
|
|
28
35
|
model the user did not name: both helpers read only the pools `plan` stored in
|
|
@@ -34,12 +41,17 @@ the draws reproducible (tests only). Any failure prints SPEC (explore) or nothin
|
|
|
34
41
|
from __future__ import annotations
|
|
35
42
|
|
|
36
43
|
import argparse
|
|
44
|
+
import contextlib
|
|
45
|
+
import errno
|
|
46
|
+
import fcntl
|
|
37
47
|
import hashlib
|
|
38
48
|
import json
|
|
39
49
|
import os
|
|
40
50
|
import random
|
|
41
51
|
import secrets
|
|
52
|
+
import stat
|
|
42
53
|
import sys
|
|
54
|
+
import tempfile
|
|
43
55
|
import time
|
|
44
56
|
from pathlib import Path
|
|
45
57
|
|
|
@@ -49,6 +61,7 @@ REGISTRY = SCRIPTS.parent / 'registry.tsv'
|
|
|
49
61
|
FAILED = ('FAIL', 'TIMEOUT') # a judgment against the spec that ran
|
|
50
62
|
NEUTRAL = ('INFRA', 'UNKNOWN', 'ERROR', 'RUNNING', 'INTERRUPTED') # say nothing about it
|
|
51
63
|
DEFAULT_ESCALATE_AFTER = 3
|
|
64
|
+
LOCK_WAIT = 5.0 # seconds to wait for P/control.lock
|
|
52
65
|
|
|
53
66
|
|
|
54
67
|
def rng_for(task: str):
|
|
@@ -275,46 +288,190 @@ def _fractal(plan: Path) -> bool:
|
|
|
275
288
|
return False
|
|
276
289
|
|
|
277
290
|
|
|
291
|
+
# --- live-control files ------------------------------------------------------------
|
|
292
|
+
# Markers are shared with `forge control` and the scheduler, so they are written the way
|
|
293
|
+
# every writer of the plan's control files writes: a task id the scheduler would accept, a
|
|
294
|
+
# real directory chain under the canonical plan dir, a regular file as the target, a temp
|
|
295
|
+
# file plus rename, and P/control.lock held around the check-and-write.
|
|
296
|
+
|
|
297
|
+
class Busy(Exception):
|
|
298
|
+
"""P/control.lock stayed held for LOCK_WAIT seconds."""
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _task_dir(plan: Path, task: str):
|
|
302
|
+
"""tasks/<task> under the canonical plan dir, or None when it is not a real directory."""
|
|
303
|
+
if not task or task in ('.', '..') or '/' in task or '\0' in task:
|
|
304
|
+
return None
|
|
305
|
+
tdir = plan / 'tasks' / task
|
|
306
|
+
try:
|
|
307
|
+
if not stat.S_ISDIR(os.lstat(tdir).st_mode) or os.path.realpath(tdir) != str(tdir):
|
|
308
|
+
return None
|
|
309
|
+
except OSError:
|
|
310
|
+
return None
|
|
311
|
+
return tdir
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@contextlib.contextmanager
|
|
315
|
+
def _control_lock(plan: Path):
|
|
316
|
+
fd = os.open(str(plan / 'control.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o644)
|
|
317
|
+
try:
|
|
318
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
319
|
+
raise OSError(errno.EINVAL, 'control.lock is not a regular file')
|
|
320
|
+
deadline = time.monotonic() + LOCK_WAIT
|
|
321
|
+
while True:
|
|
322
|
+
try:
|
|
323
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
324
|
+
break
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
if exc.errno not in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK):
|
|
327
|
+
raise
|
|
328
|
+
if time.monotonic() >= deadline:
|
|
329
|
+
raise Busy('control.lock is held') from None
|
|
330
|
+
time.sleep(0.05)
|
|
331
|
+
try:
|
|
332
|
+
yield
|
|
333
|
+
finally:
|
|
334
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
335
|
+
finally:
|
|
336
|
+
os.close(fd)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _regular(path: Path) -> bool:
|
|
340
|
+
try:
|
|
341
|
+
return stat.S_ISREG(os.lstat(path).st_mode)
|
|
342
|
+
except OSError:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
MARKERS = ('escalation.stop', 'split.suggested', 'escalation.next')
|
|
347
|
+
|
|
348
|
+
|
|
278
349
|
def _clear(tdir: Path, *names) -> None:
|
|
279
|
-
for name in names
|
|
350
|
+
for name in names:
|
|
351
|
+
if _regular(tdir / name):
|
|
352
|
+
try:
|
|
353
|
+
(tdir / name).unlink()
|
|
354
|
+
except OSError:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _write_marker(tdir: Path, name: str, text: str) -> None:
|
|
359
|
+
target = tdir / name
|
|
360
|
+
if os.path.lexists(target) and not _regular(target):
|
|
361
|
+
raise OSError(errno.EINVAL, '%s is not a regular file' % name)
|
|
362
|
+
fd, tmp = tempfile.mkstemp(dir=str(tdir), prefix='.%s.' % name)
|
|
363
|
+
try:
|
|
364
|
+
with os.fdopen(fd, 'w') as out:
|
|
365
|
+
out.write(text)
|
|
366
|
+
os.chmod(tmp, 0o644)
|
|
367
|
+
os.replace(tmp, target)
|
|
368
|
+
except BaseException:
|
|
280
369
|
try:
|
|
281
|
-
|
|
370
|
+
os.unlink(tmp)
|
|
282
371
|
except OSError:
|
|
283
372
|
pass
|
|
373
|
+
raise
|
|
284
374
|
|
|
285
375
|
|
|
286
|
-
def
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
376
|
+
def _pin(tdir: Path):
|
|
377
|
+
"""The pending pin's spec, or None. One line, one token: it goes into a TSV column."""
|
|
378
|
+
path = tdir / 'escalation.pin'
|
|
379
|
+
if not _regular(path):
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
lines = path.read_text().splitlines()
|
|
383
|
+
except (OSError, UnicodeDecodeError):
|
|
384
|
+
return None
|
|
385
|
+
spec = lines[0].strip() if lines else ''
|
|
386
|
+
if not spec or spec in ('-', 'UNASSIGNED') or any(c.isspace() or ord(c) < 32 for c in spec):
|
|
387
|
+
return None
|
|
388
|
+
return spec
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _qa_next(tdir: Path) -> bool:
|
|
392
|
+
"""True when the task's next retry only re-runs QA, so it cannot apply a pin.
|
|
393
|
+
|
|
394
|
+
That is a `resume` marker (a QA-stage infrastructure stop), or a verdict-less review of
|
|
395
|
+
committed work: prepare_retry turns that into the marker, but it has not run yet when
|
|
396
|
+
`--check` does, right after the attempt.
|
|
397
|
+
"""
|
|
398
|
+
if (tdir / 'resume').exists():
|
|
399
|
+
return True
|
|
400
|
+
try:
|
|
401
|
+
return (tdir / 'status').read_text().strip() == 'UNKNOWN' and (tdir / 'reviewed.commit').stat().st_size > 0
|
|
402
|
+
except OSError:
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _apply(plan: Path, tdir: Path, clear=(), writes=(), policy=True, pin=None):
|
|
407
|
+
"""Clear and write markers under the control lock. Returns (policy stood, pin reported).
|
|
408
|
+
|
|
409
|
+
The policy re-reads escalate_after inside the lock (a human may have switched it off since
|
|
410
|
+
the decision was made): it then writes nothing and its markers go, as `--escalate-after 0`
|
|
411
|
+
clears them. A pin is reported as escalation.next only while it is still pending; the stop
|
|
412
|
+
and split markers beneath it stay the policy's, because the pin has not been applied.
|
|
413
|
+
"""
|
|
414
|
+
with _control_lock(plan):
|
|
415
|
+
stood = policy and _setting(plan) > 0
|
|
416
|
+
if policy and not stood:
|
|
417
|
+
clear, writes = MARKERS, ()
|
|
418
|
+
reported = pin is not None and _pin(tdir) == pin
|
|
419
|
+
_clear(tdir, *clear)
|
|
420
|
+
for name, text in writes:
|
|
421
|
+
_write_marker(tdir, name, text)
|
|
422
|
+
if reported:
|
|
423
|
+
_write_marker(tdir, 'escalation.next', pin + '\n')
|
|
424
|
+
return stood, reported
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _decide(plan: Path, tdir: Path, args, after: int):
|
|
428
|
+
"""What the policy wants done to the markers: (clear, writes, spec to print, exit code)."""
|
|
296
429
|
current = _dwarf_of(plan, args.task)
|
|
297
430
|
if not current or current in ('-', 'UNASSIGNED'):
|
|
298
|
-
return 0
|
|
431
|
+
return (), (), '', 0
|
|
299
432
|
registry = registry_models()
|
|
300
433
|
streak = failed_streak(attempt_rows(tdir), current, registry)
|
|
301
434
|
if streak < after:
|
|
302
|
-
|
|
303
|
-
return 0
|
|
435
|
+
return MARKERS, (), '', 0
|
|
304
436
|
target, why = stronger(current, load_pools(plan / 'fractal-routing.json'), registry)
|
|
305
437
|
if target:
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
438
|
+
writes = (('escalation.next', target + '\n'),) if args.check else ()
|
|
439
|
+
return ('escalation.stop', 'split.suggested'), writes, target, 0
|
|
440
|
+
split = ('%s failed %d attempts on %s, and %s. Another attempt on the same model rarely passes, '
|
|
441
|
+
'so split the task into smaller ones, or retry it with an explicit --dwarf <spec>.\n'
|
|
442
|
+
% (args.task, streak, current, why))
|
|
443
|
+
return ('escalation.next',), (('escalation.stop', '%d\t%s\n' % (streak, current)), ('split.suggested', split)), '', 3
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def escalation(args) -> int:
|
|
447
|
+
plan = Path(os.path.realpath(args.plan))
|
|
448
|
+
tdir = _task_dir(plan, args.task)
|
|
449
|
+
if tdir is None:
|
|
450
|
+
return 0
|
|
451
|
+
after = _setting(plan)
|
|
452
|
+
fractal = _fractal(plan)
|
|
453
|
+
# Fractal plans freeze their eligible set at selection; a QA-only resume spends no dwarf
|
|
454
|
+
# attempt, so neither is a moment to change the implementer.
|
|
455
|
+
idle = after <= 0 or fractal or (tdir / 'resume').exists()
|
|
456
|
+
# A pin is a human's choice and outranks the policy, so `--check` reports it as the next
|
|
457
|
+
# model whatever escalate_after says; prepare_retry applies it and claims the file. The
|
|
458
|
+
# policy is still decided underneath it and its markers kept current.
|
|
459
|
+
pin = _pin(tdir) if args.check and not fractal and not _qa_next(tdir) else None
|
|
460
|
+
clear, writes, shown, rc = (MARKERS if after <= 0 else ()), (), '', 0
|
|
461
|
+
if not idle:
|
|
462
|
+
clear, writes, shown, rc = _decide(plan, tdir, args, after)
|
|
463
|
+
if not (writes or shown or pin):
|
|
464
|
+
_clear(tdir, *clear)
|
|
465
|
+
return 0
|
|
466
|
+
stood, reported = _apply(plan, tdir, clear, writes, policy=not idle, pin=pin)
|
|
467
|
+
if reported:
|
|
468
|
+
print(pin)
|
|
469
|
+
return 0
|
|
470
|
+
if not stood:
|
|
310
471
|
return 0
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
'%s failed %d attempts on %s, and %s. Another attempt on the same model rarely passes, '
|
|
315
|
-
'so split the task into smaller ones, or retry it with an explicit --dwarf <spec>.\n'
|
|
316
|
-
% (args.task, streak, current, why))
|
|
317
|
-
return 3
|
|
472
|
+
if shown:
|
|
473
|
+
print(shown)
|
|
474
|
+
return rc
|
|
318
475
|
|
|
319
476
|
|
|
320
477
|
def main(argv=None) -> int:
|