@giamme/forge 0.9.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +19 -1
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +17 -2
  5. package/docs/commands.md +50 -0
  6. package/docs/dataset.md +9 -0
  7. package/docs/decomposed-runs.md +42 -0
  8. package/docs/forge-ui.md +42 -6
  9. package/docs/reviews.md +14 -0
  10. package/forge +15 -0
  11. package/package.json +1 -1
  12. package/references/dataset.md +18 -1
  13. package/references/decompose.md +19 -1
  14. package/references/ui.md +27 -5
  15. package/scripts/forge-config.py +347 -0
  16. package/scripts/forge-control.py +645 -0
  17. package/scripts/forge-issues.py +806 -0
  18. package/scripts/forge-lib-task.sh +234 -35
  19. package/scripts/forge-parallel-options.sh +3 -1
  20. package/scripts/forge-parallel.sh +3 -2
  21. package/scripts/forge-route.py +184 -27
  22. package/scripts/forge-runs-rm.py +840 -0
  23. package/scripts/forge-runs.py +19 -1
  24. package/scripts/forge-schedule.py +227 -11
  25. package/scripts/forge-ui.py +85 -19
  26. package/scripts/forge_dataset/__init__.py +77 -6
  27. package/scripts/forge_dataset/capture.py +5 -2
  28. package/scripts/forge_dataset/harvest.py +22 -0
  29. package/scripts/forge_dataset/importer.py +419 -17
  30. package/scripts/forge_registry.py +168 -16
  31. package/ui/README.md +37 -4
  32. package/ui/docs/api.md +184 -6
  33. package/ui/forge_ui/__main__.py +10 -2
  34. package/ui/forge_ui/control.py +575 -0
  35. package/ui/forge_ui/export.py +17 -3
  36. package/ui/forge_ui/reader.py +26 -1
  37. package/ui/forge_ui/server.py +125 -4
  38. package/ui/forge_ui/static/app.js +80 -8
  39. package/ui/forge_ui/static/controls.js +619 -0
  40. package/ui/forge_ui/static/findingsview.js +412 -0
  41. package/ui/forge_ui/static/index.html +7 -1
  42. package/ui/forge_ui/static/panel.js +24 -4
  43. package/ui/forge_ui/static/rundelete.js +388 -0
  44. package/ui/forge_ui/static/settings.js +399 -0
  45. package/ui/forge_ui/static/style.css +177 -2
  46. package/ui/forge_ui/static/uikit.js +161 -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 revalidated against the worktree before it is believed.
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
- # The retry policy (forge-route.py escalation): after escalate_after failed attempts on
87
- # one model (default 3), the next attempt goes a tier up in the user's own pools instead
88
- # of repeating the dispatch whose fourth try passes least often, or the task stops with a
89
- # split suggestion. An explicit --dwarf is the human's call and skips the policy.
90
- local cur next rc
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
- if [ -z "$newdwarf" ]; then
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
- if [ "$newdwarf" = "$cur" ]; then newdwarf=""; fi # the same spec again is no escalation
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
- # Written into tasks.tsv rather than kept in a side file: the table then shows the
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
- retry_for_scheduler() { # retry_for_scheduler <plan> <id>
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
- local f
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" ] && { head -1 "$f" | tr -d ' \r'; return 0; }
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
- qa_gate() { # qa_gate <plan> <id> <tdir> <verdict>
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
- qa_gate "$PLAN" "$id" "$tdir" "$verdict"
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
@@ -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 or ('escalation.stop', 'split.suggested', 'escalation.next'):
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
- (tdir / name).unlink()
370
+ os.unlink(tmp)
282
371
  except OSError:
283
372
  pass
373
+ raise
284
374
 
285
375
 
286
- def escalation(args) -> int:
287
- plan = Path(args.plan)
288
- tdir = plan / 'tasks' / args.task
289
- after = _setting(plan)
290
- # Fractal plans freeze their eligible set at selection; a QA-only resume spends no dwarf
291
- # attempt, so neither is a moment to change the implementer.
292
- if after <= 0 or _fractal(plan) or (tdir / 'resume').exists():
293
- if after <= 0:
294
- _clear(tdir)
295
- return 0
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
- _clear(tdir)
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
- _clear(tdir, 'escalation.stop', 'split.suggested')
307
- if args.check:
308
- (tdir / 'escalation.next').write_text(target + '\n')
309
- print(target)
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
- _clear(tdir, 'escalation.next')
312
- (tdir / 'escalation.stop').write_text('%d\t%s\n' % (streak, current))
313
- (tdir / 'split.suggested').write_text(
314
- '%s failed %d attempts on %s, and %s. Another attempt on the same model rarely passes, '
315
- 'so split the task into smaller ones, or retry it with an explicit --dwarf <spec>.\n'
316
- % (args.task, streak, current, why))
317
- return 3
472
+ if shown:
473
+ print(shown)
474
+ return rc
318
475
 
319
476
 
320
477
  def main(argv=None) -> int: