@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,275 @@
|
|
|
1
|
+
"""`forge dataset outcomes`: what happened to merged work after it landed, read from git.
|
|
2
|
+
|
|
3
|
+
Every other outcome in the store is a reviewer's opinion. This one is not: once
|
|
4
|
+
`integrate` fast-forwards a branch, the lines each task added either stay in the code or
|
|
5
|
+
get rewritten or reverted. That is observable later, from the repo's history alone.
|
|
6
|
+
|
|
7
|
+
`integrate` merges the run branch with --no-ff and fast-forwards the user's branch to that
|
|
8
|
+
merge, never squashing, so every attempt commit lands as-is and `git blame` still
|
|
9
|
+
attributes a task's surviving lines to its own commits (base_ref..reviewed_commit).
|
|
10
|
+
For each `integrate_advanced` event this records, per task it brought in, how many of the
|
|
11
|
+
lines the task added are still attributed to the task's commits at a horizon (14 days by
|
|
12
|
+
default) after the integration, and whether any later commit reverted it.
|
|
13
|
+
|
|
14
|
+
Survival describes the artifact that landed (with every retry it took), not the first
|
|
15
|
+
model that was routed to the task: the exporter attaches it to the review of that diff.
|
|
16
|
+
|
|
17
|
+
Read-only by construction: the store is opened read-only (connect() would migrate it),
|
|
18
|
+
and git runs only object-store queries (rev-parse, rev-list, log, diff between commits,
|
|
19
|
+
blame at a commit, cat-file) with GIT_OPTIONAL_LOCKS=0, so nothing touches a working
|
|
20
|
+
tree or an index. Results are written with record(), which refuses a store inside the
|
|
21
|
+
repo. A horizon still in the future, or a query that failed, writes nothing and is tried
|
|
22
|
+
again next time; the rid names the integration, the reviewed commit and the horizon, so
|
|
23
|
+
a repeat harvest adds nothing.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sqlite3
|
|
31
|
+
import subprocess
|
|
32
|
+
import time
|
|
33
|
+
from collections import Counter, defaultdict
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from . import dataset_dir, record, sha256
|
|
37
|
+
|
|
38
|
+
HORIZON_DAYS = 14
|
|
39
|
+
GIT_TIMEOUT_S = 120
|
|
40
|
+
_REVERTS = re.compile(r'This reverts commit ([0-9a-f]{7,40})')
|
|
41
|
+
_HUNK = re.compile(rb'^([0-9a-f]{40}) \d+ \d+ (\d+)$')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GitError(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Git:
|
|
49
|
+
"""Object-store queries in one repo. A failure raises; it never reads as "nothing found"."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, repo: str):
|
|
52
|
+
self.repo = repo
|
|
53
|
+
self.env = dict(os.environ, GIT_OPTIONAL_LOCKS='0', GIT_TERMINAL_PROMPT='0', LC_ALL='C')
|
|
54
|
+
|
|
55
|
+
def out(self, *args, ok=(0,)) -> tuple[int, bytes]:
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(['git', '-C', self.repo, *args], stdout=subprocess.PIPE,
|
|
58
|
+
stderr=subprocess.DEVNULL, timeout=GIT_TIMEOUT_S, env=self.env)
|
|
59
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
60
|
+
raise GitError('%s: %s' % (args[0], error)) from error
|
|
61
|
+
if result.returncode not in ok:
|
|
62
|
+
raise GitError('%s exited %d' % (args[0], result.returncode))
|
|
63
|
+
return result.returncode, result.stdout
|
|
64
|
+
|
|
65
|
+
def text(self, *args) -> str:
|
|
66
|
+
return self.out(*args)[1].decode('utf-8', 'replace')
|
|
67
|
+
|
|
68
|
+
def exists(self, obj: str) -> bool:
|
|
69
|
+
return self.out('cat-file', '-e', obj, ok=(0, 1, 128))[0] == 0
|
|
70
|
+
|
|
71
|
+
def blamed(self, rev: str, path: str, commits: set) -> int:
|
|
72
|
+
"""Lines of `path` at `rev` whose origin is one of `commits`, streamed, never capped."""
|
|
73
|
+
try:
|
|
74
|
+
proc = subprocess.Popen(['git', '-C', self.repo, 'blame', '--incremental', rev, '--', path],
|
|
75
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=self.env)
|
|
76
|
+
except OSError as error:
|
|
77
|
+
raise GitError('blame: %s' % error) from error
|
|
78
|
+
count = 0
|
|
79
|
+
try:
|
|
80
|
+
for line in proc.stdout:
|
|
81
|
+
match = _HUNK.match(line.rstrip(b'\n'))
|
|
82
|
+
if match and match.group(1).decode() in commits:
|
|
83
|
+
count += int(match.group(2))
|
|
84
|
+
if proc.wait(timeout=GIT_TIMEOUT_S):
|
|
85
|
+
raise GitError('blame %s exited %d' % (path, proc.returncode))
|
|
86
|
+
except subprocess.TimeoutExpired as error:
|
|
87
|
+
proc.kill()
|
|
88
|
+
raise GitError('blame %s timed out' % path) from error
|
|
89
|
+
finally:
|
|
90
|
+
proc.stdout.close()
|
|
91
|
+
return count
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_store():
|
|
95
|
+
"""Integrations, merges and attempts, from a read-only connection; None without a store."""
|
|
96
|
+
path = dataset_dir() / 'dataset.sqlite'
|
|
97
|
+
if not path.exists():
|
|
98
|
+
return None
|
|
99
|
+
db = sqlite3.connect('file:%s?mode=ro' % path, uri=True, timeout=5)
|
|
100
|
+
try:
|
|
101
|
+
rows = db.execute("SELECT kind, run, task, repo, ts, data FROM events WHERE kind IN ('event', 'attempt')"
|
|
102
|
+
).fetchall()
|
|
103
|
+
repos = dict(db.execute('SELECT repo, path FROM repos'))
|
|
104
|
+
have = {rid for (rid,) in db.execute("SELECT rid FROM events WHERE kind = 'outcome'")}
|
|
105
|
+
finally:
|
|
106
|
+
db.close()
|
|
107
|
+
integrations, merges, bases = [], {}, {}
|
|
108
|
+
for kind, run, task, repo, ts, data in rows:
|
|
109
|
+
d = json.loads(data)
|
|
110
|
+
if kind == 'attempt':
|
|
111
|
+
if d.get('reviewed_commit') and d.get('base_ref'):
|
|
112
|
+
bases[d['reviewed_commit']] = d['base_ref']
|
|
113
|
+
elif d.get('name') == 'integrate_advanced' and d.get('candidate') and d.get('parent'):
|
|
114
|
+
integrations.append(dict(run=run, repo=repo, ts=ts, **{k: d.get(k) for k in
|
|
115
|
+
('branch', 'parent', 'candidate', 'target')}))
|
|
116
|
+
elif d.get('name') == 'merge' and d.get('status') == 'MERGED' and task:
|
|
117
|
+
commit = d.get('commit') or d.get('reviewed_commit')
|
|
118
|
+
if commit:
|
|
119
|
+
# The last MERGED event for a commit wins; split/combine copies keep their run.
|
|
120
|
+
merges[(repo, commit)] = dict(run=run, task=task, commit=commit, integration=d.get('integration'))
|
|
121
|
+
return dict(integrations=sorted(integrations, key=lambda i: i['ts']), merges=merges, bases=bases,
|
|
122
|
+
repos=repos, have=have)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def outcome_rid(candidate: str, reviewed: str, horizon_days: int) -> str:
|
|
126
|
+
return 'outcome:survival:' + sha256(('%s:%s:%d' % (candidate, reviewed, horizon_days)).encode())[:24]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def horizon_rev(git: Git, branch: str, candidate: str, cutoff: float, now: float):
|
|
130
|
+
"""('ok', rev, ts) | ('lost', None, None) | ('pending', None, None).
|
|
131
|
+
|
|
132
|
+
The branch is resolved once to an immutable tip. The candidate must sit on its
|
|
133
|
+
first-parent chain (a rebase, squash or reset loses it: unobservable, not failure).
|
|
134
|
+
The horizon is found by position along that chain, so out-of-order commit dates
|
|
135
|
+
cannot jump past a commit that came later.
|
|
136
|
+
"""
|
|
137
|
+
if not branch or git.out('rev-parse', '--verify', '-q', branch + '^{commit}', ok=(0, 1))[0]:
|
|
138
|
+
return 'lost', None, None
|
|
139
|
+
tip = git.text('rev-parse', branch + '^{commit}').strip()
|
|
140
|
+
chain = [] # first-parent commits after the candidate, newest first
|
|
141
|
+
for line in git.text('log', '--first-parent', '--format=%H %ct %P', '%s..%s' % (candidate, tip)).splitlines():
|
|
142
|
+
sha, ct, *parents = line.split()
|
|
143
|
+
chain.append((sha, int(ct), parents))
|
|
144
|
+
if tip != candidate and (not chain or chain[-1][2][:1] != [candidate]):
|
|
145
|
+
return 'lost', None, None # the walk never passed through the candidate
|
|
146
|
+
chain = [(sha, ct) for sha, ct, _ in reversed(chain)]
|
|
147
|
+
candidate_ct = int(git.text('log', '-1', '--format=%ct', candidate).strip())
|
|
148
|
+
rev, rev_ts = candidate, candidate_ct
|
|
149
|
+
for sha, ct in chain:
|
|
150
|
+
if ct > cutoff:
|
|
151
|
+
return 'ok', rev, rev_ts
|
|
152
|
+
rev, rev_ts = sha, ct
|
|
153
|
+
if now < cutoff:
|
|
154
|
+
return 'pending', None, None # more commits may still land before the horizon
|
|
155
|
+
return 'ok', rev, rev_ts
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def survival(git: Git, base: str, reviewed: str, rev: str) -> dict:
|
|
159
|
+
commits = set(git.text('rev-list', '%s..%s' % (base, reviewed)).split())
|
|
160
|
+
files, added_total, surviving_total = {}, 0, 0
|
|
161
|
+
for entry in git.text('diff', '--numstat', '-z', '--no-renames', base, reviewed).split('\0'):
|
|
162
|
+
cells = entry.lstrip('\n').split('\t', 2)
|
|
163
|
+
if len(cells) != 3 or cells[0] == '-':
|
|
164
|
+
continue # binary: no lines to follow
|
|
165
|
+
added, path = int(cells[0]), cells[2]
|
|
166
|
+
if added == 0:
|
|
167
|
+
continue
|
|
168
|
+
kept = git.blamed(rev, path, commits) if git.exists('%s:%s' % (rev, path)) else 0
|
|
169
|
+
kept = min(kept, added)
|
|
170
|
+
files[path] = [added, kept]
|
|
171
|
+
added_total += added
|
|
172
|
+
surviving_total += kept
|
|
173
|
+
return dict(commits=sorted(commits), files=files, added=added_total, surviving=surviving_total)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def reverts(git: Git, candidate: str, rev: str, shas: set) -> list:
|
|
177
|
+
if rev == candidate:
|
|
178
|
+
return []
|
|
179
|
+
out = []
|
|
180
|
+
for block in git.text('log', '--format=%H%x00%B%x01', '%s..%s' % (candidate, rev)).split('\x01'):
|
|
181
|
+
sha, _, body = block.strip('\n').partition('\x00')
|
|
182
|
+
if any(any(s.startswith(m) for s in shas) for m in _REVERTS.findall(body)):
|
|
183
|
+
out.append(sha)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def harvest(*, horizon_days: int = HORIZON_DAYS, repo: str | None = None, include_temp: bool = False,
|
|
188
|
+
now: float | None = None) -> tuple[list, Counter]:
|
|
189
|
+
from .importer import is_temp
|
|
190
|
+
state = read_store()
|
|
191
|
+
counts = Counter()
|
|
192
|
+
if state is None:
|
|
193
|
+
return [], counts
|
|
194
|
+
now = time.time() if now is None else now
|
|
195
|
+
wanted = str(Path(repo).resolve()) if repo else None
|
|
196
|
+
by_repo = defaultdict(list)
|
|
197
|
+
for integration in state['integrations']:
|
|
198
|
+
path = state['repos'].get(integration['repo'])
|
|
199
|
+
if not path or (wanted and str(Path(path).resolve()) != wanted):
|
|
200
|
+
continue
|
|
201
|
+
if is_temp(path) and not include_temp:
|
|
202
|
+
counts['skipped_temp'] += 1
|
|
203
|
+
continue
|
|
204
|
+
if not Path(path).is_dir():
|
|
205
|
+
counts['repo_missing'] += 1
|
|
206
|
+
continue
|
|
207
|
+
by_repo[path].append(integration)
|
|
208
|
+
out = []
|
|
209
|
+
for path, integrations in by_repo.items():
|
|
210
|
+
git = Git(path)
|
|
211
|
+
for integration in integrations:
|
|
212
|
+
candidate, parent = integration['candidate'], integration['parent']
|
|
213
|
+
try:
|
|
214
|
+
if not git.exists(candidate + '^{commit}'):
|
|
215
|
+
counts['candidate_missing'] += 1
|
|
216
|
+
continue
|
|
217
|
+
landed = set(git.text('rev-list', candidate, '^' + parent).split())
|
|
218
|
+
tasks = [m for (r, c), m in state['merges'].items() if r == integration['repo'] and c in landed]
|
|
219
|
+
todo = [m for m in tasks if outcome_rid(candidate, m['commit'], horizon_days) not in state['have']]
|
|
220
|
+
counts['already'] += len(tasks) - len(todo)
|
|
221
|
+
if not todo:
|
|
222
|
+
continue
|
|
223
|
+
cutoff = integration['ts'] + horizon_days * 86400
|
|
224
|
+
status, rev, rev_ts = horizon_rev(git, integration['branch'], candidate, cutoff, now)
|
|
225
|
+
except GitError:
|
|
226
|
+
counts['git_error'] += 1
|
|
227
|
+
continue
|
|
228
|
+
if status == 'pending':
|
|
229
|
+
counts['pending'] += len(todo)
|
|
230
|
+
continue
|
|
231
|
+
for merge in todo:
|
|
232
|
+
base = state['bases'].get(merge['commit'])
|
|
233
|
+
if not base:
|
|
234
|
+
counts['no_base'] += 1
|
|
235
|
+
continue
|
|
236
|
+
data = dict(name='survival', candidate=candidate, reviewed=merge['commit'], base_ref=base,
|
|
237
|
+
branch=integration['branch'], horizon_days=horizon_days, integrated_ts=integration['ts'],
|
|
238
|
+
horizon_rev=rev, horizon_ts=rev_ts, integrate_run=integration['run'])
|
|
239
|
+
try:
|
|
240
|
+
if status == 'ok':
|
|
241
|
+
found = survival(git, base, merge['commit'], rev)
|
|
242
|
+
task_shas = set(found.pop('commits')) | {s for s in (merge.get('integration'), candidate) if s}
|
|
243
|
+
data.update(found, reverted_by=reverts(git, candidate, rev, task_shas),
|
|
244
|
+
status='ok' if found['added'] else 'no_additions')
|
|
245
|
+
else:
|
|
246
|
+
data.update(status='lost')
|
|
247
|
+
except GitError:
|
|
248
|
+
counts['git_error'] += 1
|
|
249
|
+
continue
|
|
250
|
+
counts[data['status']] += 1
|
|
251
|
+
out.append((path, dict(rid=outcome_rid(candidate, merge['commit'], horizon_days), kind='outcome',
|
|
252
|
+
src='harvest', ts=now, run=merge['run'], task=merge['task'],
|
|
253
|
+
mode='parallel', data=data)))
|
|
254
|
+
return out, counts
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main(args) -> int:
|
|
258
|
+
events, counts = harvest(horizon_days=args.horizon_days, repo=args.repo, include_temp=args.include_temp)
|
|
259
|
+
written = 0
|
|
260
|
+
if not args.dry_run:
|
|
261
|
+
by_repo = defaultdict(list)
|
|
262
|
+
for path, event in events:
|
|
263
|
+
by_repo[path].append(event)
|
|
264
|
+
for path, batch in by_repo.items():
|
|
265
|
+
written += record(batch, repo=path, where='outcomes')
|
|
266
|
+
label = 'would record' if args.dry_run else 'recorded'
|
|
267
|
+
print('%s %d survival outcome(s) at %d days' % (label, len(events) if args.dry_run else written,
|
|
268
|
+
args.horizon_days))
|
|
269
|
+
for key in ('ok', 'no_additions', 'lost', 'pending', 'already', 'no_base', 'candidate_missing',
|
|
270
|
+
'repo_missing', 'skipped_temp', 'git_error'):
|
|
271
|
+
if counts.get(key):
|
|
272
|
+
print(' %-17s %d' % (key, counts[key]))
|
|
273
|
+
if not args.dry_run and written < len(events):
|
|
274
|
+
print(' %d not written; see %s' % (len(events) - written, dataset_dir() / 'errors.log'))
|
|
275
|
+
return 0
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""`forge dataset routing`: how each model does per tier, and what the randomized picks say.
|
|
2
|
+
|
|
3
|
+
Two tables, kept apart because they answer different questions:
|
|
4
|
+
|
|
5
|
+
- **Observational**: every task's first attempt, by declared tier and the model and effort
|
|
6
|
+
that ran it (resolved, so `sol` and `gpt-6.1-sol:medium:codex` are one row). First-pass rate
|
|
7
|
+
(Wilson 95%), the share that passed review at some attempt (or that a human accepted),
|
|
8
|
+
unfinished tasks, attempts per task and the implementer's runtime. Routing chose who ran what, so a model sent the hard tasks looks
|
|
9
|
+
worse here than it is. Fractal runs are left out: their dispatches have their own identities
|
|
10
|
+
and their runtime would be missing.
|
|
11
|
+
- **Randomized**: the `plan --explore` draws (forge-route.py), one row per (tier, default,
|
|
12
|
+
alternative) pair, since round-robin pools make the pair differ between rows and a spec has
|
|
13
|
+
no chance at all outside its own pairs. Each task counts in the arm it was *assigned*
|
|
14
|
+
(intent to treat), whatever the approval gate or a retry did afterwards; tasks with no
|
|
15
|
+
outcome are attrition, reported per arm. First-pass per arm is a Hajek (self-normalized
|
|
16
|
+
inverse-propensity) mean, the difference has a run-cluster bootstrap interval, and Kish's
|
|
17
|
+
effective n and the number of runs say how much the interval can be trusted.
|
|
18
|
+
|
|
19
|
+
The decision rule is fixed in advance and only advises: a pair is judged at the first of 30,
|
|
20
|
+
60 or 120 alternative assignments it reaches, on exactly the assignments up to that point.
|
|
21
|
+
The alternative is the stronger model for a low-tier pair (switch if the interval is above
|
|
22
|
+
0) and the cheaper one for medium and high (non-inferior if the interval's lower end is above
|
|
23
|
+
-0.10 and the alternative's runtime is lower). Nothing here changes a plan.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import math
|
|
29
|
+
import os
|
|
30
|
+
import random
|
|
31
|
+
import statistics
|
|
32
|
+
import time
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
|
|
35
|
+
from . import connect
|
|
36
|
+
from .export import Runs, Store, _attempts
|
|
37
|
+
|
|
38
|
+
WINDOWS = (30, 60, 120)
|
|
39
|
+
NONINFERIORITY = -0.10
|
|
40
|
+
BOOTSTRAP = 2000
|
|
41
|
+
MIN_ALTERNATIVES, MIN_RUNS = 10, 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wilson(k: int, n: int, z: float = 1.96):
|
|
45
|
+
if not n:
|
|
46
|
+
return None, None
|
|
47
|
+
p = k / n
|
|
48
|
+
centre = (p + z * z / (2 * n)) / (1 + z * z / n)
|
|
49
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
|
|
50
|
+
return max(0.0, centre - half), min(1.0, centre + half)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def decision_before(decisions, started):
|
|
54
|
+
"""The routing decision attempt 1 ran under: the latest recorded before it started.
|
|
55
|
+
|
|
56
|
+
A re-plan after attempt 1 began (or one with a new draw) must not relabel what that
|
|
57
|
+
attempt was assigned. Decisions repeat across plan revisions, so they are deduplicated.
|
|
58
|
+
"""
|
|
59
|
+
unique = {}
|
|
60
|
+
for d in decisions or ():
|
|
61
|
+
unique[d.get('decision') or json.dumps(d, sort_keys=True)] = d
|
|
62
|
+
ordered = sorted(unique.values(), key=lambda d: d.get('at') or 0)
|
|
63
|
+
if not ordered:
|
|
64
|
+
return None
|
|
65
|
+
if started is None:
|
|
66
|
+
return ordered[-1]
|
|
67
|
+
before = [d for d in ordered if (d.get('at') or 0) <= started + 1]
|
|
68
|
+
return before[-1] if before else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _started(runs: Runs, run, task, first) -> float | None:
|
|
72
|
+
for e in runs.dispatches.get((run, task, 'dwarf'), []):
|
|
73
|
+
if e['attempt'] == first['n'] and e['d'].get('wall_started'):
|
|
74
|
+
return float(e['d']['wall_started'])
|
|
75
|
+
return first['events'][0]['ts'] if first else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _runtime(runs: Runs, run, task) -> float | None:
|
|
79
|
+
durations = [e['d'].get('duration_s') for e in runs.dispatches.get((run, task, 'dwarf'), [])]
|
|
80
|
+
durations = [x for x in durations if isinstance(x, (int, float))]
|
|
81
|
+
return sum(durations) if durations else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def tasks(runs: Runs, *, repo=None, since=None) -> list:
|
|
85
|
+
"""One record per (run, task) that a plan held, with its routing decision and outcome."""
|
|
86
|
+
out = []
|
|
87
|
+
for run, revisions in runs.plans.items():
|
|
88
|
+
if any((p['d'].get('fractal_selection') or {}).get('enabled') for p in revisions):
|
|
89
|
+
continue
|
|
90
|
+
rows, decisions = {}, defaultdict(list)
|
|
91
|
+
for plan in revisions:
|
|
92
|
+
for tid, row in (plan['d'].get('tasks') or {}).items():
|
|
93
|
+
rows[tid] = row
|
|
94
|
+
decisions[tid].extend(row.get('route') or [])
|
|
95
|
+
for tid, row in rows.items():
|
|
96
|
+
events = runs.attempts.get((run, tid), [])
|
|
97
|
+
attempts = _attempts(runs, run, tid, events)
|
|
98
|
+
first = attempts[0] if attempts else None
|
|
99
|
+
if repo and not (events and runs.store.repo_paths.get(events[0]['repo']) == repo):
|
|
100
|
+
continue
|
|
101
|
+
if since and not (events and events[0]['ts'] >= since):
|
|
102
|
+
continue
|
|
103
|
+
started = _started(runs, run, tid, first) if first else None
|
|
104
|
+
decision = decision_before(decisions.get(tid), started)
|
|
105
|
+
resolved = runs.resolved(run, tid, 'dwarf', first['n']) if first else {}
|
|
106
|
+
model = ':'.join(x for x in (resolved.get('model'), resolved.get('effort')) if x) or None
|
|
107
|
+
out.append(dict(
|
|
108
|
+
run=run, task=tid, tier=row.get('declared_difficulty') or row.get('difficulty'),
|
|
109
|
+
ran=first['events'][0]['d'].get('dwarf') if first else None, model=model,
|
|
110
|
+
first_pass=first['outcome'] if first else None,
|
|
111
|
+
censored=(first['reason'] if first and first['outcome'] is None else (None if first else 'not_run')),
|
|
112
|
+
passed=any(a['outcome'] for a in attempts) or (run, tid) in runs.accepted,
|
|
113
|
+
attempts=len(attempts), runtime_s=_runtime(runs, run, tid), decision=decision))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def observational(records) -> list:
|
|
118
|
+
cells = defaultdict(list)
|
|
119
|
+
for r in records:
|
|
120
|
+
if (r['model'] or r['ran']) and r['first_pass'] is not None:
|
|
121
|
+
cells[(r['tier'], r['model'] or r['ran'])].append(r)
|
|
122
|
+
rows = []
|
|
123
|
+
for (tier, spec), group in sorted(cells.items(), key=lambda kv: (str(kv[0][0]), str(kv[0][1]))):
|
|
124
|
+
k = sum(1 for r in group if r['first_pass'])
|
|
125
|
+
low, high = wilson(k, len(group))
|
|
126
|
+
runtimes = [r['runtime_s'] for r in group if r['runtime_s'] is not None]
|
|
127
|
+
rows.append(dict(tier=tier, spec=spec, tasks=len(group), first_pass=k / len(group), ci=[low, high],
|
|
128
|
+
passed=sum(1 for r in group if r['passed']) / len(group),
|
|
129
|
+
unfinished=sum(1 for r in group if not r['passed']),
|
|
130
|
+
attempts=statistics.mean(r['attempts'] for r in group),
|
|
131
|
+
runtime_min=statistics.median(runtimes) / 60 if runtimes else None))
|
|
132
|
+
return rows
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _hajek(items):
|
|
136
|
+
"""(weighted first-pass, Kish effective n) over items with an outcome."""
|
|
137
|
+
known = [(1 / i['propensity'], 1.0 if i['first_pass'] else 0.0) for i in items if i['first_pass'] is not None]
|
|
138
|
+
total = sum(w for w, _ in known)
|
|
139
|
+
if not total:
|
|
140
|
+
return None, 0.0
|
|
141
|
+
return sum(w * y for w, y in known) / total, total * total / sum(w * w for w, _ in known)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _difference(items):
|
|
145
|
+
default = [i for i in items if i['arm'] == 'default']
|
|
146
|
+
alternative = [i for i in items if i['arm'] == 'alternative']
|
|
147
|
+
a, _ = _hajek(alternative)
|
|
148
|
+
d, _ = _hajek(default)
|
|
149
|
+
return None if a is None or d is None else a - d
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _bootstrap(items, rng):
|
|
153
|
+
by_run = defaultdict(list)
|
|
154
|
+
for i in items:
|
|
155
|
+
by_run[i['run']].append(i)
|
|
156
|
+
runs = sorted(by_run, key=str)
|
|
157
|
+
diffs = []
|
|
158
|
+
for _ in range(BOOTSTRAP):
|
|
159
|
+
sample = [x for run in (rng.choice(runs) for _ in runs) for x in by_run[run]]
|
|
160
|
+
diff = _difference(sample)
|
|
161
|
+
if diff is not None:
|
|
162
|
+
diffs.append(diff)
|
|
163
|
+
if len(diffs) < BOOTSTRAP // 2:
|
|
164
|
+
return None
|
|
165
|
+
diffs.sort()
|
|
166
|
+
return [diffs[int(0.025 * len(diffs))], diffs[int(0.975 * len(diffs)) - 1]]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def randomized(records, seed: int = 7) -> list:
|
|
170
|
+
pairs = defaultdict(list)
|
|
171
|
+
old_draws = 0
|
|
172
|
+
for r in records:
|
|
173
|
+
d = r['decision']
|
|
174
|
+
candidates = (d or {}).get('candidates') or []
|
|
175
|
+
if not d:
|
|
176
|
+
continue
|
|
177
|
+
if len(candidates) != 2 or not d.get('propensity'):
|
|
178
|
+
old_draws += 1 # an old all-specs draw has no fixed pair to compare in
|
|
179
|
+
continue
|
|
180
|
+
alternative = next(c for c in candidates if c != d.get('default'))
|
|
181
|
+
pairs[(d.get('tier'), d.get('default'), alternative)].append(dict(
|
|
182
|
+
r, arm='alternative' if d.get('chosen') == alternative else 'default',
|
|
183
|
+
propensity=float(d['propensity']), at=d.get('at') or 0,
|
|
184
|
+
followed=r['ran'] == d.get('chosen') if r['ran'] else None))
|
|
185
|
+
rows = []
|
|
186
|
+
rng = random.Random(seed)
|
|
187
|
+
if old_draws:
|
|
188
|
+
rows.append(dict(note='%d older exploratory draw(s) picked among every spec of their plan; they have no '
|
|
189
|
+
'fixed pair to compare in and are left out' % old_draws))
|
|
190
|
+
for (tier, default, alternative), items in sorted(pairs.items(), key=lambda kv: tuple(map(str, kv[0]))):
|
|
191
|
+
items.sort(key=lambda i: i['at'])
|
|
192
|
+
n_alt = sum(1 for i in items if i['arm'] == 'alternative')
|
|
193
|
+
window = max((w for w in WINDOWS if n_alt >= w), default=None)
|
|
194
|
+
judged = items
|
|
195
|
+
if window:
|
|
196
|
+
cut = [i for i in items if i['arm'] == 'alternative'][window - 1]['at']
|
|
197
|
+
judged = [i for i in items if i['at'] <= cut]
|
|
198
|
+
arms = {}
|
|
199
|
+
for arm in ('default', 'alternative'):
|
|
200
|
+
group = [i for i in judged if i['arm'] == arm]
|
|
201
|
+
rate, n_eff = _hajek(group)
|
|
202
|
+
runtimes = [i['runtime_s'] for i in group if i['runtime_s'] is not None]
|
|
203
|
+
arms[arm] = dict(spec=default if arm == 'default' else alternative, assigned=len(group),
|
|
204
|
+
first_pass=rate, n_eff=round(n_eff, 1),
|
|
205
|
+
not_followed=sum(1 for i in group if i['followed'] is False),
|
|
206
|
+
no_outcome=sum(1 for i in group if i['first_pass'] is None),
|
|
207
|
+
runtime_min=statistics.median(runtimes) / 60 if runtimes else None)
|
|
208
|
+
run_count = len({i['run'] for i in judged})
|
|
209
|
+
diff = _difference(judged)
|
|
210
|
+
ci = (_bootstrap(judged, rng) if arms['alternative']['assigned'] >= MIN_ALTERNATIVES
|
|
211
|
+
and run_count >= MIN_RUNS else None)
|
|
212
|
+
stronger_alternative = tier == 'low'
|
|
213
|
+
if not window:
|
|
214
|
+
verdict = 'not enough randomized data (%d/%d alternative assignments)' % (n_alt, WINDOWS[0])
|
|
215
|
+
elif ci is None:
|
|
216
|
+
verdict = 'window %d reached, but too few runs for an interval' % window
|
|
217
|
+
elif stronger_alternative:
|
|
218
|
+
verdict = ('switch to %s: it passes more (interval above 0)' % alternative if ci[0] > 0
|
|
219
|
+
else 'keep %s: %s is not shown to pass more' % (default, alternative))
|
|
220
|
+
else:
|
|
221
|
+
cheaper_faster = (arms['alternative']['runtime_min'] or math.inf) < (arms['default']['runtime_min'] or 0)
|
|
222
|
+
verdict = ('%s is non-inferior and faster: worth making the default' % alternative
|
|
223
|
+
if ci[0] > NONINFERIORITY and cheaper_faster
|
|
224
|
+
else 'keep %s: %s is not shown to be non-inferior' % (default, alternative))
|
|
225
|
+
rows.append(dict(tier=tier, default=default, alternative=alternative, alternative_is='stronger'
|
|
226
|
+
if stronger_alternative else 'cheaper', window=window, runs=run_count, arms=arms,
|
|
227
|
+
difference=diff, ci=ci, verdict=verdict))
|
|
228
|
+
return rows
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _pct(x):
|
|
232
|
+
return ' -' if x is None else '%3.0f%%' % (100 * x)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(args) -> int:
|
|
236
|
+
db = connect()
|
|
237
|
+
try:
|
|
238
|
+
runs = Runs(Store(db))
|
|
239
|
+
finally:
|
|
240
|
+
db.close()
|
|
241
|
+
repo = os.path.realpath(args.repo) if args.repo else None
|
|
242
|
+
since = time.time() - args.since * 86400 if args.since else None
|
|
243
|
+
records = tasks(runs, repo=repo, since=since)
|
|
244
|
+
report = dict(observational=observational(records), randomized=randomized(records))
|
|
245
|
+
if args.json:
|
|
246
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
247
|
+
return 0
|
|
248
|
+
print('observational: first attempts by declared tier and the model that ran them')
|
|
249
|
+
print(' (routing chose who ran what, so a model given the hard tasks looks worse than it is)')
|
|
250
|
+
print(' %-7s %-26s %5s %6s %-13s %6s %10s %8s %8s' % ('tier', 'model:effort', 'tasks', 'first', '95%',
|
|
251
|
+
'passed', 'unfinished', 'attempts', 'dwarf min'))
|
|
252
|
+
for r in report['observational']:
|
|
253
|
+
low, high = r['ci']
|
|
254
|
+
print(' %-7s %-26s %5d %6s [%s-%s] %6s %10d %8.1f %8s' % (
|
|
255
|
+
r['tier'], r['spec'], r['tasks'], _pct(r['first_pass']), _pct(low).strip(), _pct(high).strip(),
|
|
256
|
+
_pct(r['passed']), r['unfinished'], r['attempts'],
|
|
257
|
+
'-' if r['runtime_min'] is None else '%.1f' % r['runtime_min']))
|
|
258
|
+
print()
|
|
259
|
+
print('randomized (plan --explore): each pair of models, tasks counted in the arm they were assigned')
|
|
260
|
+
pairs = [r for r in report['randomized'] if 'note' not in r]
|
|
261
|
+
for r in report['randomized']:
|
|
262
|
+
if 'note' in r:
|
|
263
|
+
print(' (%s)' % r['note'])
|
|
264
|
+
if not pairs:
|
|
265
|
+
print(' no exploratory decisions in pairs yet; `plan` explores at 0.2 by default')
|
|
266
|
+
for r in pairs:
|
|
267
|
+
d, a = r['arms']['default'], r['arms']['alternative']
|
|
268
|
+
print(' %s: %s (default) vs %s (%s), %d run(s)%s' % (
|
|
269
|
+
r['tier'], r['default'], r['alternative'], r['alternative_is'], r['runs'],
|
|
270
|
+
', judged at window %d' % r['window'] if r['window'] else ''))
|
|
271
|
+
for name, arm in (('default', d), ('alternative', a)):
|
|
272
|
+
print(' %-11s %-24s assigned %3d first-pass %s n_eff %5.1f not followed %d no outcome %d dwarf min %s' % (
|
|
273
|
+
name, arm['spec'], arm['assigned'], _pct(arm['first_pass']), arm['n_eff'], arm['not_followed'],
|
|
274
|
+
arm['no_outcome'], '-' if arm['runtime_min'] is None else '%.1f' % arm['runtime_min']))
|
|
275
|
+
if r['difference'] is not None:
|
|
276
|
+
print(' difference %+.2f%s' % (r['difference'], '' if not r['ci'] else ' 95%% [%+.2f, %+.2f]' % tuple(r['ci'])))
|
|
277
|
+
print(' -> %s' % r['verdict'])
|
|
278
|
+
return 0
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Opt-in Fractal adapter. Importing this package never imports Fractal."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import fcntl
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
SCRIPTS = Path(__file__).resolve().parents[1]
|
|
14
|
+
REVISION = '18793200c0d7e8cdb2db369ea3abe5647a1e15e4'
|
|
15
|
+
VERSION = '1.2.0'
|
|
16
|
+
ARCHIVE_SHA256 = '3f7714ee0baec05af8d856db2b00d63398026fae2fcf50edbee542671126072c'
|
|
17
|
+
DEFAULTS = dict(depth=2, children=3, nodes=12, iterations=6, concurrency=3, deadline=2700)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def state_root() -> Path:
|
|
21
|
+
# An empty XDG_STATE_HOME counts as unset; Path('') would be the working directory.
|
|
22
|
+
return Path(os.environ.get('XDG_STATE_HOME') or str(Path.home() / '.local/state')).expanduser().resolve() / 'forge/fractal'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def runtime_python() -> Path:
|
|
26
|
+
return state_root() / 'runtime/bin/python'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_json(path: Path, data: object) -> None:
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
_runtime.write_json(path, data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_runtime_spec = importlib.util.spec_from_file_location('forge_runtime', SCRIPTS / 'forge-runtime.py')
|
|
35
|
+
_runtime = importlib.util.module_from_spec(_runtime_spec)
|
|
36
|
+
_runtime_spec.loader.exec_module(_runtime)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_json(path: Path) -> dict:
|
|
40
|
+
return json.loads(path.read_text())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def identifier(value: str) -> str:
|
|
44
|
+
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,100}', value):
|
|
45
|
+
raise ValueError('Invalid managed identifier: ' + repr(value))
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def managed_run(value: str) -> Path:
|
|
50
|
+
root = state_root() / 'runs'
|
|
51
|
+
path = root / identifier(value)
|
|
52
|
+
if path.is_symlink() or path.resolve().parent != root.resolve():
|
|
53
|
+
raise ValueError('Run must be a direct managed directory')
|
|
54
|
+
if not path.is_dir() or (path / 'run.json').is_symlink():
|
|
55
|
+
raise ValueError('Unknown managed run: ' + value)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def safe_file(root: Path, relative: str) -> Path:
|
|
60
|
+
path = root / relative
|
|
61
|
+
if path.resolve() != path.absolute() or not path.resolve().is_relative_to(root.resolve()):
|
|
62
|
+
raise ValueError('Artifact path escapes managed data or contains a symlink')
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@contextlib.contextmanager
|
|
67
|
+
def lock(path: Path, blocking: bool = True):
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
with path.open('a') as handle:
|
|
70
|
+
fcntl.flock(handle, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
71
|
+
yield
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def command(argv: list, *, cwd: Path | None = None, timeout: float = 120, input: bytes | None = None) -> bytes:
|
|
75
|
+
result = subprocess.run(list(map(str, argv)), cwd=cwd, input=input,
|
|
76
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
|
77
|
+
if result.returncode:
|
|
78
|
+
raise RuntimeError(result.stderr.decode(errors='replace') or result.stdout.decode(errors='replace'))
|
|
79
|
+
return result.stdout
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def artifact(verb: str, *args: object) -> str:
|
|
83
|
+
return command(['/bin/bash', '-c', 'source "$1"; shift; "$@"', 'forge',
|
|
84
|
+
SCRIPTS / 'forge-artifact.sh', verb, *args]).decode().strip()
|