@giamme/forge 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# forge-dispatch.sh — resolve a forge spec (alias[:effort[:harness]]) into a real
|
|
3
|
+
# CLI invocation and run it. Harness-agnostic: needs only bash + coreutils, so it
|
|
4
|
+
# behaves identically whether the orchestrator is codex, claude, openclaude or
|
|
5
|
+
# opencode.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# forge-dispatch.sh doctor (harnesses: codex claude openclaude opencode antigravity grok)
|
|
9
|
+
# forge-dispatch.sh dwarf <spec> --prompt-file <f> [--repo <dir>] [--run-dir <d>] [--yolo] [--dry-run]
|
|
10
|
+
# forge-dispatch.sh qa <spec> --prompt-file <f> [--repo <dir>] [--run-dir <d>] [--yolo] [--dry-run]
|
|
11
|
+
# [--native-review [--review-base <ref>]]
|
|
12
|
+
# any role: [--no-ripwire] [--ripwire-query-file <requirements>]
|
|
13
|
+
# any role: [--timeout <seconds>] 0 disables the watchdog
|
|
14
|
+
#
|
|
15
|
+
# Timeout precedence: --timeout, then FORGE_TIMEOUT (both used as given, 0 included),
|
|
16
|
+
# then the registry row's optional 5th `timeout` column, then the built-in 2700 (45m).
|
|
17
|
+
# A registry or built-in value is scaled by the resolved effort: <= high x1,
|
|
18
|
+
# xhigh x4/3, max/ultra/ultracode x2. The limit and its source are recorded as
|
|
19
|
+
# `timeout=` / `timeout_source=` in $ROLE.resolved.
|
|
20
|
+
#
|
|
21
|
+
# Environment: FORGE_REGISTRY alternate registry file (default: <skill>/registry.tsv)
|
|
22
|
+
# FORGE_ORPHAN_GUARD off = do not run the harness in its own process group
|
|
23
|
+
# (default on; also skipped under FORGE_FRACTAL_RUN)
|
|
24
|
+
#
|
|
25
|
+
# Exit codes: 0 ok | 2 usage/resolution error | 3 harness missing or unusable
|
|
26
|
+
# 4 backend ran but failed | 7 backend exceeded --timeout and was killed
|
|
27
|
+
# 8 infrastructure failure (auth, quota, rate limit, network, or an empty
|
|
28
|
+
# answer): the model never got to do its work. $ROLE.infra says which.
|
|
29
|
+
# A DWARF that exits 0 with an empty final message also writes $ROLE.infra
|
|
30
|
+
# (class=empty) but still exits 0; consult the file only if the diff is empty.
|
|
31
|
+
# A process still alive in the harness's group after it exited is killed and listed in
|
|
32
|
+
# $ROLE.orphans (only written when there is something to list).
|
|
33
|
+
set -uo pipefail
|
|
34
|
+
|
|
35
|
+
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
36
|
+
REGISTRY="${FORGE_REGISTRY:-$SKILL_DIR/registry.tsv}"
|
|
37
|
+
RUNTIME="$SKILL_DIR/scripts/forge-runtime.py"
|
|
38
|
+
GUARD="$SKILL_DIR/scripts/forge-guard.py"
|
|
39
|
+
|
|
40
|
+
die() { printf 'forge: %s\n' "$1" >&2; exit "${2:-2}"; }
|
|
41
|
+
note() { printf 'forge: %s\n' "$*" >&2; }
|
|
42
|
+
|
|
43
|
+
# --- effort ladders -----------------------------------------------------------
|
|
44
|
+
# codex does NOT validate model_reasoning_effort: a typo runs at some silent
|
|
45
|
+
# default instead of erroring. So forge validates before dispatch — a run at the
|
|
46
|
+
# wrong effort is expensive and invisible, which is exactly the failure worth
|
|
47
|
+
# spending ten lines to prevent.
|
|
48
|
+
ladder_for() {
|
|
49
|
+
case "$1" in
|
|
50
|
+
codex) echo "low medium high xhigh max ultra" ;;
|
|
51
|
+
claude) echo "low medium high xhigh max" ;;
|
|
52
|
+
openclaude) echo "low medium high xhigh max ultracode" ;;
|
|
53
|
+
antigravity) echo "low medium high" ;;
|
|
54
|
+
grok) echo "low medium high xhigh max" ;; # each model offers a subset (registry)
|
|
55
|
+
opencode) echo "" ;; # --variant is provider-specific; forwarded verbatim
|
|
56
|
+
*) echo "" ;;
|
|
57
|
+
esac
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# The harness name is not always the binary name.
|
|
61
|
+
binary_for() {
|
|
62
|
+
case "$1" in antigravity) echo agy ;; *) echo "$1" ;; esac
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Every effort word forge recognizes, weakest first. Used only to tell "you asked
|
|
66
|
+
# for more thinking than this pairing offers" (clamp, keep going) apart from "that
|
|
67
|
+
# is not an effort level" (stop). ultra and ultracode are each their harness's top
|
|
68
|
+
# tier under different names, so they rank together.
|
|
69
|
+
CANON="low medium high xhigh max ultra ultracode"
|
|
70
|
+
rank_of() {
|
|
71
|
+
case "$1" in
|
|
72
|
+
low) echo 0 ;; medium) echo 1 ;; high) echo 2 ;; xhigh) echo 3 ;;
|
|
73
|
+
max) echo 4 ;; ultra|ultracode) echo 5 ;; *) echo -1 ;;
|
|
74
|
+
esac
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
idx_in() { # idx_in <needle> <space-separated haystack> -> index or -1
|
|
78
|
+
local n="$1" i=0 w
|
|
79
|
+
for w in $2; do [ "$w" = "$n" ] && { echo "$i"; return; }; i=$((i+1)); done
|
|
80
|
+
echo -1
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# --- registry lookup ----------------------------------------------------------
|
|
84
|
+
# The fourth and fifth fields print "-" when empty, because `IFS=$'\t' read` treats tab as
|
|
85
|
+
# whitespace and would collapse an empty field into its neighbour.
|
|
86
|
+
lookup() { # lookup <alias> [harness] -> "harness<TAB>model<TAB>ceiling<TAB>timeout"
|
|
87
|
+
local alias="$1" want="${2:-}"
|
|
88
|
+
awk -F'\t' -v a="$alias" -v h="$want" '
|
|
89
|
+
/^#/ || NF < 3 { next }
|
|
90
|
+
$1 == a && (h == "" || $2 == h) {
|
|
91
|
+
t = $5; gsub(/[ \t\r]+$/, "", t)
|
|
92
|
+
print $2 "\t" $3 "\t" ($4 == "" ? "-" : $4) "\t" (t == "" ? "-" : t); exit
|
|
93
|
+
}
|
|
94
|
+
' "$REGISTRY"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
known_harness() {
|
|
98
|
+
case "$1" in codex|claude|openclaude|opencode|antigravity|grok) return 0 ;; *) return 1 ;; esac
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# --- doctor -------------------------------------------------------------------
|
|
102
|
+
doctor() {
|
|
103
|
+
local rc=0 h bin
|
|
104
|
+
printf '%-12s %-10s %s\n' HARNESS STATUS DETAIL
|
|
105
|
+
for h in codex claude openclaude opencode antigravity grok; do
|
|
106
|
+
bin="$(command -v "$(binary_for "$h")" 2>/dev/null)"
|
|
107
|
+
if [ -z "$bin" ]; then
|
|
108
|
+
printf '%-12s %-10s %s\n' "$h" MISSING "$(binary_for "$h") not on PATH"; rc=1; continue
|
|
109
|
+
fi
|
|
110
|
+
printf '%-12s %-10s %s\n' "$h" "found" "$bin"
|
|
111
|
+
done
|
|
112
|
+
python3 "$SKILL_DIR/scripts/forge-ripwire.py" --doctor
|
|
113
|
+
cat <<'EOT'
|
|
114
|
+
|
|
115
|
+
Binary presence is not reachability. A harness can be installed and still fail on
|
|
116
|
+
auth or billing (opencode in particular fails this way), and that failure only
|
|
117
|
+
shows up mid-dispatch. To prove a pairing end to end before spending a real run:
|
|
118
|
+
|
|
119
|
+
forge-dispatch.sh dwarf <spec> --prompt-file /dev/stdin --repo <scratch dir> \
|
|
120
|
+
<<< 'Reply with exactly: FORGE_OK'
|
|
121
|
+
EOT
|
|
122
|
+
return $rc
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
[ $# -ge 1 ] || die "usage: forge-dispatch.sh <doctor|dwarf|qa|planner> [spec] [options]"
|
|
126
|
+
ROLE="$1"; shift
|
|
127
|
+
if [ "$ROLE" = report ]; then python3 "$RUNTIME" report "${1:?report needs a run directory}"; exit $?; fi
|
|
128
|
+
PREFLIGHT=0
|
|
129
|
+
if [ "$ROLE" = doctor ]; then
|
|
130
|
+
if [ $# = 0 ]; then doctor; exit $?; fi
|
|
131
|
+
[ "${1:-}" = --spec ] || die "doctor accepts --spec <spec> [--role dwarf|qa|planner]"
|
|
132
|
+
SPEC_CHECK="${2:?--spec needs a spec}"; shift 2
|
|
133
|
+
ROLE=dwarf
|
|
134
|
+
if [ "${1:-}" = --role ]; then ROLE="${2:?}"; shift 2; fi
|
|
135
|
+
PREFLIGHT=1
|
|
136
|
+
CHECK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/forge-doctor.XXXXXX")" || exit 3
|
|
137
|
+
trap 'rm -rf "$CHECK_DIR"' EXIT
|
|
138
|
+
echo 'Offline capability check' > "$CHECK_DIR/input"
|
|
139
|
+
set -- "$SPEC_CHECK" --run-dir "$CHECK_DIR" --prompt-file "$CHECK_DIR/input" "$@"
|
|
140
|
+
fi
|
|
141
|
+
case "$ROLE" in dwarf|qa|planner) ;; *) die "unknown role '$ROLE' (expected dwarf, qa, planner or doctor)" ;; esac
|
|
142
|
+
|
|
143
|
+
# qa and planner both read the repo and write nothing to it: a reviewer that can
|
|
144
|
+
# edit the diff is no longer an independent check of it, and a planner that edits
|
|
145
|
+
# has quietly become the dwarf. They share one permission profile.
|
|
146
|
+
read_only_role() { case "$ROLE" in qa|planner) return 0 ;; *) return 1 ;; esac; }
|
|
147
|
+
[ $# -ge 1 ] || die "role '$ROLE' needs a spec, e.g. sol:xhigh:openclaude"
|
|
148
|
+
SPEC="$1"; shift
|
|
149
|
+
|
|
150
|
+
RIPWIRE_QUERY=""
|
|
151
|
+
OUTPUT=full
|
|
152
|
+
REPO="$PWD"; RUN_DIR=""; PROMPT_FILE=""; YOLO=0; DRY=0; REVIEW_BASE=""; PROMPT_VIA_STDIN=0; NATIVE_REVIEW=0; AGY_TIMEOUT="30m"; TIMEOUT_FLAG=""
|
|
153
|
+
while [ $# -gt 0 ]; do
|
|
154
|
+
case "$1" in
|
|
155
|
+
--no-ripwire) export FORGE_RIPWIRE=off; shift ;;
|
|
156
|
+
--ripwire-query-file) RIPWIRE_QUERY="${2:?}"; shift 2 ;;
|
|
157
|
+
--output) OUTPUT="${2:?}"; shift 2 ;;
|
|
158
|
+
--repo) REPO="${2:?--repo needs a value}"; shift 2 ;;
|
|
159
|
+
--run-dir) RUN_DIR="${2:?--run-dir needs a value}"; shift 2 ;;
|
|
160
|
+
--prompt-file) PROMPT_FILE="${2:?--prompt-file needs a value}"; shift 2 ;;
|
|
161
|
+
--review-base) REVIEW_BASE="${2:?--review-base needs a value}"; shift 2 ;;
|
|
162
|
+
--native-review) NATIVE_REVIEW=1; shift ;;
|
|
163
|
+
--agy-timeout) AGY_TIMEOUT="${2:?--agy-timeout needs a value}"; shift 2 ;;
|
|
164
|
+
--timeout) TIMEOUT_FLAG="${2:?--timeout needs a value in seconds, 0 to disable}"; shift 2 ;;
|
|
165
|
+
--yolo) YOLO=1; shift ;;
|
|
166
|
+
--dry-run) DRY=1; shift ;;
|
|
167
|
+
*) die "unknown option '$1'" ;;
|
|
168
|
+
esac
|
|
169
|
+
done
|
|
170
|
+
|
|
171
|
+
case "$OUTPUT" in summary|full) ;; *) die "--output must be summary or full" ;; esac
|
|
172
|
+
# Where the limit comes from is part of the record, so --timeout and FORGE_TIMEOUT are kept
|
|
173
|
+
# apart from the registry and built-in values instead of being folded into one variable.
|
|
174
|
+
case "$TIMEOUT_FLAG" in *[!0-9]*) die "--timeout must be a nonnegative integer" ;; esac
|
|
175
|
+
case "${FORGE_TIMEOUT:-}" in *[!0-9]*) die "FORGE_TIMEOUT must be a nonnegative integer" ;; esac
|
|
176
|
+
[ -z "${FORGE_REGISTRY:-}" ] || [ -f "$REGISTRY" ] || die "FORGE_REGISTRY '$REGISTRY' is not a file"
|
|
177
|
+
|
|
178
|
+
# --- resolve spec -------------------------------------------------------------
|
|
179
|
+
ALIAS="${SPEC%%:*}"; REST="${SPEC#*:}"
|
|
180
|
+
if [ "$REST" = "$SPEC" ]; then EFFORT=""; HARNESS=""
|
|
181
|
+
else
|
|
182
|
+
EFFORT="${REST%%:*}"; H="${REST#*:}"
|
|
183
|
+
if [ "$H" = "$REST" ]; then HARNESS=""; else HARNESS="$H"; fi
|
|
184
|
+
fi
|
|
185
|
+
[ -n "$ALIAS" ] || die "empty alias in spec '$SPEC'"
|
|
186
|
+
[ -z "$HARNESS" ] || known_harness "$HARNESS" \
|
|
187
|
+
|| die "unknown harness '$HARNESS' (expected codex, claude, openclaude, opencode, antigravity or grok)"
|
|
188
|
+
|
|
189
|
+
ROW="$(lookup "$ALIAS" "$HARNESS")"
|
|
190
|
+
REG_TIMEOUT="-"
|
|
191
|
+
if [ -n "$ROW" ]; then
|
|
192
|
+
IFS=$'\t' read -r HARNESS MODEL CEILING REG_TIMEOUT <<< "$ROW"
|
|
193
|
+
PASSTHRU=0
|
|
194
|
+
else
|
|
195
|
+
# Not in the registry. Treat the alias as a literal model id — but only with an
|
|
196
|
+
# explicit harness, because there is no sane default for a model forge has
|
|
197
|
+
# never seen, and guessing wrong burns a real dispatch.
|
|
198
|
+
[ -n "$HARNESS" ] || die "'$ALIAS' is not a known alias. Either add it to $REGISTRY or name the harness explicitly, e.g. --dwarf $ALIAS:high:opencode"
|
|
199
|
+
if [ -n "$(awk -F'\t' -v a="$ALIAS" '!/^#/ && $1 == a {print; exit}' "$REGISTRY")" ]; then
|
|
200
|
+
note "alias '$ALIAS' has no row for harness '$HARNESS' — using it as a literal model id"
|
|
201
|
+
else
|
|
202
|
+
note "'$ALIAS' is not in the registry — using it as a literal model id on $HARNESS"
|
|
203
|
+
fi
|
|
204
|
+
MODEL="$ALIAS"; CEILING="-"; PASSTHRU=1
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
if [ "$NATIVE_REVIEW" = 1 ] && { [ "$ROLE" != qa ] || [ "$HARNESS" != codex ]; }; then
|
|
208
|
+
die "--native-review requires QA on codex"
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
# --- resolve effort -----------------------------------------------------------
|
|
212
|
+
LADDER="$(ladder_for "$HARNESS")"
|
|
213
|
+
CLAMPED=""; NO_EFFORT=0
|
|
214
|
+
|
|
215
|
+
# Expand the registry's effort column into the concrete set this pairing accepts.
|
|
216
|
+
# The four forms exist because real backends differ in more than a ceiling: some
|
|
217
|
+
# models offer low and high but no medium, and some take no effort flag at all.
|
|
218
|
+
ALLOWED=""
|
|
219
|
+
case "$CEILING" in
|
|
220
|
+
"-") ALLOWED="" ;; # forwarded verbatim, no validation
|
|
221
|
+
none) NO_EFFORT=1 ;;
|
|
222
|
+
"<="*) top="${CEILING#<=}"
|
|
223
|
+
for w in $LADDER; do ALLOWED="$ALLOWED $w"; [ "$w" = "$top" ] && break; done ;;
|
|
224
|
+
*) ALLOWED="$(printf '%s' "$CEILING" | tr ',' ' ')" ;;
|
|
225
|
+
esac
|
|
226
|
+
|
|
227
|
+
if [ "$NO_EFFORT" = 1 ]; then
|
|
228
|
+
# Not a clamp to report: this model has no effort dial, so an effort the user
|
|
229
|
+
# typed is simply inapplicable. Say so once rather than failing the dispatch.
|
|
230
|
+
[ -n "$EFFORT" ] && note "$MODEL on $HARNESS takes no effort setting — ignoring ':$EFFORT'"
|
|
231
|
+
EFFORT=""
|
|
232
|
+
else
|
|
233
|
+
if [ -z "$EFFORT" ]; then
|
|
234
|
+
# Build at medium, review high: a reviewer that thinks less than the
|
|
235
|
+
# implementer tends to rubber-stamp, which defeats the point of a second model.
|
|
236
|
+
# Planning is the stage where thinking pays for itself most cheaply — a bad
|
|
237
|
+
# plan is executed at full cost by every dwarf downstream of it.
|
|
238
|
+
case "$ROLE" in dwarf) EFFORT="medium" ;; qa|planner) EFFORT="xhigh" ;; esac
|
|
239
|
+
DEFAULTED=1
|
|
240
|
+
[ -n "$LADDER" ] || EFFORT="" # opencode: no ladder, let the provider choose
|
|
241
|
+
else
|
|
242
|
+
DEFAULTED=0
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
if [ -n "$EFFORT" ] && [ -n "$ALLOWED" ]; then
|
|
246
|
+
if [ "$(idx_in "$EFFORT" "$ALLOWED")" -lt 0 ]; then
|
|
247
|
+
want="$(rank_of "$EFFORT")"
|
|
248
|
+
[ "$want" -ge 0 ] || die "'$EFFORT' is not a recognized effort (known: $CANON)"
|
|
249
|
+
# Step down to the strongest accepted effort at or below what was asked;
|
|
250
|
+
# if even the weakest is stronger, take that. Asking for more thinking than
|
|
251
|
+
# a model offers should cost you thinking, not the whole run.
|
|
252
|
+
pick=""
|
|
253
|
+
for w in $ALLOWED; do
|
|
254
|
+
r="$(rank_of "$w")"
|
|
255
|
+
[ "$r" -le "$want" ] && pick="$w"
|
|
256
|
+
done
|
|
257
|
+
if [ -z "$pick" ]; then for w in $ALLOWED; do pick="$w"; break; done; fi
|
|
258
|
+
[ "$DEFAULTED" = 1 ] || CLAMPED="$EFFORT -> $pick"
|
|
259
|
+
EFFORT="$pick"
|
|
260
|
+
fi
|
|
261
|
+
fi
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
if [ -n "$EFFORT" ] && [ -n "$LADDER" ] && [ "$(idx_in "$EFFORT" "$LADDER")" -lt 0 ]; then
|
|
265
|
+
die "invalid effort '$EFFORT' for $HARNESS"
|
|
266
|
+
fi
|
|
267
|
+
|
|
268
|
+
# --- resolve timeout ----------------------------------------------------------
|
|
269
|
+
# Explicit (--timeout, FORGE_TIMEOUT) wins as given, including 0. Otherwise the registry
|
|
270
|
+
# row's `timeout` column, then 2700. Only those two defaults scale with the resolved
|
|
271
|
+
# effort, because an xhigh/max run is expected to think for longer; a number the caller
|
|
272
|
+
# typed is never second-guessed.
|
|
273
|
+
if [ -n "$TIMEOUT_FLAG" ]; then LIMIT="$TIMEOUT_FLAG"; LIMIT_SOURCE=explicit
|
|
274
|
+
elif [ -n "${FORGE_TIMEOUT:-}" ]; then LIMIT="$FORGE_TIMEOUT"; LIMIT_SOURCE=env
|
|
275
|
+
elif [ "$REG_TIMEOUT" != "-" ]; then
|
|
276
|
+
case "$REG_TIMEOUT" in ''|*[!0-9]*) die "registry timeout '$REG_TIMEOUT' for $ALIAS on $HARNESS is not a nonnegative integer ($REGISTRY)" ;; esac
|
|
277
|
+
LIMIT="$REG_TIMEOUT"; LIMIT_SOURCE=registry
|
|
278
|
+
else LIMIT=2700; LIMIT_SOURCE=default
|
|
279
|
+
fi
|
|
280
|
+
# Normalize ("007" -> 7) so arithmetic is not read as octal and "00" really means off.
|
|
281
|
+
[ "${#LIMIT}" -gt 9 ] || LIMIT=$((10#$LIMIT))
|
|
282
|
+
if [ "$LIMIT_SOURCE" = registry ] || [ "$LIMIT_SOURCE" = default ]; then
|
|
283
|
+
if [ "$LIMIT" != 0 ] && [ "${#LIMIT}" -le 9 ]; then
|
|
284
|
+
effort_rank=-1; [ -z "$EFFORT" ] || effort_rank="$(rank_of "$EFFORT")"
|
|
285
|
+
if [ "$effort_rank" -ge 4 ]; then LIMIT=$((LIMIT * 2)); LIMIT_SOURCE="$LIMIT_SOURCE+effort-scaled"
|
|
286
|
+
elif [ "$effort_rank" -eq 3 ]; then LIMIT=$((LIMIT * 4 / 3)); LIMIT_SOURCE="$LIMIT_SOURCE+effort-scaled"
|
|
287
|
+
fi
|
|
288
|
+
fi
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
# --- run dir & prompt ---------------------------------------------------------
|
|
292
|
+
[ -d "$REPO" ] || die "--repo '$REPO' is not a directory"
|
|
293
|
+
REPO="$(cd "$REPO" && pwd)"
|
|
294
|
+
if [ -z "$RUN_DIR" ]; then
|
|
295
|
+
RUN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/forge-XXXXXX")" || die "could not create run dir"
|
|
296
|
+
fi
|
|
297
|
+
mkdir -p "$RUN_DIR" || die "could not create run dir '$RUN_DIR'"
|
|
298
|
+
RUN_DIR="$(cd "$RUN_DIR" && pwd)" # normalize, so the containment check below is real
|
|
299
|
+
# The run dir deliberately lives outside the repo: anything forge writes inside
|
|
300
|
+
# the working tree would show up in the dwarf's own diff and pollute QA's input.
|
|
301
|
+
case "$RUN_DIR" in "$REPO"|"$REPO"/*) die "--run-dir must be outside --repo, or it lands in the diff under review" ;; esac
|
|
302
|
+
|
|
303
|
+
# Start an immutable attempt record before preparation, including failures.
|
|
304
|
+
ATTEMPT=""
|
|
305
|
+
if [ "$PREFLIGHT" != 1 ] && [ "$DRY" != 1 ]; then
|
|
306
|
+
ATTEMPT="$(python3 "$RUNTIME" begin "$RUN_DIR" "$ROLE")" || exit 3
|
|
307
|
+
rm -f "$RUN_DIR/$ROLE.last" "$RUN_DIR/$ROLE.log" "$RUN_DIR/$ROLE.resolved" "$RUN_DIR/$ROLE.cmd" "$RUN_DIR/$ROLE.timedout" "$RUN_DIR/$ROLE.infra" "$RUN_DIR/$ROLE.orphans"
|
|
308
|
+
trap 'exit 143' TERM
|
|
309
|
+
trap 'exit 130' INT
|
|
310
|
+
# GUARD_ON is 1 only when the harness was started in its own process group (see the
|
|
311
|
+
# launch below), in which case $hpid is also the group id and the whole group is
|
|
312
|
+
# signalled. Every wait here is bounded: this runs on the way out of an interrupted
|
|
313
|
+
# dispatch and must not be the thing that hangs it.
|
|
314
|
+
exit_cleanup() {
|
|
315
|
+
if [ -n "${hpid:-}" ] && kill -0 "$hpid" 2>/dev/null; then
|
|
316
|
+
[ "${GUARD_ON:-0}" != 1 ] || kill -s TERM -- "-$hpid" 2>/dev/null
|
|
317
|
+
pkill -TERM -P "$hpid" 2>/dev/null; kill -TERM "$hpid" 2>/dev/null
|
|
318
|
+
if [ "${GUARD_ON:-0}" = 1 ]; then sleep 1; kill -s KILL -- "-$hpid" 2>/dev/null; fi
|
|
319
|
+
elif [ "${GUARD_ON:-0}" = 1 ] && [ "${reaped:-0}" != 1 ] && [ -n "${hpid:-}" ]; then
|
|
320
|
+
# Interrupted after the harness exited but before the normal reap.
|
|
321
|
+
python3 "$GUARD" reap "$hpid" "$RUN_DIR/$ROLE.orphans" >/dev/null 2>&1
|
|
322
|
+
fi
|
|
323
|
+
if [ -n "${wpid:-}" ]; then pkill -P "$wpid" 2>/dev/null; kill "$wpid" 2>/dev/null; fi
|
|
324
|
+
}
|
|
325
|
+
trap 'result=$?; exit_cleanup; python3 "$RUNTIME" finish "$ATTEMPT" "$RUN_DIR" "$ROLE" "$HARNESS" "$result"; exit "$result"' EXIT
|
|
326
|
+
fi
|
|
327
|
+
[ -n "$PROMPT_FILE" ] || die "role '$ROLE' needs --prompt-file"
|
|
328
|
+
case "$PROMPT_FILE" in -|/dev/stdin) cat > "$RUN_DIR/$ROLE.prompt" ;;
|
|
329
|
+
*) [ -r "$PROMPT_FILE" ] || die "cannot read --prompt-file '$PROMPT_FILE'"
|
|
330
|
+
if [ "$PROMPT_FILE" != "$RUN_DIR/$ROLE.prompt" ]; then
|
|
331
|
+
prompt_copy="$(mktemp "$RUN_DIR/.prompt.XXXXXX")" || die "cannot create prompt copy"
|
|
332
|
+
cat "$PROMPT_FILE" > "$prompt_copy" || { rm -f "$prompt_copy"; die "cannot materialize prompt"; }
|
|
333
|
+
mv "$prompt_copy" "$RUN_DIR/$ROLE.prompt" || die "cannot install prompt copy"
|
|
334
|
+
fi ;;
|
|
335
|
+
esac
|
|
336
|
+
# Reusing the delivered prompt must not accumulate advisory sections or constraints.
|
|
337
|
+
if [ -f "$RUN_DIR/$ROLE.delivered" ] && cmp -s "$RUN_DIR/$ROLE.prompt" "$RUN_DIR/$ROLE.delivered"; then
|
|
338
|
+
cp "$RUN_DIR/$ROLE.original" "$RUN_DIR/$ROLE.prompt"
|
|
339
|
+
fi
|
|
340
|
+
cp "$RUN_DIR/$ROLE.prompt" "$RUN_DIR/$ROLE.original"
|
|
341
|
+
[ -n "$RIPWIRE_QUERY" ] || RIPWIRE_QUERY="$RUN_DIR/$ROLE.original"
|
|
342
|
+
PROMPT_FILE="$RUN_DIR/$ROLE.prompt"
|
|
343
|
+
[ -z "$ATTEMPT" ] || touch "$ATTEMPT/prompt.ready"
|
|
344
|
+
# Streaming POSIX character class, using the caller's locale as Bash did.
|
|
345
|
+
grep -q '[^[:space:]]' "$PROMPT_FILE" || die "prompt file '$PROMPT_FILE' is empty"
|
|
346
|
+
PROMPT=""
|
|
347
|
+
|
|
348
|
+
LAST="$RUN_DIR/$ROLE.last"
|
|
349
|
+
LOG="$RUN_DIR/$ROLE.log"
|
|
350
|
+
|
|
351
|
+
# Some harnesses cannot do things the standard role prompt asks for. Telling the
|
|
352
|
+
# model up front beats letting it discover the wall mid-run: on antigravity a
|
|
353
|
+
# denied command does not degrade gracefully, it aborts the whole dispatch, so a
|
|
354
|
+
# dwarf that dutifully tries to run the test suite ends with an error and an
|
|
355
|
+
# empty diff after burning the full quota.
|
|
356
|
+
CONSTRAINT=""
|
|
357
|
+
if [ "$HARNESS" = "antigravity" ] && [ "$YOLO" != 1 ]; then
|
|
358
|
+
CONSTRAINT="This environment cannot run terminal commands: permission is denied for every
|
|
359
|
+
shell command, and a single attempt aborts this run entirely. Do not try to run tests,
|
|
360
|
+
linters, git, or any other command. Verify your work by reading the code instead, and say
|
|
361
|
+
plainly in your final message that it is unverified by execution."
|
|
362
|
+
fi
|
|
363
|
+
if [ -n "$CONSTRAINT" ]; then
|
|
364
|
+
printf '\n%s\n' "$CONSTRAINT" >> "$RUN_DIR/$ROLE.prompt"
|
|
365
|
+
PROMPT="$(cat "$RUN_DIR/$ROLE.prompt")"
|
|
366
|
+
PROMPT_FILE="$RUN_DIR/$ROLE.prompt"
|
|
367
|
+
note "antigravity without --yolo cannot run commands; told the $ROLE not to try"
|
|
368
|
+
fi
|
|
369
|
+
|
|
370
|
+
if [ -n "$ATTEMPT" ]; then
|
|
371
|
+
python3 "$SKILL_DIR/scripts/forge-ripwire.py" --repo "$REPO" --role "$ROLE" \
|
|
372
|
+
--prompt "$PROMPT_FILE" --query "$RIPWIRE_QUERY" --baseline "$REVIEW_BASE" \
|
|
373
|
+
--artifacts "$ATTEMPT" --native "$NATIVE_REVIEW" || note "Ripwire preparation failed; continuing"
|
|
374
|
+
fi
|
|
375
|
+
cp "$PROMPT_FILE" "$RUN_DIR/$ROLE.delivered"
|
|
376
|
+
case "$HARNESS" in opencode|antigravity) PROMPT="$(cat "$PROMPT_FILE")" ;; esac
|
|
377
|
+
|
|
378
|
+
# --- build argv ---------------------------------------------------------------
|
|
379
|
+
CMD=()
|
|
380
|
+
case "$HARNESS" in
|
|
381
|
+
codex)
|
|
382
|
+
if [ "$ROLE" = "qa" ] && [ "$NATIVE_REVIEW" = 1 ]; then
|
|
383
|
+
# codex's built-in reviewer. Its scope flags are mutually exclusive with a
|
|
384
|
+
# custom PROMPT, so this mode trades away the goal context: it can judge
|
|
385
|
+
# whether the diff is correct, but not whether it is the change that was
|
|
386
|
+
# actually asked for. `codex exec review` also has no --cd, hence the
|
|
387
|
+
# subshell cd below.
|
|
388
|
+
CMD=(codex exec review --json -m "$MODEL" --skip-git-repo-check -o "$LAST")
|
|
389
|
+
[ -n "$EFFORT" ] && CMD+=(-c "model_reasoning_effort=$EFFORT")
|
|
390
|
+
if [ -n "$REVIEW_BASE" ]; then CMD+=(--base "$REVIEW_BASE"); else CMD+=(--uncommitted); fi
|
|
391
|
+
if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-bypass-approvals-and-sandbox); fi
|
|
392
|
+
else
|
|
393
|
+
CMD=(codex exec --json -m "$MODEL" -C "$REPO" --skip-git-repo-check -o "$LAST")
|
|
394
|
+
[ -n "$EFFORT" ] && CMD+=(-c "model_reasoning_effort=$EFFORT")
|
|
395
|
+
# --approve-for-me and --dangerously-bypass-... are mutually exclusive.
|
|
396
|
+
if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-bypass-approvals-and-sandbox)
|
|
397
|
+
elif [ "$ROLE" = "dwarf" ]; then CMD+=(--approve-for-me)
|
|
398
|
+
else CMD+=(-s read-only); fi
|
|
399
|
+
CMD+=(-)
|
|
400
|
+
PROMPT_VIA_STDIN=1
|
|
401
|
+
fi
|
|
402
|
+
;;
|
|
403
|
+
claude|openclaude)
|
|
404
|
+
# The run dir sits outside the repo (so it stays out of the diff), which also
|
|
405
|
+
# puts it outside the harness's default reach — without this the agent
|
|
406
|
+
# refuses to open the very artifacts forge just wrote for it.
|
|
407
|
+
CMD=("$HARNESS" -p --model "$MODEL" --add-dir "$RUN_DIR")
|
|
408
|
+
# One JSON message per line: what the agent said and every tool call, so the run keeps
|
|
409
|
+
# its conversation (forge-ui reads it), ending in the `result` line forge-runtime.py
|
|
410
|
+
# takes the final message and the tokens from. stream-json needs --verbose with -p.
|
|
411
|
+
CMD+=(--output-format stream-json --verbose)
|
|
412
|
+
[ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
|
|
413
|
+
if [ "$YOLO" = 1 ]; then
|
|
414
|
+
CMD+=(--dangerously-skip-permissions)
|
|
415
|
+
else
|
|
416
|
+
# `acceptEdits`, not `auto`. Re-measured against this machine's CLIs:
|
|
417
|
+
# acceptEdits allows both Edit and Bash, whereas `auto` allows Bash but
|
|
418
|
+
# still prompts for Edit — and a headless dwarf has nobody to answer that
|
|
419
|
+
# prompt, so it ends with "I need your permission to edit f.py" and an
|
|
420
|
+
# empty diff. The failure is silent: the harness exits 0 and forge sees a
|
|
421
|
+
# successful run that changed nothing.
|
|
422
|
+
CMD+=(--permission-mode acceptEdits)
|
|
423
|
+
# Neither mode alone is enough, and the gap is silent in both directions:
|
|
424
|
+
# `auto` allows shell but prompts for Edit, so a dwarf ends with "I need
|
|
425
|
+
# your permission to edit" and an empty diff; `acceptEdits` allows Edit but
|
|
426
|
+
# still prompts for any non-trivial shell command, so a dwarf writes code
|
|
427
|
+
# and then stalls on "awaiting approval to run pytest" without ever
|
|
428
|
+
# verifying it. acceptEdits plus an explicit Bash allowance gives both, and
|
|
429
|
+
# QA's --disallowed-tools below still overrides the edit permission.
|
|
430
|
+
CMD+=(--allowedTools "Bash")
|
|
431
|
+
# Disable editing tools as defense in depth. Bash can still write files;
|
|
432
|
+
# runners use disposable snapshots and fingerprint checks for acceptance.
|
|
433
|
+
read_only_role && CMD+=(--disallowed-tools "Edit,Write,NotebookEdit")
|
|
434
|
+
fi
|
|
435
|
+
# The prompt goes on stdin, not argv. --disallowed-tools and friends are
|
|
436
|
+
# variadic, so any positional after them is silently eaten as another tool
|
|
437
|
+
# name — the run then dies on "Input must be provided" if you are lucky, or
|
|
438
|
+
# reviews nothing at all if you are not. stdin also lifts the argv length
|
|
439
|
+
# limit, which a long goal plus an inlined diff will otherwise hit.
|
|
440
|
+
PROMPT_VIA_STDIN=1
|
|
441
|
+
;;
|
|
442
|
+
opencode)
|
|
443
|
+
CMD=(opencode run -m "$MODEL" --dir "$REPO")
|
|
444
|
+
[ -n "$EFFORT" ] && CMD+=(--variant "$EFFORT")
|
|
445
|
+
# opencode run exposes no sandbox tier — without --auto it blocks on a
|
|
446
|
+
# permission prompt that nothing can answer headlessly, so it is always set.
|
|
447
|
+
CMD+=(--auto)
|
|
448
|
+
read_only_role && [ "$YOLO" != 1 ] && CMD+=(--agent plan)
|
|
449
|
+
# One JSON event per line (text, tool_use, step_finish, error): the conversation, the
|
|
450
|
+
# final message and the tokens, read by forge-runtime.py and forge-ui.
|
|
451
|
+
CMD+=(--format json)
|
|
452
|
+
CMD+=("$PROMPT")
|
|
453
|
+
;;
|
|
454
|
+
antigravity)
|
|
455
|
+
# `agy --print` is a Go flag with an OPTIONAL value: written detached it eats
|
|
456
|
+
# whatever token follows as its prompt ("--print took --print-timeout as its
|
|
457
|
+
# prompt"). The attached --print=... form is the only safe spelling, and it
|
|
458
|
+
# goes last so nothing can be captured after it.
|
|
459
|
+
CMD=(agy --model "$MODEL" --print-timeout "$AGY_TIMEOUT")
|
|
460
|
+
[ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
|
|
461
|
+
# --add-dir on the repo is not optional. Antigravity is workspace-scoped
|
|
462
|
+
# rather than cwd-scoped, so without it the agent searches the wrong tree and
|
|
463
|
+
# the run dies on "Find command timed out: context deadline exceeded".
|
|
464
|
+
CMD+=(--add-dir "$REPO" --add-dir "$RUN_DIR")
|
|
465
|
+
if [ "$YOLO" = 1 ]; then CMD+=(--dangerously-skip-permissions)
|
|
466
|
+
elif read_only_role; then CMD+=(--mode plan)
|
|
467
|
+
else CMD+=(--mode accept-edits); fi
|
|
468
|
+
CMD+=(--print="$PROMPT")
|
|
469
|
+
;;
|
|
470
|
+
grok)
|
|
471
|
+
# Headless grok refuses any tool call that needs approval and still exits 0
|
|
472
|
+
# ("permission required: Edit calc.py was not run"): the same silent empty diff
|
|
473
|
+
# claude gives without acceptEdits, so the dwarf gets edits and the whole shell.
|
|
474
|
+
# The Messages-format stream ends in a claude-shaped `result` line, which
|
|
475
|
+
# forge-runtime.py reads for the final message and the tokens.
|
|
476
|
+
CMD=(grok --model "$MODEL" --prompt-file "$PROMPT_FILE" --output-format streaming-messages-json)
|
|
477
|
+
[ -n "$EFFORT" ] && CMD+=(--reasoning-effort "$EFFORT")
|
|
478
|
+
if [ "$YOLO" = 1 ]; then
|
|
479
|
+
CMD+=(--always-approve)
|
|
480
|
+
else
|
|
481
|
+
CMD+=(--permission-mode acceptEdits --allow Bash)
|
|
482
|
+
# A deny rule beats the mode and every allow, so a reviewer keeps its shell for
|
|
483
|
+
# running tests but cannot edit. Bash can still write files, as with claude.
|
|
484
|
+
read_only_role && CMD+=(--deny Edit --deny Write)
|
|
485
|
+
fi
|
|
486
|
+
;;
|
|
487
|
+
*) die "no dispatch recipe for harness '$HARNESS'" ;;
|
|
488
|
+
esac
|
|
489
|
+
|
|
490
|
+
# Offline validation uses the exact command recipe, never a model request.
|
|
491
|
+
preflight_command() {
|
|
492
|
+
local bin help arg flag
|
|
493
|
+
local help_args=(--help)
|
|
494
|
+
bin="$(command -v "$(binary_for "$HARNESS")")" || die "harness '$HARNESS' is not installed" 3
|
|
495
|
+
case "$HARNESS" in
|
|
496
|
+
codex) help_args=(exec --help); [ "$NATIVE_REVIEW" = 1 ] && help_args=(exec review --help) ;;
|
|
497
|
+
opencode) help_args=(run --help) ;;
|
|
498
|
+
esac
|
|
499
|
+
help="$(python3 "$RUNTIME" help "${FORGE_CAPABILITY_CACHE:-$RUN_DIR/capabilities}" "$REGISTRY" "${BASH_SOURCE[0]}" "$bin" "${help_args[@]}")" || die "cannot read $HARNESS help" 3
|
|
500
|
+
for arg in "${CMD[@]:1}"; do
|
|
501
|
+
[ "$arg" = "$PROMPT" ] && continue
|
|
502
|
+
[ "$arg" = - ] && continue
|
|
503
|
+
case "$arg" in
|
|
504
|
+
-*) flag="${arg%%=*}"
|
|
505
|
+
printf '%s\n' "$help" | grep -E -- "(^|[[:space:],|])$flag([=[:space:],|]|$)" >/dev/null || die "$HARNESS does not advertise required flag $flag" 3 ;;
|
|
506
|
+
esac
|
|
507
|
+
done
|
|
508
|
+
}
|
|
509
|
+
[ -z "$ATTEMPT" ] || python3 "$RUNTIME" phase "$ATTEMPT" preflight
|
|
510
|
+
if [ "$DRY" != 1 ] || [ "$PREFLIGHT" = 1 ]; then preflight_command; fi
|
|
511
|
+
|
|
512
|
+
# --- report the resolution ----------------------------------------------------
|
|
513
|
+
{
|
|
514
|
+
echo "role=$ROLE"
|
|
515
|
+
echo "alias=$ALIAS"
|
|
516
|
+
echo "harness=$HARNESS"
|
|
517
|
+
echo "model=$MODEL"
|
|
518
|
+
echo "effort=${EFFORT:-<harness default>}"
|
|
519
|
+
echo "yolo=$([ "$YOLO" = 1 ] && echo ON || echo off)"
|
|
520
|
+
echo "passthrough=$([ "$PASSTHRU" = 1 ] && echo yes || echo no)"
|
|
521
|
+
[ -n "$CLAMPED" ] && echo "clamped=$CLAMPED"
|
|
522
|
+
echo "timeout=$LIMIT"
|
|
523
|
+
echo "timeout_source=$LIMIT_SOURCE"
|
|
524
|
+
# Native review ignores the assembled prompt, so whether it reached the model is a fact
|
|
525
|
+
# worth recording rather than inferring from the role and harness later.
|
|
526
|
+
echo "delivery=$([ "$NATIVE_REVIEW" = 1 ] && echo native_review || echo custom)"
|
|
527
|
+
echo "repo=$REPO"
|
|
528
|
+
echo "run_dir=$RUN_DIR"
|
|
529
|
+
} | tee "$RUN_DIR/$ROLE.resolved"
|
|
530
|
+
|
|
531
|
+
if [ "$PREFLIGHT" = 1 ]; then
|
|
532
|
+
echo "offline preflight passed; authentication, quota and live model availability unverified"
|
|
533
|
+
exit 0
|
|
534
|
+
fi
|
|
535
|
+
printf '%q ' "${CMD[@]}" > "$RUN_DIR/$ROLE.cmd"; echo >> "$RUN_DIR/$ROLE.cmd"
|
|
536
|
+
if [ "$DRY" = 1 ]; then
|
|
537
|
+
echo "--- dry run, command not executed ---"
|
|
538
|
+
cat "$RUN_DIR/$ROLE.cmd"
|
|
539
|
+
[ "$PROMPT_VIA_STDIN" = 1 ] && echo " (prompt delivered on stdin from $PROMPT_FILE)"
|
|
540
|
+
exit 0
|
|
541
|
+
fi
|
|
542
|
+
|
|
543
|
+
if [ "$YOLO" = 1 ]; then
|
|
544
|
+
note "YOLO: $HARNESS runs with sandbox and approvals disabled in $REPO"
|
|
545
|
+
fi
|
|
546
|
+
[ -n "$(command -v "$(binary_for "$HARNESS")")" ] || die "harness '$HARNESS' is not installed ($(binary_for "$HARNESS") not on PATH)" 3
|
|
547
|
+
|
|
548
|
+
# Harnesses that take the prompt on argv get stdin redirected from /dev/null:
|
|
549
|
+
# codex exec otherwise blocks waiting to append piped input to the prompt, which
|
|
550
|
+
# headless reads as a silent hang rather than an error.
|
|
551
|
+
#
|
|
552
|
+
# The harness runs in the background rather than through `| tee` so that its pid is
|
|
553
|
+
# knowable and therefore killable. `exec` matters: it replaces the subshell with the
|
|
554
|
+
# harness itself, so $hpid IS the backend process and not a shell that merely owns
|
|
555
|
+
# it — otherwise a timeout would kill the wrapper and leave the real process running.
|
|
556
|
+
# The cost is that the log is no longer echoed incrementally; it is printed whole at
|
|
557
|
+
# the end, so anything reading this script's stdout sees the same bytes as before.
|
|
558
|
+
# Clear the previous dispatch's artifacts. Only codex writes $LAST itself (-o);
|
|
559
|
+
# every other harness relies on the copy-from-log fallback below, which is a no-op
|
|
560
|
+
# when a stale $LAST is already there. That is harmless when each run gets a fresh
|
|
561
|
+
# run-dir, and silently wrong for `forge-parallel.sh retry`, which reuses the task
|
|
562
|
+
# directory — it would read the first attempt's result and never notice.
|
|
563
|
+
rm -f "$LAST" "$LOG" "$RUN_DIR/$ROLE.timedout" "$RUN_DIR/$ROLE.infra" "$RUN_DIR/$ROLE.orphans"
|
|
564
|
+
python3 "$RUNTIME" phase "$ATTEMPT" model
|
|
565
|
+
t0="$(date +%s)"
|
|
566
|
+
# Process-group guard. A dwarf that starts `npm test &` and ends its turn leaves that
|
|
567
|
+
# process running after dispatch returns, mutating the tree under whoever reads it next.
|
|
568
|
+
# forge-guard.py does setsid() and then exec()s the harness, so $! is still the harness
|
|
569
|
+
# pid and is now also its group id; after the harness exits, anything left in the group is
|
|
570
|
+
# reaped and listed. Off by kill switch, and under Fractal (which manages its own process
|
|
571
|
+
# groups and would otherwise lose the ability to signal this one). If the helper cannot
|
|
572
|
+
# even start, fall back to the plain exec: a guard failure must never cost the run.
|
|
573
|
+
GUARD_ON=0; reaped=0
|
|
574
|
+
case "${FORGE_ORPHAN_GUARD:-on}" in off|OFF|Off|0|no|false) ;; *)
|
|
575
|
+
if [ -z "${FORGE_FRACTAL_RUN:-}" ] && [ -r "$GUARD" ] && [ "$(python3 "$GUARD" ping 2>/dev/null)" = ok ]; then GUARD_ON=1; fi ;;
|
|
576
|
+
esac
|
|
577
|
+
RUNCMD=("${CMD[@]}")
|
|
578
|
+
[ "$GUARD_ON" != 1 ] || RUNCMD=(python3 "$GUARD" session -- "${CMD[@]}")
|
|
579
|
+
if [ "$PROMPT_VIA_STDIN" = 1 ]; then
|
|
580
|
+
( cd "$REPO" && exec "${RUNCMD[@]}" < "$PROMPT_FILE" ) > "$LOG" 2>&1 &
|
|
581
|
+
else
|
|
582
|
+
( cd "$REPO" && exec "${RUNCMD[@]}" </dev/null ) > "$LOG" 2>&1 &
|
|
583
|
+
fi
|
|
584
|
+
hpid=$!
|
|
585
|
+
|
|
586
|
+
# Watchdog. Only antigravity has a time bound of its own, and `timeout` is not
|
|
587
|
+
# present on stock macOS, so without this a hung backend blocks forever — and under
|
|
588
|
+
# forge-parallel's `xargs -P` one hung task silently stalls its whole wave. The
|
|
589
|
+
# default is deliberately generous: killing a slow-but-working dwarf is its own
|
|
590
|
+
# failure, so this is a backstop, not a scheduler.
|
|
591
|
+
wpid=""
|
|
592
|
+
if [ "${LIMIT:-0}" != "0" ]; then
|
|
593
|
+
(
|
|
594
|
+
sleep "$LIMIT"
|
|
595
|
+
kill -0 "$hpid" 2>/dev/null || exit 0
|
|
596
|
+
: > "$RUN_DIR/$ROLE.timedout"
|
|
597
|
+
# The whole process group first when the harness has its own (GUARD_ON), then
|
|
598
|
+
# children, then the process itself, then SIGKILL for a CLI that traps TERM and
|
|
599
|
+
# takes its time. Without the guard there is no group to signal as a unit.
|
|
600
|
+
[ "$GUARD_ON" != 1 ] || kill -s TERM -- "-$hpid" 2>/dev/null
|
|
601
|
+
pkill -TERM -P "$hpid" 2>/dev/null
|
|
602
|
+
kill -TERM "$hpid" 2>/dev/null
|
|
603
|
+
sleep 5
|
|
604
|
+
[ "$GUARD_ON" != 1 ] || kill -s KILL -- "-$hpid" 2>/dev/null
|
|
605
|
+
pkill -KILL -P "$hpid" 2>/dev/null
|
|
606
|
+
kill -KILL "$hpid" 2>/dev/null
|
|
607
|
+
) >/dev/null 2>&1 &
|
|
608
|
+
wpid=$!
|
|
609
|
+
fi
|
|
610
|
+
|
|
611
|
+
wait "$hpid"; rc=$?
|
|
612
|
+
if [ -n "$wpid" ]; then
|
|
613
|
+
# The watchdog is asleep in a child of its own; kill that too or it outlives the
|
|
614
|
+
# run as an orphan holding the timer.
|
|
615
|
+
pkill -P "$wpid" 2>/dev/null
|
|
616
|
+
kill "$wpid" 2>/dev/null
|
|
617
|
+
wait "$wpid" 2>/dev/null
|
|
618
|
+
fi
|
|
619
|
+
# Anything the harness left running in its group is an abandoned background job. Kill it
|
|
620
|
+
# before reading the tree, and leave the list for the caller. Bounded (~3s) and advisory:
|
|
621
|
+
# a reaper failure changes nothing about the exit code.
|
|
622
|
+
if [ "$GUARD_ON" = 1 ]; then
|
|
623
|
+
orphans_found="$(python3 "$GUARD" reap "$hpid" "$RUN_DIR/$ROLE.orphans" 2>/dev/null)"
|
|
624
|
+
case "$orphans_found" in ''|*[!0-9]*|0) ;; *) note "$HARNESS left $orphans_found process(es) running after it exited; killed — see $RUN_DIR/$ROLE.orphans" ;; esac
|
|
625
|
+
fi
|
|
626
|
+
reaped=1
|
|
627
|
+
python3 "$RUNTIME" phase "$ATTEMPT" finalization
|
|
628
|
+
DURATION=$(( $(date +%s) - t0 ))
|
|
629
|
+
echo "duration_s=$DURATION" >> "$RUN_DIR/$ROLE.resolved"
|
|
630
|
+
[ "$OUTPUT" != full ] || cat "$LOG"
|
|
631
|
+
[ "$OUTPUT" != summary ] || echo "forge: role=$ROLE backend_exit=$rc final=$LAST log=$LOG metrics=$ATTEMPT/metrics.json"
|
|
632
|
+
if [ -f "$RUN_DIR/$ROLE.timedout" ]; then
|
|
633
|
+
note "$HARNESS exceeded the ${LIMIT}s timeout and was killed after ${DURATION}s — see $LOG"
|
|
634
|
+
exit 7
|
|
635
|
+
fi
|
|
636
|
+
# Infrastructure, not the model's work: auth, quota, rate limit, network, or an answer that
|
|
637
|
+
# never arrived. Classified from the LOG because $LAST is only created later, by the EXIT
|
|
638
|
+
# trap's `finish`. Any trouble running the classifier counts as "not infrastructure".
|
|
639
|
+
INFRA="$(python3 "$RUNTIME" classify "$LOG" "$HARNESS" "$rc" "$LAST" "$ROLE" 2>/dev/null)" || INFRA=""
|
|
640
|
+
if [ -n "$INFRA" ]; then
|
|
641
|
+
TAB=$'\t'
|
|
642
|
+
infra_class="${INFRA%%"$TAB"*}"; infra_rest="${INFRA#*"$TAB"}"
|
|
643
|
+
infra_after="${infra_rest%%"$TAB"*}"; infra_detail="${infra_rest#*"$TAB"}"
|
|
644
|
+
{
|
|
645
|
+
echo "class=$infra_class"
|
|
646
|
+
echo "retry_after=$infra_after"
|
|
647
|
+
echo "detail=$infra_detail"
|
|
648
|
+
echo "rc=$rc"
|
|
649
|
+
echo "role=$ROLE"
|
|
650
|
+
echo "harness=$HARNESS"
|
|
651
|
+
echo "model=$MODEL"
|
|
652
|
+
} > "$RUN_DIR/$ROLE.infra"
|
|
653
|
+
# A dwarf may legitimately finish with nothing to say when its work is the diff, so
|
|
654
|
+
# an empty message alone is recorded but is not a failure; the caller decides when
|
|
655
|
+
# the diff turns out to be empty too. Every other role has nothing but its message.
|
|
656
|
+
if [ "$rc" -eq 0 ] && [ "$infra_class" = empty ] && [ "$ROLE" = dwarf ]; then
|
|
657
|
+
note "$HARNESS finished with an empty final message — recorded in $RUN_DIR/$ROLE.infra"
|
|
658
|
+
elif [ "$rc" -eq 0 ] && [ "$infra_class" = cancelled ]; then
|
|
659
|
+
# The CLI stopped a working model: a dwarf's diff so far still goes to review, and a
|
|
660
|
+
# reviewer cut off before its verdict is UNKNOWN, which re-runs only the review.
|
|
661
|
+
note "$HARNESS cancelled the $ROLE before it finished: $infra_detail — recorded in $RUN_DIR/$ROLE.infra"
|
|
662
|
+
else
|
|
663
|
+
note "$HARNESS failed on infrastructure ($infra_class${infra_after:+, retry after ${infra_after}s}): $infra_detail — see $LOG"
|
|
664
|
+
exit 8
|
|
665
|
+
fi
|
|
666
|
+
fi
|
|
667
|
+
if [ "$rc" -ne 0 ]; then
|
|
668
|
+
note "$HARNESS exited $rc — see $LOG"
|
|
669
|
+
exit 4
|
|
670
|
+
fi
|
|
671
|
+
echo "forge: $ROLE done in ${DURATION}s -> $LAST" >&2
|