@giamme/forge 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- package/ui/forge_ui/static/uikit.js +161 -0
package/scripts/forge-ui.py
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
prints a link to the run; every later `open` reuses it. State lives in
|
|
6
6
|
`${XDG_STATE_HOME:-~/.local/state}/forge/ui`: `server.json` (what we started), `server.log`
|
|
7
7
|
and `lock` (serialises launchers). The server exits by itself after FORGE_UI_IDLE_S idle seconds.
|
|
8
|
+
|
|
9
|
+
FORGE_UI_READONLY=1 starts (or insists on) a server that answers no write. The mode is recorded
|
|
10
|
+
with the server, and a running server of the other mode is replaced like one running old code.
|
|
11
|
+
|
|
12
|
+
The server runs without the shell's FORGE_* variables, but the CLIs its writes call need the forge
|
|
13
|
+
configuration (control.CLI_ENV_NAMES): `open` writes those to `cli-env.json` (0600) for the server to
|
|
14
|
+
load, records them in server.json, and refuses (exit 5) to reuse a server started with other values.
|
|
8
15
|
"""
|
|
9
16
|
from __future__ import annotations
|
|
10
17
|
|
|
@@ -38,6 +45,7 @@ LOCK_WAIT_S = 10
|
|
|
38
45
|
START_WAIT_S = 5
|
|
39
46
|
STOP_WAIT_S = 2
|
|
40
47
|
BROWSER_WAIT_S = 2 # a browser command that hangs must not hold `open` past its budget
|
|
48
|
+
CLI_ENV_FILE = 'cli-env.json'
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
def state_dir() -> Path:
|
|
@@ -123,13 +131,13 @@ def read_state(state):
|
|
|
123
131
|
return data if isinstance(data, dict) else None
|
|
124
132
|
|
|
125
133
|
|
|
126
|
-
def write_state(state, data):
|
|
127
|
-
handle, temp = tempfile.mkstemp(prefix='
|
|
134
|
+
def write_state(state, data, name='server.json'):
|
|
135
|
+
handle, temp = tempfile.mkstemp(prefix=name + '.', dir=str(state)) # mkstemp makes it 0600
|
|
128
136
|
try:
|
|
129
137
|
with os.fdopen(handle, 'w') as out:
|
|
130
138
|
json.dump(data, out, indent=1, sort_keys=True)
|
|
131
139
|
out.write('\n')
|
|
132
|
-
os.replace(temp, state /
|
|
140
|
+
os.replace(temp, state / name)
|
|
133
141
|
except BaseException:
|
|
134
142
|
try:
|
|
135
143
|
os.unlink(temp)
|
|
@@ -187,12 +195,25 @@ def terminate(pid) -> bool:
|
|
|
187
195
|
return wait_dead(pid, STOP_WAIT_S)
|
|
188
196
|
|
|
189
197
|
|
|
190
|
-
def
|
|
198
|
+
def read_only_wanted() -> bool:
|
|
199
|
+
return os.environ.get('FORGE_UI_READONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_read_only(answer) -> bool:
|
|
203
|
+
"""Whether a live /api/health answer is a read-only server (a server too old to say is one)."""
|
|
204
|
+
return answer.get('controls') is not True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _spawn(state, port, registry, idle, read_only=False, cli_env=None):
|
|
191
208
|
"""Start the server; returns (process, port) or (None, error text)."""
|
|
192
209
|
log = state / 'server.log'
|
|
193
210
|
env = {k: v for k, v in os.environ.items() if not k.startswith('FORGE_')}
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
# The forge configuration goes to the server's CLIs only, through a file: never into its own environment.
|
|
212
|
+
write_state(state, cli_env or {}, CLI_ENV_FILE)
|
|
213
|
+
command = [sys.executable, '-I', str(Path(__file__).resolve()), '_serve', '--cli-env', str(state / CLI_ENV_FILE),
|
|
214
|
+
'--port', str(port), '--quiet', '--registry-dir', registry, '--idle-exit', str(idle)]
|
|
215
|
+
if read_only: # the server's environment has no FORGE_* variables, so the mode goes as a flag
|
|
216
|
+
command.append('--read-only')
|
|
196
217
|
with os.fdopen(open_regular(log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC), 'w') as out:
|
|
197
218
|
proc = subprocess.Popen(command, cwd=str(state), stdin=subprocess.DEVNULL, stdout=out, stderr=out,
|
|
198
219
|
start_new_session=True, close_fds=True, env=env)
|
|
@@ -211,11 +232,11 @@ def _spawn(state, port, registry, idle):
|
|
|
211
232
|
return None, None
|
|
212
233
|
|
|
213
234
|
|
|
214
|
-
def start_server(state, port, registry, idle):
|
|
235
|
+
def start_server(state, port, registry, idle, read_only=False, cli_env=None):
|
|
215
236
|
"""Spawn a server, falling back once to a free port when the preferred one is taken."""
|
|
216
|
-
proc, result = _spawn(state, port, registry, idle)
|
|
237
|
+
proc, result = _spawn(state, port, registry, idle, read_only, cli_env)
|
|
217
238
|
if proc is None and result == 2 and port != 0:
|
|
218
|
-
proc, result = _spawn(state, 0, registry, idle)
|
|
239
|
+
proc, result = _spawn(state, 0, registry, idle, read_only, cli_env)
|
|
219
240
|
if proc is None:
|
|
220
241
|
return None
|
|
221
242
|
return {'pid': proc.pid, 'port': result}
|
|
@@ -258,6 +279,14 @@ def browse(url):
|
|
|
258
279
|
return 0
|
|
259
280
|
|
|
260
281
|
|
|
282
|
+
def config_change(recorded, current):
|
|
283
|
+
"""How the running server's forge configuration differs from this shell's, as text; '' when it does not."""
|
|
284
|
+
recorded = recorded if isinstance(recorded, dict) else {} # a record without one has none
|
|
285
|
+
names = sorted(name for name in set(recorded) | set(current) if recorded.get(name) != current.get(name))
|
|
286
|
+
return '; '.join(f'{name} is {current.get(name, "unset")} here, {recorded.get(name, "unset")} in the server'
|
|
287
|
+
for name in names)
|
|
288
|
+
|
|
289
|
+
|
|
261
290
|
def run_info(directory):
|
|
262
291
|
"""(repo, None) for a run dir, or (None, why not). Never raises and never blocks."""
|
|
263
292
|
marker = directory / 'repo'
|
|
@@ -269,7 +298,10 @@ def run_info(directory):
|
|
|
269
298
|
# a FIFO or a device named `repo` would block the read: only a regular file is read
|
|
270
299
|
if not stat.S_ISREG(os.stat(marker).st_mode):
|
|
271
300
|
return None, f'{marker} is not a regular file'
|
|
272
|
-
repo
|
|
301
|
+
# only the first line names the repo, as in forge_registry._repo_of (the runners register
|
|
302
|
+
# the same run dir under that line; anything after it must not change where it lands)
|
|
303
|
+
lines = marker.read_text().splitlines()
|
|
304
|
+
repo = lines[0].strip() if lines else ''
|
|
273
305
|
except (OSError, ValueError) as exc: # ValueError: not decodable text
|
|
274
306
|
return None, f'cannot read {marker}: {getattr(exc, "strerror", None) or exc}'
|
|
275
307
|
if not repo:
|
|
@@ -282,6 +314,7 @@ def run_info(directory):
|
|
|
282
314
|
def cmd_open(args):
|
|
283
315
|
import forge_registry
|
|
284
316
|
from forge_ui import __version__, build_id
|
|
317
|
+
from forge_ui.control import cli_env_from
|
|
285
318
|
from forge_ui.reader import run_key
|
|
286
319
|
|
|
287
320
|
try:
|
|
@@ -307,6 +340,8 @@ def cmd_open(args):
|
|
|
307
340
|
state = state_dir()
|
|
308
341
|
log = state / 'server.log'
|
|
309
342
|
registry = str(forge_registry.registry_dir())
|
|
343
|
+
read_only = read_only_wanted()
|
|
344
|
+
cli_env = cli_env_from(os.environ)
|
|
310
345
|
spawned = None
|
|
311
346
|
# Any OSError below is state we cannot use (state dir, lock, server.log, server.json) or a
|
|
312
347
|
# spawn that failed: exit 4. An unreadable or invalid server.json is not one: it means no server.
|
|
@@ -317,26 +352,35 @@ def cmd_open(args):
|
|
|
317
352
|
server = read_state(state)
|
|
318
353
|
answer = confirmed(server) if server else None
|
|
319
354
|
if answer:
|
|
320
|
-
# the record and the live answer must both name this code
|
|
321
|
-
if server.get('build') == build and answer.get('build') == build and server.get('registry') == registry
|
|
322
|
-
|
|
355
|
+
# the record and the live answer must both name this code and the mode asked for
|
|
356
|
+
if (server.get('build') == build and answer.get('build') == build and server.get('registry') == registry
|
|
357
|
+
and bool(server.get('read_only')) == read_only and is_read_only(answer) == read_only):
|
|
358
|
+
# Reused, it would run this shell's writes with another registry and dataset, and a
|
|
359
|
+
# restart could cut off a write in flight: say so instead, and how to restart it.
|
|
360
|
+
change = config_change(server.get('cli_env'), cli_env)
|
|
361
|
+
if change:
|
|
362
|
+
_say(f'error: the running forge-ui (pid {server["pid"]}) has another forge configuration '
|
|
363
|
+
f'for its writes: {change}. Run `forge ui stop`, then open again.')
|
|
364
|
+
return 5
|
|
323
365
|
else:
|
|
324
366
|
terminate(server['pid'])
|
|
325
367
|
server = None
|
|
326
368
|
else:
|
|
327
369
|
server = None # dead, or a pid that is not our forge-ui: never signal it
|
|
328
370
|
if server is None:
|
|
329
|
-
spawned = start_server(state, port, registry, idle)
|
|
371
|
+
spawned = start_server(state, port, registry, idle, read_only, cli_env)
|
|
330
372
|
if spawned is None:
|
|
331
373
|
_say(f'error: the server did not start; see {log}')
|
|
332
374
|
return 4
|
|
333
|
-
server = dict(spawned, version=__version__, build=build, registry=registry, log=str(log),
|
|
334
|
-
started=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), opened=[])
|
|
375
|
+
server = dict(spawned, version=__version__, build=build, registry=registry, log=str(log), read_only=read_only,
|
|
376
|
+
cli_env=cli_env, started=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), opened=[])
|
|
335
377
|
opened = server.get('opened')
|
|
336
378
|
opened = opened if isinstance(opened, list) else []
|
|
337
379
|
mark = key or ''
|
|
338
380
|
# a key counts as opened only when a browser is really launched for it
|
|
339
|
-
|
|
381
|
+
wanted = browser_wanted(args)
|
|
382
|
+
first = mark not in opened and wanted
|
|
383
|
+
again = mark in opened and wanted # say so, or a repeated `open` looks like it did nothing
|
|
340
384
|
if first:
|
|
341
385
|
opened.append(mark)
|
|
342
386
|
server['opened'] = opened
|
|
@@ -354,6 +398,8 @@ def cmd_open(args):
|
|
|
354
398
|
print(f'forge-ui: {url}')
|
|
355
399
|
if first:
|
|
356
400
|
launch_browser(url)
|
|
401
|
+
elif again:
|
|
402
|
+
_say('a browser tab was already opened for this link, so no new one was opened; use the link above')
|
|
357
403
|
return 0
|
|
358
404
|
|
|
359
405
|
|
|
@@ -368,11 +414,13 @@ def cmd_status(args):
|
|
|
368
414
|
return 1
|
|
369
415
|
info = {'running': True, 'pid': server['pid'], 'port': server['port'],
|
|
370
416
|
'url': f'http://{HOST}:{server["port"]}/', 'version': answer.get('version'),
|
|
371
|
-
'build': answer.get('build'), 'started': server.get('started'), 'log': server.get('log')
|
|
417
|
+
'build': answer.get('build'), 'started': server.get('started'), 'log': server.get('log'),
|
|
418
|
+
'read_only': is_read_only(answer)}
|
|
372
419
|
if args.json:
|
|
373
420
|
print(json.dumps(info, sort_keys=True))
|
|
374
421
|
else:
|
|
375
|
-
|
|
422
|
+
mode = 'read-only' if info['read_only'] else 'read-write'
|
|
423
|
+
print(f'forge-ui: running at {info["url"]} (pid {info["pid"]}, started {info["started"]}, {mode})')
|
|
376
424
|
return 0
|
|
377
425
|
|
|
378
426
|
|
|
@@ -403,8 +451,26 @@ def cmd_stop(args):
|
|
|
403
451
|
return 0
|
|
404
452
|
|
|
405
453
|
|
|
454
|
+
def load_cli_env(path):
|
|
455
|
+
"""The forge configuration `_spawn` wrote for the server's CLIs; OSError or ValueError otherwise."""
|
|
456
|
+
from forge_ui import control
|
|
457
|
+
with os.fdopen(open_regular(path, os.O_RDONLY), 'rb') as handle:
|
|
458
|
+
return control.check_cli_env(json.loads(handle.read()))
|
|
459
|
+
|
|
460
|
+
|
|
406
461
|
def serve(argv):
|
|
462
|
+
from forge_ui import control
|
|
407
463
|
from forge_ui.__main__ import main
|
|
464
|
+
if argv[:1] == ['--cli-env']:
|
|
465
|
+
if len(argv) < 2:
|
|
466
|
+
_say('error: --cli-env needs a file')
|
|
467
|
+
return 1 # not 2: start_server reads 2 as a taken port
|
|
468
|
+
try:
|
|
469
|
+
control.cli_env = load_cli_env(argv[1])
|
|
470
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
471
|
+
_say(f'error: cannot load the CLI configuration {argv[1]}: {getattr(exc, "strerror", None) or exc}')
|
|
472
|
+
return 1
|
|
473
|
+
argv = argv[2:]
|
|
408
474
|
return main(argv)
|
|
409
475
|
|
|
410
476
|
|
|
@@ -18,6 +18,8 @@ can still be answered from old runs.
|
|
|
18
18
|
Capture is fail-open in the same way Jev is. Every entry point swallows its own errors
|
|
19
19
|
into errors.log, never prints, never raises, and bounds its wait on the database with the
|
|
20
20
|
SQLite busy timeout. It uses no signals, because Fractal captures from worker threads.
|
|
21
|
+
record_strict() is the one exception, for a caller that must know an event is stored (it
|
|
22
|
+
raises DatasetError instead of returning 0).
|
|
21
23
|
FORGE_DATASET=off turns it off.
|
|
22
24
|
|
|
23
25
|
Nothing that builds a prompt may ever read this store: a reviewer that could see earlier
|
|
@@ -25,12 +27,14 @@ runs' verdicts would no longer be independent.
|
|
|
25
27
|
"""
|
|
26
28
|
from __future__ import annotations
|
|
27
29
|
|
|
30
|
+
import contextlib
|
|
28
31
|
import gzip
|
|
29
32
|
import hashlib
|
|
30
33
|
import json
|
|
31
34
|
import os
|
|
32
35
|
import sqlite3
|
|
33
36
|
import subprocess
|
|
37
|
+
import threading
|
|
34
38
|
import time
|
|
35
39
|
import traceback
|
|
36
40
|
import uuid
|
|
@@ -90,8 +94,13 @@ def data_home() -> Path:
|
|
|
90
94
|
explicit = os.environ.get('FORGE_DATA_HOME')
|
|
91
95
|
if explicit:
|
|
92
96
|
return Path(explicit).expanduser()
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
# An empty or relative XDG_DATA_HOME counts as unset, as the XDG spec says; taken
|
|
98
|
+
# literally it named a directory below the current one, usually the user's repo.
|
|
99
|
+
value = os.environ.get('XDG_DATA_HOME') or ''
|
|
100
|
+
base = Path(value).expanduser() if value else None
|
|
101
|
+
if base is None or not base.is_absolute():
|
|
102
|
+
base = Path.home() / '.local' / 'share'
|
|
103
|
+
return base / 'forge'
|
|
95
104
|
|
|
96
105
|
|
|
97
106
|
def dataset_dir() -> Path:
|
|
@@ -253,14 +262,46 @@ def put_blob(data: bytes | str, kind: str = 'text') -> dict:
|
|
|
253
262
|
n=len(data), kept=len(stored), cut=span)
|
|
254
263
|
|
|
255
264
|
|
|
265
|
+
_BLOB_WATCH = threading.local()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@contextlib.contextmanager
|
|
269
|
+
def watch_blob_failures():
|
|
270
|
+
"""Collect this thread's failed blob writes while the block runs (yields the list).
|
|
271
|
+
|
|
272
|
+
Only a caller that asks pays for the record: a long-lived process that never watches
|
|
273
|
+
keeps nothing, and another thread's failed write never lands in this thread's list.
|
|
274
|
+
"""
|
|
275
|
+
previous = getattr(_BLOB_WATCH, 'failures', None)
|
|
276
|
+
failures: list[str] = []
|
|
277
|
+
_BLOB_WATCH.failures = failures
|
|
278
|
+
try:
|
|
279
|
+
yield failures
|
|
280
|
+
finally:
|
|
281
|
+
_BLOB_WATCH.failures = previous
|
|
282
|
+
|
|
283
|
+
|
|
256
284
|
def put_file(path: str | Path, kind: str = 'text') -> dict | None:
|
|
285
|
+
"""Store a file's content; None when it is absent, unreadable, or could not be stored.
|
|
286
|
+
|
|
287
|
+
A failed write into the blob store is also noted for a watch_blob_failures() block: the caller
|
|
288
|
+
still gets None (live capture fails open), but a strict importer can tell "no file" from
|
|
289
|
+
"content lost" and refuse to let the event win its rid without it.
|
|
290
|
+
"""
|
|
257
291
|
path = Path(path)
|
|
258
292
|
try:
|
|
259
293
|
if not path.is_file():
|
|
260
294
|
return None
|
|
261
|
-
|
|
295
|
+
data = path.read_bytes()
|
|
262
296
|
except OSError:
|
|
263
297
|
return None
|
|
298
|
+
try:
|
|
299
|
+
return put_blob(data, kind)
|
|
300
|
+
except OSError as error:
|
|
301
|
+
failures = getattr(_BLOB_WATCH, 'failures', None)
|
|
302
|
+
if failures is not None:
|
|
303
|
+
failures.append('%s: cannot store content: %s' % (path, error))
|
|
304
|
+
return None
|
|
264
305
|
|
|
265
306
|
|
|
266
307
|
def read_blob(stored_sha: str) -> bytes:
|
|
@@ -384,13 +425,29 @@ def insert(db: sqlite3.Connection, event: dict) -> bool:
|
|
|
384
425
|
return cursor.rowcount == 1
|
|
385
426
|
|
|
386
427
|
|
|
387
|
-
|
|
388
|
-
"""
|
|
389
|
-
|
|
428
|
+
class DatasetError(Exception):
|
|
429
|
+
"""An event could not be persisted (or an import/archive could not be completed)."""
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _record(events: list[dict], repo: str | Path | None, where: str, strict: bool) -> int:
|
|
433
|
+
"""The one writer behind record() and record_strict(), so the two cannot drift.
|
|
434
|
+
|
|
435
|
+
Lenient: any problem is logged (or, for a refused location, ignored) and counts as 0.
|
|
436
|
+
Strict: the same problems raise DatasetError, because 0 new rows must keep meaning
|
|
437
|
+
"everything was already there".
|
|
438
|
+
"""
|
|
439
|
+
if not events:
|
|
390
440
|
return 0
|
|
391
441
|
try:
|
|
392
|
-
|
|
393
|
-
|
|
442
|
+
if not enabled():
|
|
443
|
+
if strict:
|
|
444
|
+
raise DatasetError('the dataset is off (FORGE_DATASET=off)')
|
|
445
|
+
return 0
|
|
446
|
+
refused = location_refused(repo)
|
|
447
|
+
if refused:
|
|
448
|
+
# Not logged: errors.log lives in the same refused place.
|
|
449
|
+
if strict:
|
|
450
|
+
raise DatasetError(refused)
|
|
394
451
|
return 0
|
|
395
452
|
# Fingerprinting the code reads every shipped file and runs git once per process:
|
|
396
453
|
# done under the write lock, it held every other writer past its busy timeout.
|
|
@@ -414,11 +471,30 @@ def record(events: list[dict], repo: str | Path | None = None, where: str = 'rec
|
|
|
414
471
|
raise
|
|
415
472
|
finally:
|
|
416
473
|
db.close()
|
|
474
|
+
except DatasetError:
|
|
475
|
+
raise
|
|
417
476
|
except Exception as error:
|
|
418
477
|
log_error(where, error)
|
|
478
|
+
if strict:
|
|
479
|
+
raise DatasetError('%s: %s' % (where, error)) from error
|
|
419
480
|
return 0
|
|
420
481
|
|
|
421
482
|
|
|
483
|
+
def record(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
|
|
484
|
+
"""Write events in one transaction. Fail-open: returns how many were new, 0 on error."""
|
|
485
|
+
return _record(events, repo, where, strict=False)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def record_strict(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
|
|
489
|
+
"""Like record(), but a failure to persist raises DatasetError instead of returning 0.
|
|
490
|
+
|
|
491
|
+
For callers that must know every event is in the store (`forge runs rm` before it
|
|
492
|
+
deletes a run dir). Covers a refused location, a store that is off or unwritable, a
|
|
493
|
+
sqlite error and a database still locked after the busy timeout.
|
|
494
|
+
"""
|
|
495
|
+
return _record(events, repo, where, strict=True)
|
|
496
|
+
|
|
497
|
+
|
|
422
498
|
def create_once(path: Path, text: str) -> str:
|
|
423
499
|
"""Write `text` to `path` unless it exists; return whichever content won.
|
|
424
500
|
|
|
@@ -155,8 +155,11 @@ def point(name: str, directory: str | None, task: str | None = None, values: dic
|
|
|
155
155
|
refs[label] = put_blob(value, 'log')
|
|
156
156
|
task = task or ctx.get('task')
|
|
157
157
|
data['blobs'] = refs
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
if name in h.INTERVENTIONS:
|
|
159
|
+
rid = h.intervention_rid(name, uid, task, values or {})
|
|
160
|
+
else:
|
|
161
|
+
rid = 'event:%s:%s' % (name, h.sha256(json.dumps([uid, task, time.time_ns(), data], sort_keys=True,
|
|
162
|
+
default=str).encode())[:24])
|
|
160
163
|
record([dict(rid=rid, kind=kind, ts=time.time(), run=uid, run_id=ctx.get('run_id'),
|
|
161
164
|
mode=ctx.get('mode'), task=task, parent=env['parent'], inv=env['inv'], data=data)],
|
|
162
165
|
repo=repo or ctx.get('repo'), where='event:' + name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""`forge dataset ...`: inspect the run dataset, import old runs, and the shell's capture hooks.
|
|
2
2
|
|
|
3
|
-
User-facing: status, dump, import, export, outcomes. Internal (called by forge's shell scripts, fail-open,
|
|
3
|
+
User-facing: status, dump, import, export, outcomes, routing, reviewers, forecast. Internal (called by forge's shell scripts, fail-open,
|
|
4
4
|
silent): event, invocation, plan.
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
@@ -28,6 +28,24 @@ def _human(n: int) -> str:
|
|
|
28
28
|
return str(n)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _continued_sessions(db) -> dict:
|
|
32
|
+
"""How many dispatches asked to continue a reviewer session.
|
|
33
|
+
|
|
34
|
+
Counted from the dispatch events themselves. A shadow review is not required, and a
|
|
35
|
+
continue that failed still counts: the file was written because continuing was requested.
|
|
36
|
+
"""
|
|
37
|
+
continued = failed = 0
|
|
38
|
+
for (raw,) in db.execute(
|
|
39
|
+
"SELECT data FROM events WHERE kind = 'dispatch' AND data LIKE ?",
|
|
40
|
+
('%"session_continue": "%',)):
|
|
41
|
+
value = json.loads(raw).get('session_continue')
|
|
42
|
+
if value == 'continued':
|
|
43
|
+
continued += 1
|
|
44
|
+
elif value == 'failed':
|
|
45
|
+
failed += 1
|
|
46
|
+
return dict(continued_reviews=continued, failed_continues=failed)
|
|
47
|
+
|
|
48
|
+
|
|
31
49
|
def status(args) -> int:
|
|
32
50
|
from . import checks
|
|
33
51
|
directory = dataset_dir()
|
|
@@ -43,6 +61,7 @@ def status(args) -> int:
|
|
|
43
61
|
last = db.execute('SELECT MAX(ts) FROM events').fetchone()[0]
|
|
44
62
|
summary['last_event'] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last)) if last else None
|
|
45
63
|
summary['checks'] = checks.run(db, verbose=args.verbose)
|
|
64
|
+
summary.update(_continued_sessions(db))
|
|
46
65
|
if summary['events'].get('dispatch'):
|
|
47
66
|
from . import export
|
|
48
67
|
if db.execute("SELECT 1 FROM events WHERE kind = 'dispatch' AND data LIKE '%qa_shadow%' LIMIT 1").fetchone():
|
|
@@ -77,6 +96,9 @@ def status(args) -> int:
|
|
|
77
96
|
% (shadow['shadow_reviews'], shadow['pairs'], shadow['agree'], rate))
|
|
78
97
|
for pair, n in shadow['by_pair'].items():
|
|
79
98
|
print(' %s: %d of %d agree' % (pair, n['agree'], n['pairs']))
|
|
99
|
+
if summary.get('continued_reviews') or summary.get('failed_continues'):
|
|
100
|
+
print(' continued reviews: %d' % summary['continued_reviews'])
|
|
101
|
+
print(' failed continues: %d' % summary['failed_continues'])
|
|
80
102
|
if summary['legacy_archives']:
|
|
81
103
|
print(' legacy: %d archived run dir(s) kept in %s (`forge dataset import %s` re-reads them;'
|
|
82
104
|
' events already stored are skipped)' % (summary['legacy_archives'], legacy, legacy))
|
|
@@ -163,6 +185,16 @@ def run_routing(args) -> int:
|
|
|
163
185
|
return routing.main(args)
|
|
164
186
|
|
|
165
187
|
|
|
188
|
+
def run_reviewers(args) -> int:
|
|
189
|
+
from . import reviewers
|
|
190
|
+
return reviewers.main(args)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_forecast(args) -> int:
|
|
194
|
+
from . import forecast
|
|
195
|
+
return forecast.main(args)
|
|
196
|
+
|
|
197
|
+
|
|
166
198
|
def parser() -> argparse.ArgumentParser:
|
|
167
199
|
top = argparse.ArgumentParser(prog='forge dataset', description='The run dataset: every run\'s inputs and outcomes.')
|
|
168
200
|
sub = top.add_subparsers(dest='command', required=True)
|
|
@@ -204,6 +236,17 @@ def parser() -> argparse.ArgumentParser:
|
|
|
204
236
|
p.add_argument('--since', type=float, metavar='DAYS', help='only tasks first attempted in the last DAYS days')
|
|
205
237
|
p.add_argument('--json', action='store_true')
|
|
206
238
|
p.set_defaults(func=run_routing)
|
|
239
|
+
p = sub.add_parser('reviewers', help='how often work each reviewer passed was later reverted or rewritten (advisory)')
|
|
240
|
+
p.add_argument('--horizon-days', type=int, default=14, help='the outcomes recorded at this horizon (default 14)')
|
|
241
|
+
p.add_argument('--repo', help='only this repo')
|
|
242
|
+
p.add_argument('--json', action='store_true')
|
|
243
|
+
p.set_defaults(func=run_reviewers)
|
|
244
|
+
p = sub.add_parser('forecast', help="a plan's likely tokens, time and first-try pass rate, from past tasks (advisory)")
|
|
245
|
+
p.add_argument('plan', help='the plan dir')
|
|
246
|
+
p.add_argument('--repo', help='only history from this repo')
|
|
247
|
+
p.add_argument('--json', action='store_true')
|
|
248
|
+
p.add_argument('--run', action='store_true', help=argparse.SUPPRESS) # forge-parallel.sh run: budgeted, silent, records
|
|
249
|
+
p.set_defaults(func=run_forecast)
|
|
207
250
|
p = sub.add_parser('event', help=argparse.SUPPRESS)
|
|
208
251
|
p.add_argument('name')
|
|
209
252
|
p.add_argument('--dir')
|
|
@@ -213,6 +213,10 @@ class Runs:
|
|
|
213
213
|
for e in store.of('dispatch'):
|
|
214
214
|
if e['task']:
|
|
215
215
|
self.dispatches[(e['run'], e['task'], e['d'].get('role'))].append(e)
|
|
216
|
+
# Store order is import order. A reader that takes [-1] means the dispatch
|
|
217
|
+
# the attempt ended on, so each list is chronological; a tie keeps store order.
|
|
218
|
+
for rows in self.dispatches.values():
|
|
219
|
+
rows.sort(key=lambda e: (e['attempt'] or 0, e['d'].get('wall_started') or e['ts']))
|
|
216
220
|
self.accepted = {(e['run'], e['d'].get('task')) for e in store.of('invocation')
|
|
217
221
|
if e['d'].get('command') == 'accept' and e['d'].get('exit') == 0}
|
|
218
222
|
self.interrupted = {(e['run'], e['task']) for e in store.of('event')
|
|
@@ -558,8 +562,23 @@ def _float(value):
|
|
|
558
562
|
return None
|
|
559
563
|
|
|
560
564
|
|
|
565
|
+
def session_continued(event) -> bool:
|
|
566
|
+
"""Whether this dispatch was asked to continue a reviewer session.
|
|
567
|
+
|
|
568
|
+
`continued` and `failed` both record the request. A missing field, on a dispatch
|
|
569
|
+
captured before the experiment, is a fresh session.
|
|
570
|
+
"""
|
|
571
|
+
return (event.get('d') or {}).get('session_continue') is not None
|
|
572
|
+
|
|
573
|
+
|
|
561
574
|
def _qa_rows(runs: Runs):
|
|
562
|
-
"""Every QA dispatch with a verdict, with the diff it was shown and its task text.
|
|
575
|
+
"""Every independent QA dispatch with a verdict, with the diff it was shown and its task text.
|
|
576
|
+
|
|
577
|
+
A dispatch that continued a reviewer session is not an independent judgment, including
|
|
578
|
+
one whose continue failed after it had already printed a verdict. A fresh fallback in
|
|
579
|
+
the same round has no `session_continue` and stays. There is no switch to put the
|
|
580
|
+
continued ones back, so a slice never mixes the two.
|
|
581
|
+
"""
|
|
563
582
|
store = runs.store
|
|
564
583
|
effort = {}
|
|
565
584
|
for (run, task), rows in runs.attempts.items():
|
|
@@ -572,6 +591,8 @@ def _qa_rows(runs: Runs):
|
|
|
572
591
|
if role != 'qa':
|
|
573
592
|
continue
|
|
574
593
|
for e in rows:
|
|
594
|
+
if session_continued(e):
|
|
595
|
+
continue
|
|
575
596
|
d = e['d']
|
|
576
597
|
if d.get('verdict') not in ('PASS', 'FAIL'):
|
|
577
598
|
continue
|
|
@@ -604,6 +625,12 @@ def shadow_pairs(runs: Runs) -> dict:
|
|
|
604
625
|
Usable means the shadow ran to its end on an untouched snapshot (`shadow_review` says
|
|
605
626
|
done), both reviewers gave a verdict, and the shadow reviewed the commit that attempt
|
|
606
627
|
committed. Anything else would count noise in the shadow as disagreement.
|
|
628
|
+
|
|
629
|
+
The pair uses the attempt's last verdict-bearing primary. The dispatch list is
|
|
630
|
+
chronological, so that entry is the one the attempt ended on. A QA-only re-run keeps
|
|
631
|
+
the attempt number, so the candidates stay unfiltered until that last one is chosen:
|
|
632
|
+
an earlier fresh verdict must not stand in for a later continued session. The pair is
|
|
633
|
+
then dropped when that chosen primary continued a session.
|
|
607
634
|
"""
|
|
608
635
|
ended = {}
|
|
609
636
|
for e in runs.store.of('event'):
|
|
@@ -620,8 +647,14 @@ def shadow_pairs(runs: Runs) -> dict:
|
|
|
620
647
|
primary = [d for d in runs.dispatches.get((run, task, 'qa'), [])
|
|
621
648
|
if d['attempt'] == n and d['d'].get('verdict') in ('PASS', 'FAIL')]
|
|
622
649
|
reviewed = (shadow['d'].get('shadow') or {}).get('reviewed')
|
|
623
|
-
if primary and reviewed and reviewed == runs.reviewed.get((run, task, n)):
|
|
624
|
-
|
|
650
|
+
if not (primary and reviewed and reviewed == runs.reviewed.get((run, task, n))):
|
|
651
|
+
continue
|
|
652
|
+
# Chosen after the list is built, and that list is chronological: filtering
|
|
653
|
+
# continued reviews out first would let an earlier fresh verdict stand in
|
|
654
|
+
# for the one the attempt ended on.
|
|
655
|
+
if session_continued(primary[-1]):
|
|
656
|
+
continue
|
|
657
|
+
out[(run, task, n)] = (primary[-1], shadow)
|
|
625
658
|
return out
|
|
626
659
|
|
|
627
660
|
|