@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/SKILL.md +22 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +28 -2
  5. package/docs/commands.md +55 -0
  6. package/docs/dataset.md +22 -0
  7. package/docs/decomposed-runs.md +54 -2
  8. package/docs/forge-ui.md +48 -7
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +87 -2
  11. package/forge +31 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +103 -4
  14. package/references/decompose.md +41 -5
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +31 -6
  17. package/scripts/forge-config.py +350 -0
  18. package/scripts/forge-control.py +649 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +1134 -0
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +358 -41
  24. package/scripts/forge-parallel-options.sh +3 -1
  25. package/scripts/forge-parallel.sh +99 -13
  26. package/scripts/forge-prompt.py +61 -4
  27. package/scripts/forge-route.py +245 -28
  28. package/scripts/forge-runs-rm.py +840 -0
  29. package/scripts/forge-runs.py +19 -1
  30. package/scripts/forge-runtime.py +47 -1
  31. package/scripts/forge-schedule.py +227 -11
  32. package/scripts/forge-ui.py +85 -19
  33. package/scripts/forge_dataset/__init__.py +84 -8
  34. package/scripts/forge_dataset/capture.py +5 -2
  35. package/scripts/forge_dataset/cli.py +44 -1
  36. package/scripts/forge_dataset/export.py +36 -3
  37. package/scripts/forge_dataset/forecast.py +355 -0
  38. package/scripts/forge_dataset/harvest.py +45 -2
  39. package/scripts/forge_dataset/importer.py +431 -17
  40. package/scripts/forge_dataset/reviewers.py +145 -0
  41. package/scripts/forge_dataset/routing.py +1 -0
  42. package/scripts/forge_jev/__init__.py +7 -3
  43. package/scripts/forge_registry.py +183 -26
  44. package/ui/README.md +41 -7
  45. package/ui/docs/api.md +259 -21
  46. package/ui/forge_ui/__main__.py +10 -2
  47. package/ui/forge_ui/control.py +587 -0
  48. package/ui/forge_ui/export.py +17 -3
  49. package/ui/forge_ui/reader.py +210 -12
  50. package/ui/forge_ui/server.py +125 -4
  51. package/ui/forge_ui/static/app.js +104 -18
  52. package/ui/forge_ui/static/cloud3d.js +4 -1
  53. package/ui/forge_ui/static/cloudmodel.js +1 -1
  54. package/ui/forge_ui/static/cloudui.js +13 -4
  55. package/ui/forge_ui/static/controls.js +619 -0
  56. package/ui/forge_ui/static/findingsview.js +438 -0
  57. package/ui/forge_ui/static/graph.js +6 -5
  58. package/ui/forge_ui/static/index.html +7 -1
  59. package/ui/forge_ui/static/leaderboard.js +18 -0
  60. package/ui/forge_ui/static/modelstats.js +19 -1
  61. package/ui/forge_ui/static/panel.js +76 -14
  62. package/ui/forge_ui/static/rundelete.js +388 -0
  63. package/ui/forge_ui/static/runmodel.js +128 -13
  64. package/ui/forge_ui/static/scene3d.js +32 -15
  65. package/ui/forge_ui/static/settings.js +399 -0
  66. package/ui/forge_ui/static/style.css +184 -5
  67. package/ui/forge_ui/static/uikit.js +161 -0
@@ -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='server.json.', dir=str(state))
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 / 'server.json')
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 _spawn(state, port, registry, idle):
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
- command = [sys.executable, '-I', str(Path(__file__).resolve()), '_serve', '--port', str(port),
195
- '--quiet', '--registry-dir', registry, '--idle-exit', str(idle)]
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 = marker.read_text().strip()
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
- pass # reuse
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
- first = mark not in opened and browser_wanted(args)
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
- print(f'forge-ui: running at {info["url"]} (pid {info["pid"]}, started {info["started"]})')
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
- base = os.environ.get('XDG_DATA_HOME') or str(Path.home() / '.local' / 'share')
94
- return Path(base).expanduser() / 'forge'
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
- return put_blob(path.read_bytes(), kind)
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
- def record(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
388
- """Write events in one transaction. Fail-open: returns how many were new, 0 on error."""
389
- if not enabled() or not events:
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
- # Refused silently: errors.log lives in the same refused place.
393
- if location_refused(repo):
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
- rid = 'event:%s:%s' % (name, h.sha256(json.dumps([uid, task, time.time_ns(), data], sort_keys=True,
159
- default=str).encode())[:24])
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
- out[(run, task, n)] = (primary[-1], shadow)
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