@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,312 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Readiness scheduler. Only the coordinator merges; tasks keep pinned baselines.
|
|
3
|
+
|
|
4
|
+
Two opt-in behaviours live here, both off by default so a plain `run` is unchanged:
|
|
5
|
+
|
|
6
|
+
* ``--retry-failed N`` re-queues a task whose reviewer said FAIL/UNKNOWN up to N more
|
|
7
|
+
times *inside the same run*, so one failing task never holds the others back until the
|
|
8
|
+
run exits. Dependents already wait for MERGED, so nothing downstream starts early.
|
|
9
|
+
* ``--infra-retries M`` waits out an infrastructure failure (quota, auth, rate limit,
|
|
10
|
+
network, empty output) up to M times instead of stopping. Without it the scheduler
|
|
11
|
+
stops dispatching, lets running tasks drain and exits 8: nothing was the task's fault
|
|
12
|
+
and no attempt was spent, so the next `run` simply resumes.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import fcntl
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import posixpath
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dataset_event(name: str, plan: Path, task: str, values: dict) -> None:
|
|
28
|
+
"""Record a status only the scheduler sets (INTERRUPTED, BLOCKED, ERROR) in the run
|
|
29
|
+
dataset. No task process is alive to record it. Fail-open, like every capture."""
|
|
30
|
+
try:
|
|
31
|
+
scripts = str(Path(__file__).resolve().parent)
|
|
32
|
+
if scripts not in sys.path:
|
|
33
|
+
sys.path.insert(0, scripts)
|
|
34
|
+
from forge_dataset.capture import point
|
|
35
|
+
point(name, str(plan), task, values)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def overlap(a: str, b: str) -> bool:
|
|
41
|
+
def paths(value: str) -> list[str]:
|
|
42
|
+
return [posixpath.normpath(p) for p in value.split(',') if p and p != '-']
|
|
43
|
+
|
|
44
|
+
return any(
|
|
45
|
+
x == '.' or y == '.' or x == y or x.startswith(y + '/') or y.startswith(x + '/')
|
|
46
|
+
for x in paths(a) for y in paths(b)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
INFRA_EXIT = 8
|
|
51
|
+
# A parsed retry-after beyond this is not a pause worth waiting for inside one run.
|
|
52
|
+
INFRA_WAIT_CAP = 6 * 3600
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def infra_info(root: Path) -> dict:
|
|
56
|
+
"""key=value lines the task wrote when it stopped on an infrastructure failure."""
|
|
57
|
+
info = {}
|
|
58
|
+
try:
|
|
59
|
+
for line in (root / 'infra.txt').read_text(errors='replace').splitlines():
|
|
60
|
+
key, _, value = line.partition('=')
|
|
61
|
+
if key:
|
|
62
|
+
info[key.strip()] = value.strip()
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
return info
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def infra_wait(info: dict, cycle: int) -> float:
|
|
69
|
+
"""Seconds to wait before re-dispatching after an infrastructure failure."""
|
|
70
|
+
try:
|
|
71
|
+
base = float(os.environ.get('FORGE_INFRA_BACKOFF', '60'))
|
|
72
|
+
except ValueError:
|
|
73
|
+
base = 60.0
|
|
74
|
+
try:
|
|
75
|
+
hinted = float(info.get('retry_after', ''))
|
|
76
|
+
except ValueError:
|
|
77
|
+
hinted = None
|
|
78
|
+
if hinted is not None and hinted >= 0:
|
|
79
|
+
return min(hinted, INFRA_WAIT_CAP)
|
|
80
|
+
return min(base * (2 ** cycle), max(base, 1800.0))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
84
|
+
infra_retries: int = 0) -> int:
|
|
85
|
+
if capacity <= 0:
|
|
86
|
+
raise ValueError('capacity must be positive')
|
|
87
|
+
if retry_failed < 0 or infra_retries < 0:
|
|
88
|
+
raise ValueError('retry counts must not be negative')
|
|
89
|
+
plan = Path(plan)
|
|
90
|
+
rows = [
|
|
91
|
+
line.split('\t') for line in (plan / 'tasks.tsv').read_text().splitlines()
|
|
92
|
+
if line and not line.startswith('#')
|
|
93
|
+
]
|
|
94
|
+
if any(len(row) != 7 for row in rows):
|
|
95
|
+
raise ValueError('tasks.tsv requires seven columns')
|
|
96
|
+
tasks = {row[0]: row for row in rows}
|
|
97
|
+
if len(tasks) != len(rows):
|
|
98
|
+
raise ValueError('duplicate task IDs')
|
|
99
|
+
for name in tasks:
|
|
100
|
+
if not name or name in ('.', '..') or '/' in name:
|
|
101
|
+
raise ValueError('invalid task ID: ' + name)
|
|
102
|
+
(plan / 'tasks' / name).mkdir(parents=True, exist_ok=True)
|
|
103
|
+
active = {}
|
|
104
|
+
pending = list(tasks)
|
|
105
|
+
interrupted = False
|
|
106
|
+
retries = {} # automatic reviewer-driven retries used per task, this run
|
|
107
|
+
retried = set() # tasks whose next dispatch is a retry (FORGE_FRACTAL_RETRY=1)
|
|
108
|
+
offsets = {} # how much of each task.out was already echoed under --output full
|
|
109
|
+
infra_seen = [] # tasks stopped by an infrastructure failure, awaiting resume
|
|
110
|
+
infra_cycles = 0 # how many times this run already waited out an infrastructure pause
|
|
111
|
+
paused = False # an infrastructure failure was seen: dispatch nothing new
|
|
112
|
+
resume_at = 0.0
|
|
113
|
+
|
|
114
|
+
def status(name: str) -> str:
|
|
115
|
+
root = plan / 'tasks' / name
|
|
116
|
+
if (root / 'merged').exists():
|
|
117
|
+
return 'MERGED'
|
|
118
|
+
file = root / 'status'
|
|
119
|
+
return file.read_text().strip() if file.exists() else 'PENDING'
|
|
120
|
+
|
|
121
|
+
def mark(name: str, state: str) -> None:
|
|
122
|
+
(plan / 'tasks' / name / 'status').write_text(state + '\n')
|
|
123
|
+
dataset_event('sched_terminal', plan, name, dict(status=state))
|
|
124
|
+
|
|
125
|
+
def merge(name: str) -> None:
|
|
126
|
+
result = subprocess.run(['/bin/bash', script, '_merge', str(plan), name])
|
|
127
|
+
if result.returncode and status(name) == 'PASS':
|
|
128
|
+
mark(name, 'ERROR')
|
|
129
|
+
|
|
130
|
+
def retryable(name: str) -> bool:
|
|
131
|
+
return (
|
|
132
|
+
retry_failed > 0
|
|
133
|
+
and retries.get(name, 0) < retry_failed
|
|
134
|
+
and status(name) in ('FAIL', 'UNKNOWN')
|
|
135
|
+
# A task that produced no new work (empty diff, byte-identical retry) would
|
|
136
|
+
# only repeat itself; its marker says so and it is left for a human.
|
|
137
|
+
and not (plan / 'tasks' / name / 'noretry').exists()
|
|
138
|
+
# The retry policy found no stronger model after repeated failures on one: a
|
|
139
|
+
# split, or an explicit `retry --dwarf`, is the human's call.
|
|
140
|
+
and not (plan / 'tasks' / name / 'escalation.stop').exists()
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def requeue(name: str) -> bool:
|
|
144
|
+
"""Prepare a retry exactly as `retry` does, then put the task at the queue head."""
|
|
145
|
+
result = subprocess.run(['/bin/bash', script, '_prepare_retry', str(plan), name])
|
|
146
|
+
if result.returncode:
|
|
147
|
+
return False
|
|
148
|
+
retries[name] = retries.get(name, 0) + 1
|
|
149
|
+
retried.add(name)
|
|
150
|
+
if name not in pending:
|
|
151
|
+
pending.insert(0, name)
|
|
152
|
+
print('forge: retrying ' + name + ' (automatic retry ' + str(retries[name]) + '/'
|
|
153
|
+
+ str(retry_failed) + ')', file=sys.stderr, flush=True)
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def echo_output(name: str) -> None:
|
|
157
|
+
# task.out is opened in append mode, so a retried task's file keeps its earlier
|
|
158
|
+
# attempts; print only what is new.
|
|
159
|
+
try:
|
|
160
|
+
data = (plan / 'tasks' / name / 'task.out').read_bytes()
|
|
161
|
+
except OSError:
|
|
162
|
+
return
|
|
163
|
+
start = offsets.get(name, 0)
|
|
164
|
+
offsets[name] = len(data)
|
|
165
|
+
print(data[start:].decode('utf-8', 'replace'), end='', flush=True)
|
|
166
|
+
|
|
167
|
+
def stop(signum: int, frame: object) -> None:
|
|
168
|
+
nonlocal interrupted
|
|
169
|
+
interrupted = True
|
|
170
|
+
|
|
171
|
+
signal.signal(signal.SIGTERM, stop)
|
|
172
|
+
signal.signal(signal.SIGINT, stop)
|
|
173
|
+
try:
|
|
174
|
+
# Failures require an explicit retry unless this run asked for automatic ones.
|
|
175
|
+
# Saved PASS results use the same fingerprint checks without paying for
|
|
176
|
+
# implementation or QA again; an INFRA stop is not a failure of the work, so it
|
|
177
|
+
# is simply dispatched again.
|
|
178
|
+
for name in pending[:]:
|
|
179
|
+
state = status(name)
|
|
180
|
+
if state == 'PASS':
|
|
181
|
+
merge(name)
|
|
182
|
+
if state == 'RUNNING':
|
|
183
|
+
mark(name, 'INTERRUPTED')
|
|
184
|
+
if state in ('FAIL', 'UNKNOWN') and retryable(name):
|
|
185
|
+
requeue(name)
|
|
186
|
+
if status(name) not in ('PENDING', 'BLOCKED', 'INFRA'):
|
|
187
|
+
pending.remove(name)
|
|
188
|
+
while pending or active:
|
|
189
|
+
if interrupted:
|
|
190
|
+
break
|
|
191
|
+
for name, (process, log) in list(active.items()):
|
|
192
|
+
if process.poll() is not None:
|
|
193
|
+
log.close()
|
|
194
|
+
del active[name]
|
|
195
|
+
if os.environ.get('FORGE_OUTPUT') == 'full':
|
|
196
|
+
echo_output(name)
|
|
197
|
+
state = status(name)
|
|
198
|
+
if state == 'PASS':
|
|
199
|
+
merge(name)
|
|
200
|
+
elif state == 'RUNNING':
|
|
201
|
+
mark(name, 'ERROR')
|
|
202
|
+
elif state == 'INFRA':
|
|
203
|
+
paused = True
|
|
204
|
+
if name not in infra_seen:
|
|
205
|
+
infra_seen.append(name)
|
|
206
|
+
elif state in ('FAIL', 'UNKNOWN') and retryable(name):
|
|
207
|
+
requeue(name)
|
|
208
|
+
if paused and not active and infra_seen and infra_cycles < infra_retries:
|
|
209
|
+
# Everything running has drained. Wait out the outage once for all the
|
|
210
|
+
# tasks it stopped, then dispatch them again.
|
|
211
|
+
wait = max(infra_wait(infra_info(plan / 'tasks' / n), infra_cycles) for n in infra_seen)
|
|
212
|
+
infra_cycles += 1
|
|
213
|
+
print('forge: infrastructure failure on ' + ', '.join(infra_seen)
|
|
214
|
+
+ '; waiting ' + ('%g' % wait) + 's before resuming (no attempt spent)',
|
|
215
|
+
file=sys.stderr, flush=True)
|
|
216
|
+
resume_at = time.time() + wait
|
|
217
|
+
while time.time() < resume_at and not interrupted:
|
|
218
|
+
time.sleep(min(0.05, max(resume_at - time.time(), 0)))
|
|
219
|
+
if interrupted:
|
|
220
|
+
break
|
|
221
|
+
for name in reversed(infra_seen):
|
|
222
|
+
if name not in pending:
|
|
223
|
+
pending.insert(0, name)
|
|
224
|
+
infra_seen.clear()
|
|
225
|
+
paused = False
|
|
226
|
+
for name in pending[:]:
|
|
227
|
+
if paused or len(active) >= capacity or interrupted:
|
|
228
|
+
break
|
|
229
|
+
deps = [d for d in tasks[name][1].split(',') if d and d != '-']
|
|
230
|
+
if any(status(dep) != 'MERGED' for dep in deps):
|
|
231
|
+
continue
|
|
232
|
+
if any(overlap(tasks[name][3], tasks[other][3]) for other in active):
|
|
233
|
+
continue
|
|
234
|
+
root = plan / 'tasks' / name
|
|
235
|
+
if not (root / 'base_ref').exists():
|
|
236
|
+
worktree = Path((plan / 'wt_root').read_text().strip()) / '_integration'
|
|
237
|
+
base = subprocess.check_output(['git', '-C', str(worktree), 'rev-parse', 'HEAD'])
|
|
238
|
+
(root / 'base_ref').write_bytes(base)
|
|
239
|
+
log = open(root / 'task.out', 'ab')
|
|
240
|
+
# A retried task runs with the same environment `retry` gives it, so the
|
|
241
|
+
# no-progress guard (byte-identical diff) applies under Fractal too.
|
|
242
|
+
env = (dict(os.environ, FORGE_FRACTAL_RETRY='1', FORGE_DATASET_TRIGGER='auto')
|
|
243
|
+
if name in retried else None)
|
|
244
|
+
process = subprocess.Popen(
|
|
245
|
+
['/bin/bash', script, '_task', str(plan), name],
|
|
246
|
+
stdout=log, stderr=subprocess.STDOUT, start_new_session=True, env=env,
|
|
247
|
+
)
|
|
248
|
+
active[name] = process, log
|
|
249
|
+
pending.remove(name)
|
|
250
|
+
print('forge: dispatched ' + name + ' -> ' + str(root), file=sys.stderr, flush=True)
|
|
251
|
+
if not active:
|
|
252
|
+
if paused:
|
|
253
|
+
# Infrastructure stop: leave everything not yet run PENDING. Marking it
|
|
254
|
+
# BLOCKED would turn "the provider was down" into a recorded failure.
|
|
255
|
+
break
|
|
256
|
+
for name in pending:
|
|
257
|
+
mark(name, 'BLOCKED')
|
|
258
|
+
break
|
|
259
|
+
time.sleep(0.05)
|
|
260
|
+
finally:
|
|
261
|
+
for process, log in active.values():
|
|
262
|
+
try:
|
|
263
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
264
|
+
except ProcessLookupError:
|
|
265
|
+
pass
|
|
266
|
+
for name, (process, log) in active.items():
|
|
267
|
+
try:
|
|
268
|
+
process.wait(timeout=5)
|
|
269
|
+
except subprocess.TimeoutExpired:
|
|
270
|
+
pass
|
|
271
|
+
# Remove descendants even if the shell exited before its backend.
|
|
272
|
+
try:
|
|
273
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
274
|
+
except ProcessLookupError:
|
|
275
|
+
pass
|
|
276
|
+
process.wait()
|
|
277
|
+
log.close()
|
|
278
|
+
mark(name, 'INTERRUPTED')
|
|
279
|
+
if interrupted:
|
|
280
|
+
return 130
|
|
281
|
+
if paused or any(status(name) == 'INFRA' for name in tasks):
|
|
282
|
+
return INFRA_EXIT
|
|
283
|
+
return 0 if all(status(name) == 'MERGED' for name in tasks) else 5
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main() -> None:
|
|
287
|
+
try:
|
|
288
|
+
if sys.argv[1] == 'lock':
|
|
289
|
+
_, script, command, plan, *args = sys.argv[1:]
|
|
290
|
+
# The shell inherits the lock for its entire operation, including
|
|
291
|
+
# preflight, setup, scheduling, verification and cleanup.
|
|
292
|
+
lock = open(Path(plan) / 'schedule.lock', 'a')
|
|
293
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
294
|
+
os.set_inheritable(lock.fileno(), True)
|
|
295
|
+
env = dict(os.environ, FORGE_PLAN_LOCK=plan)
|
|
296
|
+
os.execve('/bin/bash', ['/bin/bash', script, command, plan, *args], env)
|
|
297
|
+
parser = argparse.ArgumentParser(prog='forge-schedule.py')
|
|
298
|
+
parser.add_argument('script')
|
|
299
|
+
parser.add_argument('plan')
|
|
300
|
+
parser.add_argument('capacity', type=int)
|
|
301
|
+
parser.add_argument('--retry-failed', type=int, default=0, metavar='N')
|
|
302
|
+
parser.add_argument('--infra-retries', type=int, default=0, metavar='M')
|
|
303
|
+
args = parser.parse_args(sys.argv[1:])
|
|
304
|
+
sys.exit(schedule(args.script, args.plan, args.capacity, args.retry_failed,
|
|
305
|
+
args.infra_retries))
|
|
306
|
+
except (OSError, ValueError, KeyError) as error:
|
|
307
|
+
print('forge scheduler: ' + str(error), file=sys.stderr)
|
|
308
|
+
sys.exit(3)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == '__main__':
|
|
312
|
+
main()
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A shadow review: a second, independent review of a sampled task attempt, never consumed.
|
|
3
|
+
|
|
4
|
+
Nothing in forge has ever reviewed one diff twice, so how often two reviewers agree
|
|
5
|
+
-- the noise in every review label -- is unknown. `plan --shadow-qa <spec>[,<spec>...]`
|
|
6
|
+
turns this on for a sampled share of reviewed attempts (`--shadow-rate`, default 0.1).
|
|
7
|
+
|
|
8
|
+
forge-shadow.py run --plan P --task ID --worktree W --base B --reviewed R --attempt N --qa SPEC [--yolo]
|
|
9
|
+
Started in the background by the task, just before the primary review. Exits at once
|
|
10
|
+
unless this attempt is sampled. Otherwise it builds its own review snapshot and run
|
|
11
|
+
dir under <plan>/shadow/<id>/<attempt>-<k>/, reviews a byte-identical copy of the
|
|
12
|
+
primary's qa.input, and returns. Never exits nonzero.
|
|
13
|
+
forge-shadow.py preflight --plan P
|
|
14
|
+
Which listed shadow specs resolve here; writes <plan>/shadow_qa.ok. Never fails a run.
|
|
15
|
+
|
|
16
|
+
Guarantees, because the task waits for this process before it writes any status:
|
|
17
|
+
- nothing reads the shadow's result in the run path; it changes no status, memory,
|
|
18
|
+
ledger, gate or retry, and its files never share a name or a dir with the primary's
|
|
19
|
+
- it holds no inherited file descriptor (close_fds), so no lock outlives the task
|
|
20
|
+
- it dies with the task: when its parent goes away (an interrupt, a killed scheduler,
|
|
21
|
+
Ctrl-C on a manual retry, which never reaches a background job) it stops the review
|
|
22
|
+
- it is bounded: once the task signals that the primary review finished (SIGUSR1), the
|
|
23
|
+
shadow gets as long again as the primary took, at least SHADOW_GRACE_S, then stops
|
|
24
|
+
<shadow dir>/shadow.status (and a `shadow_review` dataset event) says how it ended: done,
|
|
25
|
+
timeout, error, invalid (the reviewer changed its snapshot), overtime, stopped, orphaned.
|
|
26
|
+
Only a `done` review counts as a second opinion.
|
|
27
|
+
Forge never picks a model: the reviewer is one the user listed, preferring a different
|
|
28
|
+
model family from the primary reviewer's, so two reviewers do not share one blind spot.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import signal
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import tempfile
|
|
41
|
+
import time
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
45
|
+
DISPATCH = SCRIPTS / 'forge-dispatch.sh'
|
|
46
|
+
ARTIFACT = SCRIPTS / 'forge-artifact.sh'
|
|
47
|
+
SHADOW_GRACE_S = float(os.environ.get('FORGE_SHADOW_GRACE_S') or 300)
|
|
48
|
+
STOP_WAIT_S = 10
|
|
49
|
+
PREPARE_TIMEOUT_S = 120
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def rate_of(plan: Path) -> float:
|
|
53
|
+
try:
|
|
54
|
+
rate = float((plan / 'shadow_rate').read_text().strip())
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
rate = 0.1
|
|
57
|
+
return min(max(rate, 0.0), 1.0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sampled(plan: Path, task: str, attempt: str, rate: float) -> bool:
|
|
61
|
+
"""Deterministic per attempt: a QA-only resume reaches the same answer."""
|
|
62
|
+
run = ''
|
|
63
|
+
for name in ('dataset.uid', 'run_id'):
|
|
64
|
+
try:
|
|
65
|
+
run = (plan / name).read_text().strip()
|
|
66
|
+
break
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
digest = hashlib.sha256(('%s:%s:%s' % (run, task, attempt)).encode()).digest()
|
|
70
|
+
return int.from_bytes(digest[:8], 'big') < rate * 2 ** 64
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def family(model: str) -> str:
|
|
74
|
+
m = (model or '').lower().rsplit('/', 1)[-1]
|
|
75
|
+
if m.startswith(('claude', 'opus', 'sonnet', 'haiku', 'fable')):
|
|
76
|
+
return 'claude'
|
|
77
|
+
if m.startswith(('gpt', 'sol', 'astra', 'luna', 'terra', 'o1', 'o3', 'o4')) or 'gpt-' in m:
|
|
78
|
+
return 'gpt'
|
|
79
|
+
if m.startswith('gemini'):
|
|
80
|
+
return 'gemini'
|
|
81
|
+
if m.startswith('grok'):
|
|
82
|
+
return 'grok'
|
|
83
|
+
return m
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolved_model(spec: str) -> str | None:
|
|
87
|
+
"""The model a qa dispatch of `spec` would use, by dispatch's own dry run."""
|
|
88
|
+
with tempfile.TemporaryDirectory(prefix='forge-shadow-resolve-') as tmp:
|
|
89
|
+
prompt = Path(tmp) / 'input'
|
|
90
|
+
prompt.write_text('Offline resolution')
|
|
91
|
+
try:
|
|
92
|
+
out = subprocess.run(['/bin/bash', str(DISPATCH), 'qa', spec, '--run-dir', tmp, '--prompt-file',
|
|
93
|
+
str(prompt), '--dry-run'], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
94
|
+
timeout=60, text=True)
|
|
95
|
+
except (OSError, subprocess.SubprocessError):
|
|
96
|
+
return None
|
|
97
|
+
fields = dict(line.split('=', 1) for line in out.stdout.splitlines() if '=' in line)
|
|
98
|
+
return fields.get('model') or None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def candidates(plan: Path) -> list:
|
|
102
|
+
path = plan / 'shadow_qa.ok' if (plan / 'shadow_qa.ok').exists() else plan / 'shadow_qa'
|
|
103
|
+
try:
|
|
104
|
+
text = path.read_text()
|
|
105
|
+
except OSError:
|
|
106
|
+
return []
|
|
107
|
+
return [s.strip() for s in text.replace('\n', ',').split(',') if s.strip()]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def pick(listed: list, primary: str) -> tuple[str | None, str | None]:
|
|
111
|
+
"""(spec, why): the first listed spec of another family than the primary's, else the first."""
|
|
112
|
+
if not listed:
|
|
113
|
+
return None, None
|
|
114
|
+
mine = family(resolved_model(primary) or primary)
|
|
115
|
+
for spec in listed:
|
|
116
|
+
if family(resolved_model(spec) or spec) != mine:
|
|
117
|
+
return spec, 'other_family'
|
|
118
|
+
return listed[0], 'same_family'
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fresh_dir(base: Path, attempt: str) -> Path:
|
|
122
|
+
"""A dir no earlier try used: a snapshot refuses an existing destination."""
|
|
123
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
k = 1
|
|
125
|
+
while True:
|
|
126
|
+
candidate = base / ('%s-%d' % (attempt, k))
|
|
127
|
+
try:
|
|
128
|
+
candidate.mkdir()
|
|
129
|
+
return candidate
|
|
130
|
+
except FileExistsError:
|
|
131
|
+
k += 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def bash(function: str, *args) -> subprocess.CompletedProcess:
|
|
135
|
+
return subprocess.run(['/bin/bash', '-c', 'source "$1"; shift; "$@"', '_', str(ARTIFACT), function, *args],
|
|
136
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=PREPARE_TIMEOUT_S)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write(path: Path, text: str) -> None:
|
|
140
|
+
try:
|
|
141
|
+
path.write_text(text)
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def dataset_event(out: Path, task: str, values: dict) -> None:
|
|
147
|
+
"""How the shadow ended, in the run dataset. Its dispatch was captured when it exited,
|
|
148
|
+
before the snapshot was checked again, so an `invalid` verdict can only be told here."""
|
|
149
|
+
try:
|
|
150
|
+
if str(SCRIPTS) not in sys.path:
|
|
151
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
152
|
+
from forge_dataset.capture import point
|
|
153
|
+
point('shadow_review', str(out), task, values)
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Stopped(Exception):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
STATE = dict(child=None, primary_done=None, stop=False, started=time.time())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _primary_done(_signum, _frame):
|
|
166
|
+
if STATE['primary_done'] is None:
|
|
167
|
+
STATE['primary_done'] = time.time()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _stop(_signum, _frame):
|
|
171
|
+
# Before the review starts there is nothing to stop but this process; afterwards the
|
|
172
|
+
# watch loop stops the review itself, so the status file is always written.
|
|
173
|
+
if STATE['child'] is None:
|
|
174
|
+
raise Stopped()
|
|
175
|
+
STATE['stop'] = True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run(args) -> int:
|
|
179
|
+
# First, before anything slow: the task may signal that its review finished at any time,
|
|
180
|
+
# and SIGUSR1's default action would kill a launcher that is still preparing.
|
|
181
|
+
signal.signal(signal.SIGUSR1, _primary_done)
|
|
182
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
183
|
+
plan = Path(args.plan)
|
|
184
|
+
parent = os.getppid()
|
|
185
|
+
status, out, spec, why = 'skipped', None, None, None
|
|
186
|
+
try:
|
|
187
|
+
if os.environ.get('FORGE_FRACTAL_RUN') or not sampled(plan, args.task, args.attempt, rate_of(plan)):
|
|
188
|
+
return 0
|
|
189
|
+
spec, why = pick(candidates(plan), args.qa)
|
|
190
|
+
if not spec:
|
|
191
|
+
return 0
|
|
192
|
+
out = fresh_dir(plan / 'shadow' / args.task, args.attempt)
|
|
193
|
+
meta = dict(task=args.task, attempt=args.attempt, reviewed=args.reviewed, primary=args.qa, spec=spec,
|
|
194
|
+
pick=why, started=time.time())
|
|
195
|
+
write(out / 'shadow.json', json.dumps(meta, indent=1))
|
|
196
|
+
review = out / 'review-snapshot' # review-*: the importer never walks into a snapshot
|
|
197
|
+
if bash('forge_review_snapshot', args.worktree, args.base, args.reviewed, str(review)).returncode:
|
|
198
|
+
status = 'error'
|
|
199
|
+
return 0
|
|
200
|
+
before = bash('forge_fingerprint', str(review)).stdout.strip()
|
|
201
|
+
shutil.copyfile(Path(args.tdir) / 'qa.input', out / 'qa.input')
|
|
202
|
+
env = dict(os.environ, FORGE_SHADOW='1', FORGE_SHADOW_ATTEMPT=args.attempt,
|
|
203
|
+
FORGE_SHADOW_REVIEWED=args.reviewed, FORGE_SHADOW_FROM=args.qa)
|
|
204
|
+
argv = ['/bin/bash', str(DISPATCH), 'qa', spec, '--repo', str(review), '--run-dir', str(out),
|
|
205
|
+
'--review-base', (out / 'review.base').read_text().strip(),
|
|
206
|
+
'--ripwire-query-file', str(Path(args.tdir) / 'prompt.md'), '--prompt-file', str(out / 'qa.input'),
|
|
207
|
+
'--output', 'summary']
|
|
208
|
+
if args.yolo:
|
|
209
|
+
argv.append('--yolo')
|
|
210
|
+
with open(out / 'shadow.out', 'wb') as log:
|
|
211
|
+
STATE['child'] = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT,
|
|
212
|
+
env=env, close_fds=True)
|
|
213
|
+
status = watch(STATE['child'], parent)
|
|
214
|
+
after = bash('forge_fingerprint', str(review)).stdout.strip()
|
|
215
|
+
if status == 'done' and (not before or after != before):
|
|
216
|
+
status = 'invalid' # the reviewer changed its snapshot: its verdict counts for nothing
|
|
217
|
+
except Stopped:
|
|
218
|
+
status = 'stopped'
|
|
219
|
+
except Exception as error: # a shadow never fails the task
|
|
220
|
+
status = 'error'
|
|
221
|
+
if out is not None:
|
|
222
|
+
write(out / 'shadow.error', '%s: %s\n' % (type(error).__name__, error))
|
|
223
|
+
finally:
|
|
224
|
+
if out is not None:
|
|
225
|
+
write(out / 'shadow.status', status + '\n')
|
|
226
|
+
dataset_event(out, args.task, dict(status=status, attempt=args.attempt, reviewed=args.reviewed,
|
|
227
|
+
primary=args.qa, spec=spec, pick=why))
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def grace_ends() -> float | None:
|
|
232
|
+
"""When the shadow must stop: as long again as the primary took, at least SHADOW_GRACE_S."""
|
|
233
|
+
done = STATE['primary_done']
|
|
234
|
+
if done is None:
|
|
235
|
+
return None
|
|
236
|
+
return done + max(SHADOW_GRACE_S, done - STATE['started'])
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def watch(child: subprocess.Popen, parent: int) -> str:
|
|
240
|
+
"""Wait for the review; stop it when the task dies or stops it, or its grace runs out."""
|
|
241
|
+
while True:
|
|
242
|
+
try:
|
|
243
|
+
child.wait(timeout=1)
|
|
244
|
+
return 'done' if child.returncode == 0 else ('timeout' if child.returncode == 7 else 'error')
|
|
245
|
+
except subprocess.TimeoutExpired:
|
|
246
|
+
pass
|
|
247
|
+
ends = grace_ends()
|
|
248
|
+
if os.getppid() != parent:
|
|
249
|
+
reason = 'orphaned' # the task is gone: an interrupt, or a killed scheduler
|
|
250
|
+
elif STATE['stop']:
|
|
251
|
+
reason = 'stopped'
|
|
252
|
+
elif ends is not None and time.time() >= ends:
|
|
253
|
+
reason = 'overtime'
|
|
254
|
+
else:
|
|
255
|
+
continue
|
|
256
|
+
child.terminate() # dispatch's TERM trap reaps the harness's process group
|
|
257
|
+
try:
|
|
258
|
+
child.wait(timeout=STOP_WAIT_S)
|
|
259
|
+
except subprocess.TimeoutExpired:
|
|
260
|
+
child.kill()
|
|
261
|
+
child.wait()
|
|
262
|
+
return reason
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def preflight(args) -> int:
|
|
266
|
+
plan = Path(args.plan)
|
|
267
|
+
listed = [s.strip() for s in (plan / 'shadow_qa').read_text().replace('\n', ',').split(',') if s.strip()] \
|
|
268
|
+
if (plan / 'shadow_qa').exists() else []
|
|
269
|
+
ok = []
|
|
270
|
+
for spec in listed:
|
|
271
|
+
flags = ['--yolo'] if (plan / 'yolo_qa').exists() else []
|
|
272
|
+
try:
|
|
273
|
+
result = subprocess.run(['/bin/bash', str(DISPATCH), 'doctor', '--spec', spec, '--role', 'qa', *flags],
|
|
274
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120)
|
|
275
|
+
good = result.returncode == 0
|
|
276
|
+
except (OSError, subprocess.SubprocessError):
|
|
277
|
+
good = False
|
|
278
|
+
if good:
|
|
279
|
+
ok.append(spec)
|
|
280
|
+
else:
|
|
281
|
+
print('forge: shadow review spec %s is not usable here; it is skipped' % spec, file=sys.stderr)
|
|
282
|
+
write(plan / 'shadow_qa.ok', ','.join(ok) + '\n')
|
|
283
|
+
if listed and not ok:
|
|
284
|
+
print('forge: no shadow review spec is usable; shadow reviews are off for this run', file=sys.stderr)
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main(argv=None) -> int:
|
|
289
|
+
ap = argparse.ArgumentParser(prog='forge-shadow.py')
|
|
290
|
+
sub = ap.add_subparsers(dest='command', required=True)
|
|
291
|
+
p = sub.add_parser('run')
|
|
292
|
+
for name in ('plan', 'task', 'worktree', 'base', 'reviewed', 'attempt', 'qa', 'tdir'):
|
|
293
|
+
p.add_argument('--' + name, required=True)
|
|
294
|
+
p.add_argument('--yolo', action='store_true')
|
|
295
|
+
p = sub.add_parser('preflight')
|
|
296
|
+
p.add_argument('--plan', required=True)
|
|
297
|
+
args = ap.parse_args(argv)
|
|
298
|
+
try:
|
|
299
|
+
return run(args) if args.command == 'run' else preflight(args)
|
|
300
|
+
except Exception:
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == '__main__':
|
|
305
|
+
sys.exit(main())
|