@giamme/forge 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- package/ui/forge_ui/static/uikit.js +161 -0
|
@@ -40,18 +40,216 @@ infra_stop() { # infra_stop <tdir> <id> <role> <attempt>
|
|
|
40
40
|
note "$id: $role dispatch hit an infrastructure failure (${cls:-unknown}) — no attempt spent; re-run to resume"
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
#
|
|
44
|
-
# reviewed
|
|
45
|
-
|
|
43
|
+
# True when the worktree is still exactly what was reviewed. Reads and deletes nothing:
|
|
44
|
+
# the reviewed commit, the fingerprint and changes.diff are present, and the worktree's
|
|
45
|
+
# HEAD and fingerprint equal them.
|
|
46
|
+
reviewed_state_matches() { # reviewed_state_matches <tdir> <wt>
|
|
46
47
|
local tdir="$1" wt="$2"
|
|
47
|
-
[ "$(cat "$tdir/resume" 2>/dev/null)" = qa ] || return 1
|
|
48
48
|
[ -s "$tdir/reviewed.commit" ] && [ -s "$tdir/source.fingerprint" ] && [ -s "$tdir/changes.diff" ] || return 1
|
|
49
49
|
[ -e "$wt/.git" ] || return 1
|
|
50
50
|
[ "$(git -C "$wt" rev-parse HEAD 2>/dev/null)" = "$(cat "$tdir/reviewed.commit")" ] || return 1
|
|
51
51
|
[ "$(forge_fingerprint "$wt")" = "$(cat "$tdir/source.fingerprint")" ]
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
# A QA-only resume is trusted only while the worktree is still exactly what was
|
|
55
|
+
# reviewed. Anything else clears the marker and the full path runs.
|
|
56
|
+
resume_valid() { # resume_valid <tdir> <wt>
|
|
57
|
+
[ "$(cat "$1/resume" 2>/dev/null)" = qa ] || return 1
|
|
58
|
+
reviewed_state_matches "$1" "$2"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# The next entry's kind, decided before it starts with the test do_task_body applies
|
|
62
|
+
# (resume_valid): true when it only re-runs QA. A marker the worktree no longer matches is
|
|
63
|
+
# cleared here, so whoever prepares the entry and do_task_body agree that it is a full one.
|
|
64
|
+
resume_check() { # resume_check <plan> <id>
|
|
65
|
+
local tdir="$1/tasks/$2" root
|
|
66
|
+
[ -f "$tdir/resume" ] || return 1
|
|
67
|
+
root="$(cat "$1/wt_root" 2>/dev/null)"
|
|
68
|
+
[ -n "$root" ] && resume_valid "$tdir" "$root/$2" && return 0
|
|
69
|
+
rm -f "$tdir/resume"
|
|
70
|
+
note "$2: the saved review state no longer matches the worktree — the next attempt runs the full task"
|
|
71
|
+
return 1
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# --- escalation pin -------------------------------------------------------------
|
|
75
|
+
# A human's one-shot choice of the model for a task's next full retry:
|
|
76
|
+
# tasks/<id>/escalation.pin holds one dwarf spec (written by `forge control pin`). The
|
|
77
|
+
# retry claims it with an atomic rename so a writer replacing it meanwhile never races the
|
|
78
|
+
# read, and settles the claim once tasks.tsv and escalations.tsv are written.
|
|
79
|
+
forge_plan_is_fractal() { # forge_plan_is_fractal <plan>
|
|
80
|
+
[ -s "$1/fractal-selection.json" ] || return 1
|
|
81
|
+
python3 -c 'import json, sys
|
|
82
|
+
try:
|
|
83
|
+
sys.exit(0 if json.load(open(sys.argv[1])).get("enabled") else 1)
|
|
84
|
+
except Exception:
|
|
85
|
+
sys.exit(1)' "$1/fractal-selection.json"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# The pin's spec: the first line, trimmed, a single token (it lands in a TSV column).
|
|
89
|
+
# Prints nothing when the file is not a regular file or holds no usable spec.
|
|
90
|
+
pin_spec() { # pin_spec <file>
|
|
91
|
+
local spec
|
|
92
|
+
[ -f "$1" ] && [ ! -L "$1" ] || return 0
|
|
93
|
+
spec="$(head -n 1 "$1" 2>/dev/null | tr -d '\r')"
|
|
94
|
+
spec="${spec#"${spec%%[![:space:]]*}"}"; spec="${spec%"${spec##*[![:space:]]}"}"
|
|
95
|
+
case "$spec" in ''|-|UNASSIGNED|*[[:space:][:cntrl:]]*) return 0 ;; esac
|
|
96
|
+
printf '%s\n' "$spec"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# claim_pin <tdir> -> prints the spec and returns 0 when a pin was claimed; the file is
|
|
100
|
+
# then escalation.pin.applying until settle_pin. A pin with no usable spec is dropped.
|
|
101
|
+
# The claim is a bare rename(2), not mv: mv moves a file INTO a destination that is a
|
|
102
|
+
# directory or a symlink to one (BSD mv has no -T), which could carry the pin out of the
|
|
103
|
+
# plan. rename replaces a stale file or symlink named .applying and fails on a directory,
|
|
104
|
+
# and then the pin stays where it is for a later retry.
|
|
105
|
+
claim_pin() {
|
|
106
|
+
local tdir="$1" spec
|
|
107
|
+
[ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] || return 1
|
|
108
|
+
if ! python3 -c 'import os, sys; os.rename(sys.argv[1], sys.argv[2])' \
|
|
109
|
+
"$tdir/escalation.pin" "$tdir/escalation.pin.applying" 2>/dev/null; then
|
|
110
|
+
[ -e "$tdir/escalation.pin" ] && \
|
|
111
|
+
note "$(basename "$tdir"): could not claim the escalation pin (escalation.pin.applying is in the way) — leaving it in place"
|
|
112
|
+
return 1
|
|
113
|
+
fi
|
|
114
|
+
spec="$(pin_spec "$tdir/escalation.pin.applying")"
|
|
115
|
+
if [ -z "$spec" ]; then
|
|
116
|
+
rm -f "$tdir/escalation.pin.applying"
|
|
117
|
+
note "$(basename "$tdir"): the escalation pin holds no usable spec — dropped"
|
|
118
|
+
return 1
|
|
119
|
+
fi
|
|
120
|
+
printf '%s\n' "$spec"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# settle_pin <tdir> done|restore. Done deletes the claim. Restore gives the pin back unless
|
|
124
|
+
# a new one appeared meanwhile (hard link: it fails if the target exists, so a pin written
|
|
125
|
+
# during the application is never overwritten); that newer pin is the one for the next retry.
|
|
126
|
+
# python3, like claim_pin's rename: forge already requires it, and `ln` is one tool more.
|
|
127
|
+
settle_pin() {
|
|
128
|
+
local tdir="$1"
|
|
129
|
+
[ "$2" = restore ] && python3 -c 'import os, sys; os.link(sys.argv[1], sys.argv[2])' \
|
|
130
|
+
"$tdir/escalation.pin.applying" "$tdir/escalation.pin" 2>/dev/null
|
|
131
|
+
rm -f "$tdir/escalation.pin.applying"
|
|
132
|
+
return 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# True when the task's next retry only re-runs QA: a `resume` marker (a QA-stage infrastructure
|
|
136
|
+
# stop), or a verdict-less review of committed work, which prepare_retry turns into the marker
|
|
137
|
+
# but has not yet when the run summary is printed. Such a retry leaves a pin in place.
|
|
138
|
+
# Both are believed only while the worktree is still what was reviewed — the comparison
|
|
139
|
+
# resume_valid uses, and the marker prepare_retry is about to write gets the same test.
|
|
140
|
+
# This never deletes the marker; resume_check owns that.
|
|
141
|
+
qa_only_next() { # qa_only_next <plan> <id>
|
|
142
|
+
local tdir="$1/tasks/$2" root="" wt=""
|
|
143
|
+
root="$(cat "$1/wt_root" 2>/dev/null)"
|
|
144
|
+
[ -n "$root" ] && wt="$root/$2"
|
|
145
|
+
if [ "$(cat "$tdir/resume" 2>/dev/null)" = qa ]; then
|
|
146
|
+
[ -n "$wt" ] && reviewed_state_matches "$tdir" "$wt"
|
|
147
|
+
return
|
|
148
|
+
fi
|
|
149
|
+
if [ "$(task_status "$1" "$2")" = UNKNOWN ] && [ -s "$tdir/reviewed.commit" ]; then
|
|
150
|
+
[ -n "$wt" ] && reviewed_state_matches "$tdir" "$wt"
|
|
151
|
+
return
|
|
152
|
+
fi
|
|
153
|
+
return 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
qa_only_next_flag() { # qa_only_next_flag <plan> <id> -> yes | no, for `escalation --check`
|
|
157
|
+
if qa_only_next "$1" "$2"; then printf 'yes\n'; else printf 'no\n'; fi
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# tasks/<id>/escalations.tsv, probed and appended the way forge-control writes a plan file
|
|
161
|
+
# (forge-route.py record loads that code): every component below the plan is a real
|
|
162
|
+
# directory, and the file is absent or a regular file. A symlink at any level is a refusal.
|
|
163
|
+
escalation_record_ok() { # escalation_record_ok <plan> <id>
|
|
164
|
+
python3 "$SKILL_DIR/scripts/forge-route.py" record --plan "$1" --task "$2" --probe
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
escalation_record_line() { # escalation_record_line <plan> <id> <line>
|
|
168
|
+
python3 "$SKILL_DIR/scripts/forge-route.py" record --plan "$1" --task "$2" --line "$3"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Writes the task's implementer column in tasks.tsv: a temp file, then a rename.
|
|
172
|
+
set_dwarf() { # set_dwarf <plan> <id> <spec>
|
|
173
|
+
awk -F'\t' -v OFS='\t' -v id="$2" -v dw="$3" \
|
|
174
|
+
'$0 !~ /^#/ && $1==id { $5=dw } { print }' "$1/tasks.tsv" > "$1/.tasks.tsv.retry" \
|
|
175
|
+
&& mv "$1/.tasks.tsv.retry" "$1/tasks.tsv"
|
|
176
|
+
}
|
|
177
|
+
|
|
54
178
|
# --- retry preparation ----------------------------------------------------------
|
|
179
|
+
# Carries the last review's report forward as the findings of the next attempt, and records
|
|
180
|
+
# which commit that report judged (what reviewed.commit holds now, since qa.last is that
|
|
181
|
+
# review's). The two are written together: the commit first, so a report never sits beside a
|
|
182
|
+
# commit that belongs to an older one. Repeating the call writes the same bytes.
|
|
183
|
+
carry_findings() { # carry_findings <tdir>
|
|
184
|
+
local tdir="$1"
|
|
185
|
+
if [ -s "$tdir/reviewed.commit" ]; then
|
|
186
|
+
cp "$tdir/reviewed.commit" "$tdir/.retry_findings.commit.tmp" \
|
|
187
|
+
&& mv "$tdir/.retry_findings.commit.tmp" "$tdir/retry_findings.commit"
|
|
188
|
+
else
|
|
189
|
+
rm -f "$tdir/retry_findings.commit"
|
|
190
|
+
fi
|
|
191
|
+
# The reviewer session that wrote these findings, and what it ran as, kept beside them for a
|
|
192
|
+
# plan with --reviewer-session. Removed first, so an older session never sits next to newer
|
|
193
|
+
# findings; a review that left no session leaves none here.
|
|
194
|
+
rm -f "$tdir/retry_findings.session" "$tdir/retry_findings.resolved"
|
|
195
|
+
cp "$tdir/qa.last" "$tdir/retry_findings.md"
|
|
196
|
+
[ ! -f "$tdir/qa.session" ] || cp "$tdir/qa.session" "$tdir/retry_findings.session" 2>/dev/null || true
|
|
197
|
+
[ ! -f "$tdir/qa.resolved" ] || cp "$tdir/qa.resolved" "$tdir/retry_findings.resolved" 2>/dev/null || true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# --- reviewer-session continuation (plan --reviewer-session, an opt-in experiment) ------------
|
|
201
|
+
# review_session_decide <session-file> <resolved-file> <resolution-file>: whether this review
|
|
202
|
+
# round continues the earlier reviewer's session. Prints `mode<TAB>reason<TAB>session`; the
|
|
203
|
+
# session is the id to continue, and empty for every fresh answer.
|
|
204
|
+
# session-file the id the earlier review ran under (retry_findings.session / qa.session)
|
|
205
|
+
# resolved-file that review's resolution (retry_findings.resolved / qa.resolved)
|
|
206
|
+
# resolution-file this dispatch's resolution (doctor output): harness=, model=, effort=
|
|
207
|
+
# The caller has already handled the cases that need no files (first round, Fractal). Anything
|
|
208
|
+
# that cannot be checked is fresh; nothing here aborts.
|
|
209
|
+
review_session_decide() {
|
|
210
|
+
local sf="$1" rf="$2" cf="$3" id="" k a b h
|
|
211
|
+
[ -f "$sf" ] && id="$(head -1 "$sf" 2>/dev/null | tr -d '\r')"
|
|
212
|
+
if [ -z "$id" ]; then printf 'fresh\tno-session\t\n'; return 0; fi
|
|
213
|
+
case "$id" in *[!A-Za-z0-9-]*) printf 'fresh\tinvalid-session\t\n'; return 0 ;; esac
|
|
214
|
+
if [ "${#id}" -lt 8 ] || [ "${#id}" -gt 128 ]; then printf 'fresh\tinvalid-session\t\n'; return 0; fi
|
|
215
|
+
h="$(sed -n 's/^harness=//p' "$cf" 2>/dev/null | head -1)"
|
|
216
|
+
if [ -z "$h" ]; then printf 'fresh\tharness-unsupported\t\n'; return 0; fi
|
|
217
|
+
for k in harness model effort; do
|
|
218
|
+
a="$(sed -n "s/^$k=//p" "$rf" 2>/dev/null | head -1)"
|
|
219
|
+
b="$(sed -n "s/^$k=//p" "$cf" 2>/dev/null | head -1)"
|
|
220
|
+
if [ -z "$a" ] || [ "$a" != "$b" ]; then printf 'fresh\tspec-changed\t\n'; return 0; fi
|
|
221
|
+
done
|
|
222
|
+
case "$h" in
|
|
223
|
+
claude|openclaude|codex) printf 'continued\tsame-spec\t%s\n' "$id" ;;
|
|
224
|
+
*) printf 'fresh\tharness-unsupported\t\n' ;;
|
|
225
|
+
esac
|
|
226
|
+
return 0
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# The diff between the attempt the carried findings judged and the one under review now, as
|
|
230
|
+
# files a later gate can reuse (tasks/<id>/changes.since.{diff,range,stat}). All three go
|
|
231
|
+
# when there is no valid range: no findings, no recorded commit, a commit that is unknown or
|
|
232
|
+
# no ancestor of the reviewed one, or nothing changed.
|
|
233
|
+
attempt_delta() { # attempt_delta <tdir> <worktree> <reviewed-commit>
|
|
234
|
+
local tdir="$1" wt="$2" reviewed="$3" from="" to=""
|
|
235
|
+
rm -f "$tdir/.changes.since.diff" "$tdir/.changes.since.stat"
|
|
236
|
+
if [ -s "$tdir/retry_findings.md" ] && [ -s "$tdir/retry_findings.commit" ] \
|
|
237
|
+
&& from="$(git -C "$wt" rev-parse --verify -q "$(cat "$tdir/retry_findings.commit")^{commit}")" \
|
|
238
|
+
&& to="$(git -C "$wt" rev-parse --verify -q "$reviewed^{commit}")" \
|
|
239
|
+
&& git -C "$wt" merge-base --is-ancestor "$from" "$to" \
|
|
240
|
+
&& git -C "$wt" diff --binary "$from" "$to" -- . ':(exclude).forge' > "$tdir/.changes.since.diff" 2>/dev/null \
|
|
241
|
+
&& [ -s "$tdir/.changes.since.diff" ] \
|
|
242
|
+
&& git -C "$wt" diff --stat "$from" "$to" -- . ':(exclude).forge' > "$tdir/.changes.since.stat" 2>/dev/null; then
|
|
243
|
+
printf '%s %s\n' "$from" "$to" > "$tdir/changes.since.range"
|
|
244
|
+
mv "$tdir/.changes.since.diff" "$tdir/changes.since.diff"
|
|
245
|
+
mv "$tdir/.changes.since.stat" "$tdir/changes.since.stat"
|
|
246
|
+
return 0
|
|
247
|
+
fi
|
|
248
|
+
rm -f "$tdir/.changes.since.diff" "$tdir/.changes.since.stat" \
|
|
249
|
+
"$tdir/changes.since.diff" "$tdir/changes.since.range" "$tdir/changes.since.stat"
|
|
250
|
+
return 0
|
|
251
|
+
}
|
|
252
|
+
|
|
55
253
|
# Shared by `retry` (a human asked) and the scheduler's automatic retries. The findings
|
|
56
254
|
# are the whole point of retrying rather than re-running: the dwarf is told what was
|
|
57
255
|
# wrong with its own previous attempt.
|
|
@@ -66,30 +264,57 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
|
|
|
66
264
|
elif [ "$st" = UNKNOWN ] && [ -s "$tdir/reviewed.commit" ]; then
|
|
67
265
|
# The reviewer never reached a verdict, so the code was not rejected. Re-asking QA is
|
|
68
266
|
# cheaper and more honest than paying an implementer to rewrite unjudged work; the
|
|
69
|
-
# marker is
|
|
267
|
+
# marker is validated against the worktree below before it is believed.
|
|
70
268
|
echo qa > "$tdir/resume"
|
|
71
|
-
note "$id: the reviewer gave no verdict — re-running QA on the unchanged work"
|
|
72
269
|
# Only a real review replaces the findings. The runner also writes explanatory text
|
|
73
270
|
# into qa.last when it fails a task without dispatching QA ("produced no changes at
|
|
74
271
|
# all"), and copying that over would throw away the actual findings the previous
|
|
75
272
|
# reviewer gave — the one thing a retry exists to carry forward.
|
|
76
273
|
elif [ "$verdict" = PASS ] || [ "$verdict" = FAIL ]; then
|
|
77
|
-
|
|
274
|
+
carry_findings "$tdir"
|
|
78
275
|
elif grep -q 'FORGE_VERDICT' "$tdir/qa.last" 2>/dev/null; then
|
|
79
|
-
|
|
276
|
+
carry_findings "$tdir"
|
|
80
277
|
elif [ -s "$tdir/retry_findings.md" ]; then
|
|
81
278
|
note "$id: no new review since the last retry — reusing the previous findings"
|
|
82
279
|
else
|
|
83
280
|
note "$id: no previous QA findings to pass on (last status was $st)"
|
|
84
281
|
: > "$tdir/retry_findings.md"
|
|
282
|
+
rm -f "$tdir/retry_findings.commit" "$tdir/retry_findings.session" "$tdir/retry_findings.resolved"
|
|
283
|
+
fi
|
|
284
|
+
# A QA-only resume (an unreviewed verdict, or a QA-stage infrastructure stop) re-asks the
|
|
285
|
+
# reviewer about work that is already committed; the dwarf does not run, so it is no moment
|
|
286
|
+
# to change the implementer. Both branches above wrote the `resume` marker; it holds only
|
|
287
|
+
# while the worktree is still what was reviewed, which do_task_body checks again.
|
|
288
|
+
local qa_only=0
|
|
289
|
+
if resume_check "$PLAN" "$id"; then
|
|
290
|
+
qa_only=1
|
|
291
|
+
[ "$st" = UNKNOWN ] && note "$id: the reviewer gave no verdict — re-running QA on the unchanged work"
|
|
85
292
|
fi
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
293
|
+
# Who decides the next implementer, in this order: an explicit --dwarf (the human's call,
|
|
294
|
+
# source `retry`), a pin (the human's call, applied once, source `pin`), then the retry
|
|
295
|
+
# policy (forge-route.py escalation): after escalate_after failed attempts on one model
|
|
296
|
+
# (default 3) the next attempt goes a tier up in the user's own pools instead of repeating
|
|
297
|
+
# the dispatch whose fourth try passes least often, or the task stops with a split
|
|
298
|
+
# suggestion.
|
|
299
|
+
local cur next rc esc_source="" pinned="" has_pin=0
|
|
91
300
|
cur="$(field "$T" "$id" dwarf)"
|
|
92
|
-
|
|
301
|
+
[ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] && has_pin=1
|
|
302
|
+
if [ -n "$newdwarf" ]; then
|
|
303
|
+
esc_source=retry
|
|
304
|
+
rm -f "$tdir/escalation.stop" "$tdir/split.suggested" "$tdir/escalation.next"
|
|
305
|
+
elif [ "$has_pin" = 1 ] && [ "$qa_only" = 1 ]; then
|
|
306
|
+
note "$id: the escalation pin waits for the next full retry (this one only re-runs QA)"
|
|
307
|
+
elif [ "$has_pin" = 1 ] && forge_plan_is_fractal "$PLAN"; then
|
|
308
|
+
note "$id: an escalation pin is set, but Fractal plans never apply pins — leaving it in place"
|
|
309
|
+
elif [ "$has_pin" = 1 ] && ! escalation_record_ok "$PLAN" "$id"; then
|
|
310
|
+
# Before the claim: a refused record leaves the pin unclaimed and tasks.tsv as it was.
|
|
311
|
+
note "$id: could not record the escalation in escalations.tsv; nothing was retried"
|
|
312
|
+
return 1
|
|
313
|
+
elif [ "$has_pin" = 1 ] && pinned="$(claim_pin "$tdir")"; then
|
|
314
|
+
esc_source=pin
|
|
315
|
+
newdwarf="$pinned"
|
|
316
|
+
note "$id: applying the pinned escalation to $pinned"
|
|
317
|
+
else
|
|
93
318
|
next="$(python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$PLAN" --task "$id")"; rc=$?
|
|
94
319
|
if [ "$rc" = 3 ]; then
|
|
95
320
|
note "$(cat "$tdir/split.suggested" 2>/dev/null)"
|
|
@@ -98,36 +323,97 @@ prepare_retry() { # prepare_retry <plan> <id> [new-dwarf]
|
|
|
98
323
|
if [ -n "$next" ]; then
|
|
99
324
|
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
325
|
newdwarf="$next"
|
|
326
|
+
esc_source=policy
|
|
101
327
|
fi
|
|
102
|
-
else
|
|
103
|
-
rm -f "$tdir/escalation.stop" "$tdir/split.suggested" "$tdir/escalation.next"
|
|
104
328
|
fi
|
|
105
|
-
|
|
329
|
+
# The same spec again is no escalation, except a pin: a human asked for it, so the
|
|
330
|
+
# request is recorded and consumed.
|
|
331
|
+
if [ "$newdwarf" = "$cur" ] && [ "$esc_source" != pin ]; then newdwarf=""; fi
|
|
106
332
|
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"
|
|
333
|
+
write_escalation "$PLAN" "$id" "$cur" "$newdwarf" "$esc_source" || return 1
|
|
124
334
|
fi
|
|
125
335
|
rm -f "$tdir/noretry"
|
|
126
336
|
}
|
|
127
337
|
|
|
338
|
+
# Puts the next implementer in place: tasks.tsv, the escalations.tsv record and the dataset
|
|
339
|
+
# event. Returns 1, with tasks.tsv and a claimed pin given back, when nothing was changed.
|
|
340
|
+
write_escalation() { # write_escalation <plan> <id> <from> <to> <retry|pin|policy>
|
|
341
|
+
local PLAN="$1" id="$2" cur="$3" newdwarf="$4" esc_source="$5" tdir="$1/tasks/$2" line
|
|
342
|
+
# A pin is held to its record: whether the record can be written is learned before
|
|
343
|
+
# anything is changed, so a failure there leaves tasks.tsv and the pin as they were.
|
|
344
|
+
if [ "$esc_source" = pin ] && ! escalation_record_ok "$PLAN" "$id"; then
|
|
345
|
+
note "$id: could not record the escalation in escalations.tsv; nothing was retried"
|
|
346
|
+
settle_pin "$tdir" restore
|
|
347
|
+
return 1
|
|
348
|
+
fi
|
|
349
|
+
# Written into tasks.tsv rather than kept in a side file: the table then shows the
|
|
350
|
+
# model that will actually be spent, and plan's rule that an explicit column value
|
|
351
|
+
# survives a re-plan protects the escalation for free. Rewritten first, so a failed
|
|
352
|
+
# rewrite is never recorded as an escalation that happened.
|
|
353
|
+
if [ "$newdwarf" != "$cur" ]; then
|
|
354
|
+
set_dwarf "$PLAN" "$id" "$newdwarf" || {
|
|
355
|
+
note "$id: could not write $newdwarf into tasks.tsv; nothing was retried"
|
|
356
|
+
[ "$esc_source" = pin ] && settle_pin "$tdir" restore
|
|
357
|
+
return 1
|
|
358
|
+
}
|
|
359
|
+
fi
|
|
360
|
+
# The rewrite loses which model failed first, and an escalation is exactly the record
|
|
361
|
+
# of "this tier was too weak for this task". The last column says who decided: forge's
|
|
362
|
+
# policy, a human's `retry --dwarf`, or a pin.
|
|
363
|
+
line="$(printf '%s\t%s\t%s\t%s\t%s' "$(date +%s)" "$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
|
|
364
|
+
"$cur" "$newdwarf" "$esc_source")"
|
|
365
|
+
if ! escalation_record_line "$PLAN" "$id" "$line" && [ "$esc_source" = pin ]; then
|
|
366
|
+
# The rewrite is undone with the record, so the pin given back is still a real
|
|
367
|
+
# escalation next time, not a same-spec one that hides the model it replaced.
|
|
368
|
+
if [ "$newdwarf" = "$cur" ] || set_dwarf "$PLAN" "$id" "$cur"; then
|
|
369
|
+
note "$id: could not record the escalation in escalations.tsv; nothing was retried"
|
|
370
|
+
settle_pin "$tdir" restore
|
|
371
|
+
return 1
|
|
372
|
+
fi
|
|
373
|
+
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"
|
|
374
|
+
fi
|
|
375
|
+
# Recorded at the rewrite itself: it happens before the retried attempt begins, so no
|
|
376
|
+
# attempt capture would ever see it as something that attempt produced.
|
|
377
|
+
forge_dataset_event escalation --dir "$PLAN" --task "$id" --set "from=$cur" \
|
|
378
|
+
--set "to=$newdwarf" --set "after_attempt=$(cat "$tdir/attempt" 2>/dev/null || echo 0)" \
|
|
379
|
+
--set "source=$esc_source"
|
|
380
|
+
# A pin, like an explicit --dwarf, settles the retry policy's verdicts about this task.
|
|
381
|
+
[ "$esc_source" = pin ] && rm -f "$tdir/escalation.stop" "$tdir/split.suggested"
|
|
382
|
+
rm -f "$tdir/escalation.next"
|
|
383
|
+
[ "$esc_source" = pin ] && settle_pin "$tdir" done
|
|
384
|
+
return 0
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# An infrastructure stop dispatched again by the scheduler. It is no retry: no attempt or
|
|
388
|
+
# budget is spent, the findings carried forward stay, and the retry policy is not asked. But
|
|
389
|
+
# unless the stop came at QA (and the reviewed work is intact), the entry is a full
|
|
390
|
+
# implementer attempt, so a pending pin applies here exactly as prepare_retry applies it.
|
|
391
|
+
# Returns 1 when a claimed pin could not be put in place; the task is then not resumed.
|
|
392
|
+
prepare_resume() { # prepare_resume <plan> <id>
|
|
393
|
+
local PLAN="$1" id="$2" tdir="$1/tasks/$2" pinned
|
|
394
|
+
[ -f "$tdir/escalation.pin" ] && [ ! -L "$tdir/escalation.pin" ] || return 0
|
|
395
|
+
if resume_check "$PLAN" "$id"; then
|
|
396
|
+
note "$id: the escalation pin waits for the next full retry (this resume only re-runs QA)"
|
|
397
|
+
elif forge_plan_is_fractal "$PLAN"; then
|
|
398
|
+
note "$id: an escalation pin is set, but Fractal plans never apply pins — leaving it in place"
|
|
399
|
+
elif ! escalation_record_ok "$PLAN" "$id"; then
|
|
400
|
+
note "$id: could not record the escalation in escalations.tsv; nothing was retried"
|
|
401
|
+
return 1
|
|
402
|
+
elif pinned="$(claim_pin "$tdir")"; then
|
|
403
|
+
note "$id: applying the pinned escalation to $pinned"
|
|
404
|
+
write_escalation "$PLAN" "$id" "$(field "$PLAN/tasks.tsv" "$id" dwarf)" "$pinned" pin || return 1
|
|
405
|
+
fi
|
|
406
|
+
return 0
|
|
407
|
+
}
|
|
408
|
+
|
|
128
409
|
# 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
|
-
|
|
410
|
+
# reads as PENDING again. `retry` clears it itself, after its own preflight. With --resume it
|
|
411
|
+
# prepares an infrastructure stop's resume instead (prepare_resume), and the status stays.
|
|
412
|
+
retry_for_scheduler() { # retry_for_scheduler <plan> <id> [--resume]
|
|
413
|
+
if [ "${3:-}" = --resume ]; then
|
|
414
|
+
prepare_resume "$1" "$2"
|
|
415
|
+
return $?
|
|
416
|
+
fi
|
|
131
417
|
prepare_retry "$1" "$2" || return $?
|
|
132
418
|
rm -f "$1/tasks/$2/status"
|
|
133
419
|
}
|
|
@@ -173,9 +459,17 @@ guard_after_dwarf() { # guard_after_dwarf <wt> <tdir> <id>
|
|
|
173
459
|
|
|
174
460
|
# --- QA severity gate -----------------------------------------------------------
|
|
175
461
|
qa_threshold_for() { # per-task file wins over the plan-level default; prints nothing if unset
|
|
176
|
-
|
|
462
|
+
# A per-task "none" exempts the task from a plan-wide threshold (prints nothing, no fallback).
|
|
463
|
+
# A plan-level "none" is the same as no file. Anything but P0-P3 or none is ignored, so a
|
|
464
|
+
# garbage per-task value falls back to the plan file.
|
|
465
|
+
local f v
|
|
177
466
|
for f in "$1/tasks/$2/qa_threshold" "$1/qa_threshold"; do
|
|
178
|
-
[ -s "$f" ]
|
|
467
|
+
[ -s "$f" ] || continue
|
|
468
|
+
v="$(head -1 "$f" | tr -d ' \r' | tr 'a-z' 'A-Z')"
|
|
469
|
+
case "$v" in
|
|
470
|
+
P0|P1|P2|P3) printf '%s\n' "$v"; return 0 ;;
|
|
471
|
+
NONE) [ "$f" = "$1/qa_threshold" ] || return 0 ;;
|
|
472
|
+
esac
|
|
179
473
|
done
|
|
180
474
|
return 0
|
|
181
475
|
}
|
|
@@ -184,10 +478,13 @@ qa_threshold_for() { # per-task file wins over the plan-level default; prints no
|
|
|
184
478
|
# and less severe than the threshold is accepted as PASS-with-known-issues. The literal
|
|
185
479
|
# status stays PASS, so the merge path, the scheduler and Fractal need no new vocabulary.
|
|
186
480
|
# Anything unlabelled stays blocking: a finding the gate cannot place is not tolerated.
|
|
187
|
-
|
|
481
|
+
# The optional 5th argument is the threshold the caller already used for this attempt's review
|
|
482
|
+
# prompt; when given (even empty) it is used as-is, so a change to the files between the prompt
|
|
483
|
+
# and the gate cannot split one attempt across two thresholds. Absent, the files are read.
|
|
484
|
+
qa_gate() { # qa_gate <plan> <id> <tdir> <verdict> [threshold]
|
|
188
485
|
local plan="$1" id="$2" tdir="$3" verdict="$4" thr line
|
|
189
486
|
rm -f "$tdir/qa.gate" "$tdir/known_issues.md"
|
|
190
|
-
thr="$(qa_threshold_for "$plan" "$id")"
|
|
487
|
+
if [ "$#" -ge 5 ]; then thr="$5"; else thr="$(qa_threshold_for "$plan" "$id")"; fi
|
|
191
488
|
[ -n "$thr" ] || return 0
|
|
192
489
|
line="$(python3 "$SKILL_DIR/scripts/forge-contract.py" gate --verdict "${verdict:-UNKNOWN}" \
|
|
193
490
|
--qa-last "$tdir/qa.last" --threshold "$thr" --known-issues "$tdir/known_issues.md" 2>/dev/null)" || return 0
|
|
@@ -265,10 +562,30 @@ report_size() { # report_size <plan>
|
|
|
265
562
|
|
|
266
563
|
# The retry policy's verdicts: a task that stopped with a split suggestion, or whose next
|
|
267
564
|
# retry would go a tier up. Decided after every attempt, so a spent budget still says it.
|
|
565
|
+
# A pending pin is read from the file itself: it can be set between two attempts, and it
|
|
566
|
+
# outranks both verdicts (the retry applies it first). The converse needs care too: a pin
|
|
567
|
+
# withdrawn since the last attempt leaves the `escalation.next` that `--check` wrote for it,
|
|
568
|
+
# so a marker found with no pin pending is decided again rather than trusted.
|
|
268
569
|
report_escalations() { # report_escalations <plan>
|
|
269
|
-
local plan="$1" id n=0 head
|
|
570
|
+
local plan="$1" id n=0 head pin
|
|
270
571
|
head="retry policy (after $(cat "$plan/escalate_after" 2>/dev/null || echo 3) failed attempts on one model):"
|
|
271
572
|
for id in $(all_ids "$plan/tasks.tsv"); do
|
|
573
|
+
pin="$(pin_spec "$plan/tasks/$id/escalation.pin")"
|
|
574
|
+
[ -n "$pin" ] && forge_plan_is_fractal "$plan" && pin=""
|
|
575
|
+
if [ -n "$pin" ] && [ "$(task_status "$plan" "$id")" != MERGED ]; then
|
|
576
|
+
[ "$n" = 0 ] && echo "$head"
|
|
577
|
+
n=$((n+1))
|
|
578
|
+
if qa_only_next "$plan" "$id"; then
|
|
579
|
+
printf ' %-14s its next retry goes to %s (pinned; after the QA-only re-run)\n' "$id" "$pin"
|
|
580
|
+
else
|
|
581
|
+
printf ' %-14s its next retry goes to %s (pinned)\n' "$id" "$pin"
|
|
582
|
+
fi
|
|
583
|
+
continue
|
|
584
|
+
fi
|
|
585
|
+
if [ -s "$plan/tasks/$id/escalation.next" ] && [ "$(task_status "$plan" "$id")" != MERGED ]; then
|
|
586
|
+
python3 "$SKILL_DIR/scripts/forge-route.py" escalation --plan "$plan" --task "$id" --check \
|
|
587
|
+
--qa-only-next "$(qa_only_next_flag "$plan" "$id")" >/dev/null 2>&1 || true
|
|
588
|
+
fi
|
|
272
589
|
if [ -s "$plan/tasks/$id/split.suggested" ]; then
|
|
273
590
|
[ "$n" = 0 ] && echo "$head"
|
|
274
591
|
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
|