@giamme/forge 0.10.0 → 0.11.1
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 +43 -0
- package/SKILL.md +4 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +13 -1
- package/docs/commands.md +11 -5
- package/docs/dataset.md +13 -0
- package/docs/decomposed-runs.md +13 -3
- package/docs/forge-ui.md +8 -3
- package/docs/memory.md +4 -2
- package/docs/reviews.md +73 -2
- package/forge +16 -1
- package/package.json +1 -1
- package/references/dataset.md +86 -4
- package/references/decompose.md +24 -6
- package/references/harnesses.md +43 -1
- package/references/ui.md +4 -1
- package/scripts/forge-config.py +5 -2
- package/scripts/forge-control.py +4 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +345 -17
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +132 -14
- package/scripts/forge-parallel.sh +96 -11
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +72 -12
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-ui.py +8 -4
- package/scripts/forge_dataset/__init__.py +7 -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 +23 -2
- package/scripts/forge_dataset/importer.py +20 -8
- 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 +15 -10
- package/ui/README.md +5 -4
- package/ui/docs/api.md +88 -23
- package/ui/forge_ui/control.py +26 -5
- package/ui/forge_ui/reader.py +184 -11
- package/ui/forge_ui/static/app.js +24 -10
- 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/findingsview.js +65 -14
- package/ui/forge_ui/static/graph.js +6 -5
- 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 +52 -10
- 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 +48 -12
- package/ui/forge_ui/static/style.css +7 -3
package/scripts/forge-prompt.py
CHANGED
|
@@ -5,20 +5,72 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
DIFF_INLINE_CAP = 100 * 1024
|
|
9
|
+
REPLY_CAP = 8 * 1024
|
|
10
|
+
|
|
11
|
+
# What a reviewer is asked to do with the previous round's report. The labelled-line rule
|
|
12
|
+
# matters: the severity gate, `forge issues` and forge-ui count every `- [Px][...]` line as open.
|
|
13
|
+
QA_ROUND_NOTE = """\
|
|
14
|
+
Check each finding above first: is it fixed, still present, or validly rejected by the
|
|
15
|
+
implementer (see their reply below)? Report a finding that is still present again, in the
|
|
16
|
+
usual labelled format with its severity. Mention a fixed or rejected finding only in a plain
|
|
17
|
+
sentence, never as a `- [Px][CONFIRMED]` or `- [Px][PLAUSIBLE]` line: every labelled line is
|
|
18
|
+
counted as an open finding. This round's threshold, in the contract below, governs; it may
|
|
19
|
+
differ from the last round's. Then review the full task diff below: the changes since that
|
|
20
|
+
round are where new problems are most likely."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read(path: str | None, errors: str = 'strict') -> str:
|
|
24
|
+
if path and Path(path).is_file():
|
|
25
|
+
with Path(path).open(newline="", errors=errors) as source:
|
|
26
|
+
return source.read()
|
|
27
|
+
return ''
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def carried(text: str) -> str:
|
|
31
|
+
# The dataset and forge-ui take the LAST ```diff fence of a QA prompt as the reviewed
|
|
32
|
+
# diff, so nothing carried from an earlier round may open one.
|
|
33
|
+
return text.replace('```diff', '```text')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def qa_round(findings: str, diff: str | None, span: str | None, stat: str | None, reply: str | None) -> str:
|
|
37
|
+
parts = ['## Findings from the previous review round\n' + carried(findings).rstrip('\n') + '\n\n' + QA_ROUND_NOTE]
|
|
38
|
+
# A git diff carries raw source bytes, so a file that is not UTF-8 must not abort the prompt.
|
|
39
|
+
patch = read(diff, 'replace')
|
|
40
|
+
if patch:
|
|
41
|
+
label = ' (' + span.strip() + ')' if span and span.strip() else ''
|
|
42
|
+
if len(patch.encode()) <= DIFF_INLINE_CAP:
|
|
43
|
+
body = '```patch\n' + patch.rstrip('\n') + '\n```'
|
|
44
|
+
else:
|
|
45
|
+
body = ('The diff since that review is too large to inline; its stat:\n\n```text\n'
|
|
46
|
+
+ read(stat, 'replace').rstrip('\n') + '\n```')
|
|
47
|
+
parts.append('### Changes since that review' + label + '\n' + body)
|
|
48
|
+
said = read(reply, 'replace')
|
|
49
|
+
if said.strip():
|
|
50
|
+
raw = said.encode()
|
|
51
|
+
if len(raw) > REPLY_CAP:
|
|
52
|
+
said = raw[:REPLY_CAP].decode(errors='ignore') + '\n[... the reply continues; cut here ...]\n'
|
|
53
|
+
parts.append("### The implementer's reply (its own account; verify every claim against the code)\n"
|
|
54
|
+
+ carried(said).rstrip('\n'))
|
|
55
|
+
return '\n\n'.join(parts) + '\n'
|
|
56
|
+
|
|
8
57
|
|
|
9
58
|
def context(
|
|
10
59
|
requirements: str, approach: str | None = None, capsule: str | None = None,
|
|
11
60
|
retry: str | None = None, goal: str | None = None, memories: tuple[str, ...] = (),
|
|
61
|
+
role: str = 'dwarf', retry_diff: str | None = None, retry_range: str | None = None,
|
|
62
|
+
retry_stat: str | None = None, reply: str | None = None,
|
|
12
63
|
) -> str:
|
|
13
64
|
chunks = []
|
|
14
65
|
for title, path in [('Ownership and baseline', capsule), ('Overall goal', goal),
|
|
15
66
|
('Complete implementation requirements', requirements),
|
|
16
67
|
('Intended approach', approach),
|
|
17
68
|
('Your previous attempt was reviewed and rejected', retry)]:
|
|
18
|
-
if path and Path(path).is_file()
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
69
|
+
data = read(path) if path and Path(path).is_file() else ''
|
|
70
|
+
if data:
|
|
71
|
+
if role == 'qa' and path == retry:
|
|
72
|
+
chunks.append(qa_round(data, retry_diff, retry_range, retry_stat, reply).rstrip('\n'))
|
|
73
|
+
else:
|
|
22
74
|
chunks.append('## ' + title + '\n' + data)
|
|
23
75
|
# Memory injection emits headings and prose around bullet facts. Union only
|
|
24
76
|
# exact fact lines, preserving distinct facts even when they share a key.
|
|
@@ -40,6 +92,11 @@ if __name__ == '__main__':
|
|
|
40
92
|
for key in ('requirements', 'approach', 'capsule', 'retry', 'goal'):
|
|
41
93
|
parser.add_argument('--' + key, required=key == 'requirements')
|
|
42
94
|
parser.add_argument('--memory', action='append', default=[])
|
|
95
|
+
parser.add_argument('--role', choices=('dwarf', 'qa'), default='dwarf')
|
|
96
|
+
parser.add_argument('--retry-diff')
|
|
97
|
+
parser.add_argument('--retry-range')
|
|
98
|
+
parser.add_argument('--retry-stat')
|
|
99
|
+
parser.add_argument('--reply')
|
|
43
100
|
args = vars(parser.parse_args())
|
|
44
101
|
args['memories'] = args.pop('memory')
|
|
45
102
|
print(context(**args), end='')
|
package/scripts/forge-route.py
CHANGED
|
@@ -16,7 +16,7 @@ bias (inverse-propensity weighting needs the probability each choice had).
|
|
|
16
16
|
rewritten) and marks a changed pick in tasks/<id>/route.explored for the table. A row
|
|
17
17
|
with no neighbour is not explored and records nothing.
|
|
18
18
|
|
|
19
|
-
forge-route.py escalation --plan P --task ID [--check]
|
|
19
|
+
forge-route.py escalation --plan P --task ID [--check] [--qa-only-next yes|no]
|
|
20
20
|
The retry policy: after <plan>/escalate_after (default 3; 0 = off) failed attempts on
|
|
21
21
|
one spec, the next attempt goes to the next tier up of the user's pools instead of
|
|
22
22
|
repeating the same dispatch. Prints that spec when one is due. When none exists, writes
|
|
@@ -25,11 +25,21 @@ bias (inverse-propensity weighting needs the probability each choice had).
|
|
|
25
25
|
the streak no longer holds. A pending tasks/<id>/escalation.pin (a human's one-shot
|
|
26
26
|
choice, applied by prepare_retry) is what `--check` reports as the next model instead
|
|
27
27
|
(escalation.next = its spec), whatever escalate_after says, unless the next retry is a
|
|
28
|
-
QA-only resume or the plan is Fractal (pins never apply there).
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
QA-only resume or the plan is Fractal (pins never apply there). `--qa-only-next` is
|
|
29
|
+
that fact as the caller measured it; without the option, a resume marker or an
|
|
30
|
+
unreviewed commit is taken as QA-only. Nothing has applied the pin yet, so the policy's
|
|
31
|
+
own stop and split markers stay as the policy decided them beneath it: they are what
|
|
32
|
+
holds again if the pin is withdrawn. The marker writes take <plan>/control.lock and
|
|
33
|
+
re-read escalate_after inside it: a policy decision made before a human switched the
|
|
34
|
+
policy off writes nothing.
|
|
35
|
+
|
|
36
|
+
forge-route.py record --plan P --task ID (--probe | --line TEXT)
|
|
37
|
+
Internal. Probe or append tasks/<id>/escalations.tsv through forge-control.py, which
|
|
38
|
+
walks from the plan one directory at a time and refuses a symlink at any component.
|
|
39
|
+
`--probe` exits 0 when every component below the plan is a real directory and the file
|
|
40
|
+
is absent or a regular file. `--line` appends one record under those same conditions.
|
|
41
|
+
A missing parent exits 1 and writes nothing: the walk has no directory descriptor then,
|
|
42
|
+
and opening the leaf would create it in the process's current directory.
|
|
33
43
|
|
|
34
44
|
The propensity of the chosen spec is (1-E)*[chosen == SPEC] + E/2. Forge never picks a
|
|
35
45
|
model the user did not name: both helpers read only the pools `plan` stored in
|
|
@@ -389,11 +399,10 @@ def _pin(tdir: Path):
|
|
|
389
399
|
|
|
390
400
|
|
|
391
401
|
def _qa_next(tdir: Path) -> bool:
|
|
392
|
-
"""
|
|
402
|
+
"""Fallback when `--qa-only-next` is absent: a resume marker, or an unreviewed commit.
|
|
393
403
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
`--check` does, right after the attempt.
|
|
404
|
+
This does not look at the worktree. Callers that measured it (qa_only_next) pass
|
|
405
|
+
`--qa-only-next` instead, so a marker the worktree no longer matches is a full retry.
|
|
397
406
|
"""
|
|
398
407
|
if (tdir / 'resume').exists():
|
|
399
408
|
return True
|
|
@@ -455,8 +464,10 @@ def escalation(args) -> int:
|
|
|
455
464
|
idle = after <= 0 or fractal or (tdir / 'resume').exists()
|
|
456
465
|
# A pin is a human's choice and outranks the policy, so `--check` reports it as the next
|
|
457
466
|
# model whatever escalate_after says; prepare_retry applies it and claims the file. The
|
|
458
|
-
# policy is still decided underneath it and its markers kept current.
|
|
459
|
-
|
|
467
|
+
# policy is still decided underneath it and its markers kept current. The policy's own
|
|
468
|
+
# idle test above is separate and stays on the raw resume marker.
|
|
469
|
+
qa_only = _qa_next(tdir) if args.qa_only_next is None else args.qa_only_next == 'yes'
|
|
470
|
+
pin = _pin(tdir) if args.check and not fractal and not qa_only else None
|
|
460
471
|
clear, writes, shown, rc = (MARKERS if after <= 0 else ()), (), '', 0
|
|
461
472
|
if not idle:
|
|
462
473
|
clear, writes, shown, rc = _decide(plan, tdir, args, after)
|
|
@@ -474,6 +485,45 @@ def escalation(args) -> int:
|
|
|
474
485
|
return rc
|
|
475
486
|
|
|
476
487
|
|
|
488
|
+
def _load_control():
|
|
489
|
+
"""forge-control.py, by path: probe and append_line refuse a symlink at any component."""
|
|
490
|
+
import importlib.util
|
|
491
|
+
spec = importlib.util.spec_from_file_location(
|
|
492
|
+
'forge_control', Path(__file__).resolve().with_name('forge-control.py'))
|
|
493
|
+
module = importlib.util.module_from_spec(spec)
|
|
494
|
+
sys.modules['forge_control'] = module
|
|
495
|
+
spec.loader.exec_module(module)
|
|
496
|
+
return module
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def record_escalation(args) -> int:
|
|
500
|
+
"""Probe or append tasks/<id>/escalations.tsv. 0 when that is safe; 1 when it is not.
|
|
501
|
+
|
|
502
|
+
A missing parent is a refusal on both paths. probe() reports that as the file being
|
|
503
|
+
absent, so the check has to look at the walk itself. append_line refuses the same case
|
|
504
|
+
before it opens the leaf: with no directory descriptor that open would use the process
|
|
505
|
+
cwd, including when the task directory disappears between this check and the append.
|
|
506
|
+
"""
|
|
507
|
+
if not args.task or args.task in ('.', '..') or '/' in args.task or '\0' in args.task:
|
|
508
|
+
return 1
|
|
509
|
+
try:
|
|
510
|
+
control = _load_control()
|
|
511
|
+
plan = os.path.realpath(args.plan)
|
|
512
|
+
rel = 'tasks/%s/escalations.tsv' % args.task
|
|
513
|
+
parent, _leaf = control._open_parent(plan, rel)
|
|
514
|
+
if parent is None:
|
|
515
|
+
return 1
|
|
516
|
+
os.close(parent)
|
|
517
|
+
if args.probe:
|
|
518
|
+
control.probe(plan, rel)
|
|
519
|
+
else:
|
|
520
|
+
line = args.line if args.line.endswith('\n') else args.line + '\n'
|
|
521
|
+
control.append_line(plan, rel, line)
|
|
522
|
+
except Exception:
|
|
523
|
+
return 1
|
|
524
|
+
return 0
|
|
525
|
+
|
|
526
|
+
|
|
477
527
|
def main(argv=None) -> int:
|
|
478
528
|
ap = argparse.ArgumentParser(prog='forge-route.py')
|
|
479
529
|
sub = ap.add_subparsers(dest='command', required=True)
|
|
@@ -484,6 +534,14 @@ def main(argv=None) -> int:
|
|
|
484
534
|
p.add_argument('--plan', required=True)
|
|
485
535
|
p.add_argument('--task', required=True)
|
|
486
536
|
p.add_argument('--check', action='store_true')
|
|
537
|
+
p.add_argument('--qa-only-next', choices=('yes', 'no'), default=None,
|
|
538
|
+
help='whether the next entry only re-runs QA, as the caller measured it')
|
|
539
|
+
p = sub.add_parser('record', help=argparse.SUPPRESS)
|
|
540
|
+
p.add_argument('--plan', required=True)
|
|
541
|
+
p.add_argument('--task', required=True)
|
|
542
|
+
mode = p.add_mutually_exclusive_group(required=True)
|
|
543
|
+
mode.add_argument('--probe', action='store_true')
|
|
544
|
+
mode.add_argument('--line')
|
|
487
545
|
args = ap.parse_args(argv)
|
|
488
546
|
if args.command == 'explore':
|
|
489
547
|
try:
|
|
@@ -491,6 +549,8 @@ def main(argv=None) -> int:
|
|
|
491
549
|
except Exception:
|
|
492
550
|
print(args.default)
|
|
493
551
|
return 0
|
|
552
|
+
if args.command == 'record':
|
|
553
|
+
return record_escalation(args)
|
|
494
554
|
try:
|
|
495
555
|
return escalation(args)
|
|
496
556
|
except Exception:
|
package/scripts/forge-runtime.py
CHANGED
|
@@ -175,6 +175,43 @@ def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
|
|
|
175
175
|
return final, usage
|
|
176
176
|
|
|
177
177
|
|
|
178
|
+
def session_id(log: str, harness: str) -> str | None:
|
|
179
|
+
"""The harness session this dispatch ran in, or None.
|
|
180
|
+
|
|
181
|
+
claude and openclaude name it session_id on the stream-json init line and
|
|
182
|
+
again on the result line. The init line wins. The result line is used when
|
|
183
|
+
there is no init line, or that line has no session_id. A session_id that is
|
|
184
|
+
present but not a string is not a session, and the result line does not
|
|
185
|
+
replace it. codex names it thread_id on the first thread.started line.
|
|
186
|
+
Any other harness, a missing id, or an id that is not a plain string is None.
|
|
187
|
+
"""
|
|
188
|
+
if harness not in ('claude', 'openclaude', 'codex'):
|
|
189
|
+
return None
|
|
190
|
+
saw_init = False
|
|
191
|
+
result_id = None
|
|
192
|
+
for line in log.splitlines():
|
|
193
|
+
try:
|
|
194
|
+
event = json.loads(line)
|
|
195
|
+
except ValueError:
|
|
196
|
+
continue
|
|
197
|
+
if not isinstance(event, dict):
|
|
198
|
+
continue
|
|
199
|
+
if harness == 'codex':
|
|
200
|
+
if event.get('type') == 'thread.started':
|
|
201
|
+
value = event.get('thread_id')
|
|
202
|
+
return value if type(value) is str else None
|
|
203
|
+
continue
|
|
204
|
+
kind = event.get('type')
|
|
205
|
+
if not saw_init and kind == 'system' and event.get('subtype') == 'init':
|
|
206
|
+
saw_init = True
|
|
207
|
+
if 'session_id' in event:
|
|
208
|
+
value = event.get('session_id')
|
|
209
|
+
return value if type(value) is str else None
|
|
210
|
+
elif kind == 'result' and 'session_id' in event:
|
|
211
|
+
result_id = event.get('session_id')
|
|
212
|
+
return result_id if type(result_id) is str else None
|
|
213
|
+
|
|
214
|
+
|
|
178
215
|
def finish(attempt: str, root: str, role: str, harness: str, rc: str) -> None:
|
|
179
216
|
try:
|
|
180
217
|
_finish(Path(attempt), Path(root), role, harness, rc)
|
|
@@ -189,6 +226,7 @@ def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None
|
|
|
189
226
|
structured = harness in STRUCTURED
|
|
190
227
|
text = log.read_text(errors='replace') if structured and log.exists() else ''
|
|
191
228
|
final, usage = extract(text, harness)
|
|
229
|
+
found = session_id(text, harness)
|
|
192
230
|
if not last.exists() or not last.stat().st_size:
|
|
193
231
|
if final is not None:
|
|
194
232
|
last.write_text(final + '\n')
|
|
@@ -201,6 +239,7 @@ def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None
|
|
|
201
239
|
data.update(
|
|
202
240
|
harness=harness,
|
|
203
241
|
prompt_bytes=prompt.stat().st_size if prompt_ready else None,
|
|
242
|
+
session_id=found,
|
|
204
243
|
**usage,
|
|
205
244
|
)
|
|
206
245
|
infra = root / (role + '.infra')
|
|
@@ -209,7 +248,14 @@ def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None
|
|
|
209
248
|
for line in infra.read_text(errors='replace').splitlines():
|
|
210
249
|
if line.startswith('class='):
|
|
211
250
|
data['infra_class'] = line[len('class='):].strip() or None
|
|
212
|
-
|
|
251
|
+
# Drop a stale id first. A dispatch that published none must not leave the
|
|
252
|
+
# previous one for a later round to continue.
|
|
253
|
+
session_path = root / (role + '.session')
|
|
254
|
+
session_path.unlink(missing_ok=True)
|
|
255
|
+
if found is not None:
|
|
256
|
+
session_path.write_text(found + '\n')
|
|
257
|
+
for suffix in ('prompt', 'last', 'log', 'resolved', 'cmd', 'timedout', 'infra', 'orphans',
|
|
258
|
+
'session', 'continue'):
|
|
213
259
|
if suffix == 'prompt' and not prompt_ready:
|
|
214
260
|
continue
|
|
215
261
|
source = root / (role + '.' + suffix)
|
package/scripts/forge-ui.py
CHANGED
|
@@ -355,12 +355,16 @@ def cmd_open(args):
|
|
|
355
355
|
# the record and the live answer must both name this code and the mode asked for
|
|
356
356
|
if (server.get('build') == build and answer.get('build') == build and server.get('registry') == registry
|
|
357
357
|
and bool(server.get('read_only')) == read_only and is_read_only(answer) == read_only):
|
|
358
|
-
# Reused,
|
|
359
|
-
#
|
|
358
|
+
# Reused, its CLIs would run with another registry and dataset. A server that takes
|
|
359
|
+
# writes may be in the middle of one, which a restart could cut off: say so instead,
|
|
360
|
+
# and how to restart it. A read-only one has none to cut off: replace it.
|
|
360
361
|
change = config_change(server.get('cli_env'), cli_env)
|
|
361
|
-
if change:
|
|
362
|
+
if change and read_only:
|
|
363
|
+
terminate(server['pid'])
|
|
364
|
+
server = None
|
|
365
|
+
elif change:
|
|
362
366
|
_say(f'error: the running forge-ui (pid {server["pid"]}) has another forge configuration '
|
|
363
|
-
f'for its
|
|
367
|
+
f'for its CLIs: {change}. Run `forge ui stop`, then open again.')
|
|
364
368
|
return 5
|
|
365
369
|
else:
|
|
366
370
|
terminate(server['pid'])
|
|
@@ -94,8 +94,13 @@ def data_home() -> Path:
|
|
|
94
94
|
explicit = os.environ.get('FORGE_DATA_HOME')
|
|
95
95
|
if explicit:
|
|
96
96
|
return Path(explicit).expanduser()
|
|
97
|
-
|
|
98
|
-
|
|
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'
|
|
99
104
|
|
|
100
105
|
|
|
101
106
|
def dataset_dir() -> Path:
|
|
@@ -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
|
|