@giamme/forge 0.8.0 → 0.10.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 +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -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 +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -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 +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -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 +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""forge runs [--repo DIR | --all] [--limit N] [--json]: this repo's recent runs, in the terminal.
|
|
3
|
+
|
|
4
|
+
Every solo run, `plan` and `run` registers its run dir (forge_registry.py); this lists them newest
|
|
5
|
+
first, one line each (run id, kind, age, task states, verification and final review, goal) with the
|
|
6
|
+
run dir under it, so you can see what happened without a browser and get the dir to resume, retry
|
|
7
|
+
or open with `forge ui open`. The repo is the Git top level of the current directory unless
|
|
8
|
+
`--repo` names one; `--all` lists the runs of every repo. It reads the same run summaries as
|
|
9
|
+
forge-ui and never writes anything.
|
|
10
|
+
|
|
11
|
+
`forge runs rm <run-dir> [--dry-run] [--yes] [--no-archive] [--worktrees] [--branches] [--json]`
|
|
12
|
+
is the one thing here that changes anything: it deletes a run, after its traces are in the dataset
|
|
13
|
+
and an archive exists (scripts/forge-runs-rm.py).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
26
|
+
sys.path.insert(0, str(SKILL / 'scripts'))
|
|
27
|
+
sys.path.insert(0, str(SKILL / 'ui'))
|
|
28
|
+
|
|
29
|
+
import forge_registry # noqa: E402
|
|
30
|
+
from forge_ui import reader # noqa: E402
|
|
31
|
+
|
|
32
|
+
FIELDS = ('key', 'run_id', 'kind', 'goal', 'repo', 'path', 'created', 'updated', 'task_count', 'counts',
|
|
33
|
+
'verification_status', 'final_review')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def repo_of(start: Path) -> Path:
|
|
37
|
+
"""The Git top level of `start`, else `start` itself: the key runs are registered under."""
|
|
38
|
+
try:
|
|
39
|
+
out = subprocess.run(['git', '-C', str(start), 'rev-parse', '--show-toplevel'], capture_output=True,
|
|
40
|
+
text=True, stdin=subprocess.DEVNULL, timeout=10)
|
|
41
|
+
if out.returncode == 0 and out.stdout.strip():
|
|
42
|
+
return Path(out.stdout.strip()).resolve()
|
|
43
|
+
except (OSError, subprocess.SubprocessError):
|
|
44
|
+
pass
|
|
45
|
+
return start.resolve()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def registered(path: Path) -> list[str]:
|
|
49
|
+
"""The run dirs one registry file lists (absolute paths, comments skipped)."""
|
|
50
|
+
try:
|
|
51
|
+
lines = path.read_text(encoding='utf-8', errors='replace').splitlines()
|
|
52
|
+
except OSError:
|
|
53
|
+
return []
|
|
54
|
+
return [line.strip() for line in lines if line.strip() and not line.startswith('#') and os.path.isabs(line.strip())]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def runs(repo: Path | None) -> list[dict]:
|
|
58
|
+
if repo is None:
|
|
59
|
+
found = reader.discover(registry=True, registry_dir=forge_registry.registry_dir())
|
|
60
|
+
else:
|
|
61
|
+
found = reader.discover(plans=registered(forge_registry.runs_registry(repo)), registry=False)
|
|
62
|
+
result = []
|
|
63
|
+
for path, source in found:
|
|
64
|
+
try:
|
|
65
|
+
summary = reader.summarize(path, source)
|
|
66
|
+
except Exception: # one unreadable run dir must not hide the others
|
|
67
|
+
continue
|
|
68
|
+
result.append({k: summary.get(k) for k in FIELDS})
|
|
69
|
+
return sorted(result, key=lambda r: r['updated'] if r['updated'] is not None else float('-inf'), reverse=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def age(epoch, now) -> str:
|
|
73
|
+
if epoch is None:
|
|
74
|
+
return '?'
|
|
75
|
+
s = max(0, now - epoch)
|
|
76
|
+
for unit, size in (('d', 86400), ('h', 3600), ('m', 60)):
|
|
77
|
+
if s >= size:
|
|
78
|
+
return f'{int(s // size)}{unit} ago'
|
|
79
|
+
return 'just now'
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def line(run: dict, now: float, with_repo: bool = False) -> str:
|
|
83
|
+
counts = ' · '.join(f'{status.lower()} {n}' for status, n in (run['counts'] or {}).items()) or 'no tasks'
|
|
84
|
+
checks = ''.join(f' {label} {run[key]}' for label, key in (('verify', 'verification_status'), ('review', 'final_review'))
|
|
85
|
+
if run.get(key))
|
|
86
|
+
goal = str(run['goal'] or 'Untitled run').splitlines()[0]
|
|
87
|
+
if len(goal) > 72:
|
|
88
|
+
goal = goal[:71] + '…'
|
|
89
|
+
repo = f" {Path(run['repo']).name or run['repo']}" if with_repo and run.get('repo') else ''
|
|
90
|
+
return f"{run['run_id'] or run['key']} {run['kind']}{repo} {age(run['updated'], now)} {counts}{checks} {goal}\n {run['path']}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove(argv) -> int:
|
|
94
|
+
"""`forge runs rm`: loaded on demand, so listing never pays for the dataset package."""
|
|
95
|
+
import importlib.util
|
|
96
|
+
spec = importlib.util.spec_from_file_location('forge_runs_rm', Path(__file__).resolve().with_name('forge-runs-rm.py'))
|
|
97
|
+
module = importlib.util.module_from_spec(spec)
|
|
98
|
+
sys.modules['forge_runs_rm'] = module
|
|
99
|
+
spec.loader.exec_module(module)
|
|
100
|
+
return module.main(argv)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv=None) -> int:
|
|
104
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
105
|
+
if argv[:1] == ['rm']:
|
|
106
|
+
return remove(argv[1:])
|
|
107
|
+
parser = argparse.ArgumentParser(prog='forge runs', description="This repo's recent forge runs, newest first. Listing is read-only.",
|
|
108
|
+
epilog='Delete a run, keeping its traces: forge runs rm <run-dir> (forge runs rm --help)')
|
|
109
|
+
which = parser.add_mutually_exclusive_group()
|
|
110
|
+
which.add_argument('--repo', type=Path, metavar='DIR', help='the repo whose runs to list (default: the Git top level of the current directory)')
|
|
111
|
+
which.add_argument('--all', action='store_true', help='list the runs of every repo')
|
|
112
|
+
parser.add_argument('--limit', type=int, default=10, metavar='N', help='at most N runs (default 10; 0 lists them all)')
|
|
113
|
+
parser.add_argument('--json', action='store_true', help='print the runs as a JSON array, for scripts and agents')
|
|
114
|
+
args = parser.parse_args(argv)
|
|
115
|
+
if args.limit < 0:
|
|
116
|
+
parser.error('--limit must be 0 or more')
|
|
117
|
+
if args.repo is not None and not args.repo.is_dir():
|
|
118
|
+
parser.error(f'--repo: no such directory: {args.repo}')
|
|
119
|
+
repo = None if args.all else repo_of(args.repo if args.repo is not None else Path.cwd())
|
|
120
|
+
found = runs(repo)
|
|
121
|
+
shown = found[:args.limit] if args.limit else found
|
|
122
|
+
if args.json:
|
|
123
|
+
json.dump(shown, sys.stdout, indent=2)
|
|
124
|
+
sys.stdout.write('\n')
|
|
125
|
+
return 0
|
|
126
|
+
where = 'every repo' if repo is None else str(repo)
|
|
127
|
+
if not found:
|
|
128
|
+
print(f'forge runs: no runs registered for {where}')
|
|
129
|
+
print('Runs register when they start (/forge "<goal>" --dwarf <model>); FORGE_RUN_REGISTRY=off turns that off.')
|
|
130
|
+
return 0
|
|
131
|
+
print(f'forge runs for {where}: {len(shown)} of {len(found)}, newest first')
|
|
132
|
+
now = time.time()
|
|
133
|
+
for run in shown:
|
|
134
|
+
print(line(run, now, with_repo=repo is None))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == '__main__':
|
|
139
|
+
sys.exit(main())
|
package/scripts/forge-runtime.py
CHANGED
|
@@ -101,9 +101,45 @@ def token_count(value: object) -> int | None:
|
|
|
101
101
|
return value if type(value) is int and value >= 0 else None
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
# Harnesses whose log is a stream of JSON events rather than the answer itself.
|
|
105
|
+
STRUCTURED = ('codex', 'claude', 'openclaude', 'grok', 'opencode')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _opencode_final(events: list) -> str:
|
|
109
|
+
"""opencode's answer: the text of its last step, after its last tool call."""
|
|
110
|
+
final, step = '', []
|
|
111
|
+
for event in events:
|
|
112
|
+
kind = event.get('type')
|
|
113
|
+
if kind in ('step_start', 'tool_use'):
|
|
114
|
+
step = []
|
|
115
|
+
elif kind == 'text':
|
|
116
|
+
part = event.get('part')
|
|
117
|
+
text = part.get('text') if isinstance(part, dict) else None
|
|
118
|
+
if isinstance(text, str) and text.strip():
|
|
119
|
+
step.append(text)
|
|
120
|
+
final = '\n'.join(step)
|
|
121
|
+
return final
|
|
122
|
+
|
|
123
|
+
|
|
104
124
|
def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
|
|
105
125
|
usage = dict(input_tokens=None, cached_input_tokens=None, output_tokens=None)
|
|
106
126
|
final = None
|
|
127
|
+
if harness == 'opencode':
|
|
128
|
+
events = _events(log)[0]
|
|
129
|
+
for event in events:
|
|
130
|
+
part = event.get('part')
|
|
131
|
+
tokens = part.get('tokens') if event.get('type') == 'step_finish' and isinstance(part, dict) else None
|
|
132
|
+
if not isinstance(tokens, dict):
|
|
133
|
+
continue
|
|
134
|
+
cache = tokens.get('cache') if isinstance(tokens.get('cache'), dict) else {}
|
|
135
|
+
# input counts what was read from and written to the cache, as claude's does
|
|
136
|
+
for key, parts in (('input_tokens', (tokens.get('input'), cache.get('read'), cache.get('write'))),
|
|
137
|
+
('cached_input_tokens', (cache.get('read'),)),
|
|
138
|
+
('output_tokens', (tokens.get('output'), tokens.get('reasoning')))):
|
|
139
|
+
counts = [token_count(v) for v in parts if v is not None]
|
|
140
|
+
if counts and all(v is not None for v in counts):
|
|
141
|
+
usage[key] = (usage[key] or 0) + sum(counts)
|
|
142
|
+
return _opencode_final(events) or None, usage
|
|
107
143
|
for line in log.splitlines():
|
|
108
144
|
try:
|
|
109
145
|
event = json.loads(line)
|
|
@@ -124,8 +160,9 @@ def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
|
|
|
124
160
|
if isinstance(item, dict) and item.get('type') == 'agent_message':
|
|
125
161
|
if isinstance(item.get('text'), str):
|
|
126
162
|
final = item['text']
|
|
127
|
-
# grok's streaming-messages-json
|
|
128
|
-
|
|
163
|
+
# claude's and openclaude's stream-json and grok's streaming-messages-json end in the same
|
|
164
|
+
# `result` event, token buckets included.
|
|
165
|
+
if harness in ('claude', 'openclaude', 'grok') and event.get('type') == 'result':
|
|
129
166
|
if isinstance(event.get('result'), str):
|
|
130
167
|
final = event['result']
|
|
131
168
|
# Claude reports uncached, cache-read and cache-creation separately.
|
|
@@ -149,7 +186,7 @@ def finish(attempt: str, root: str, role: str, harness: str, rc: str) -> None:
|
|
|
149
186
|
def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None:
|
|
150
187
|
log = root / (role + '.log')
|
|
151
188
|
last = root / (role + '.last')
|
|
152
|
-
structured = harness in
|
|
189
|
+
structured = harness in STRUCTURED
|
|
153
190
|
text = log.read_text(errors='replace') if structured and log.exists() else ''
|
|
154
191
|
final, usage = extract(text, harness)
|
|
155
192
|
if not last.exists() or not last.stat().st_size:
|
|
@@ -439,7 +476,7 @@ def classify(log: str, harness: str, rc: object, last: str = '', role: str = '',
|
|
|
439
476
|
text = Path(log).read_text(errors='replace')
|
|
440
477
|
except OSError:
|
|
441
478
|
text = ''
|
|
442
|
-
events, stray = _events(text) if harness in
|
|
479
|
+
events, stray = _events(text) if harness in STRUCTURED else ([], [])
|
|
443
480
|
|
|
444
481
|
if harness == 'codex':
|
|
445
482
|
final = _codex_final(events, last)
|
|
@@ -454,6 +491,22 @@ def classify(log: str, harness: str, rc: object, last: str = '', role: str = '',
|
|
|
454
491
|
return 'empty', None, 'codex finished with exit 0 but produced no final message'
|
|
455
492
|
return classify_text('\n'.join(pending + [_tail(stray)]), now=now)
|
|
456
493
|
|
|
494
|
+
if harness == 'opencode' and events:
|
|
495
|
+
# Like codex: an error event counts when the run produced no answer after it.
|
|
496
|
+
errors = []
|
|
497
|
+
for event in events:
|
|
498
|
+
error = event.get('error') if event.get('type') == 'error' else None
|
|
499
|
+
if isinstance(error, dict):
|
|
500
|
+
data = error.get('data') if isinstance(error.get('data'), dict) else {}
|
|
501
|
+
errors.append(str(data.get('message') or error.get('name') or json.dumps(error)))
|
|
502
|
+
final = _opencode_final(events)
|
|
503
|
+
if rc == 0:
|
|
504
|
+
if final.strip():
|
|
505
|
+
return None
|
|
506
|
+
hit = classify_text('\n'.join(errors), now=now) if errors else None
|
|
507
|
+
return hit or ('empty', None, 'opencode finished with exit 0 but produced no final message')
|
|
508
|
+
return classify_text('\n'.join(errors + [_tail(stray)]), now=now)
|
|
509
|
+
|
|
457
510
|
results = [e for e in events if e.get('type') == 'result'] if harness in ('claude', 'openclaude', 'grok') else []
|
|
458
511
|
if results:
|
|
459
512
|
event = results[-1]
|
|
@@ -478,8 +531,8 @@ def classify(log: str, harness: str, rc: object, last: str = '', role: str = '',
|
|
|
478
531
|
return 'empty', None, '%s finished with exit 0 but its final result is empty' % harness
|
|
479
532
|
return None
|
|
480
533
|
|
|
481
|
-
# Raw-log harness (
|
|
482
|
-
# died before emitting its result): the log is the message, so only the tail of a
|
|
534
|
+
# Raw-log harness (antigravity, an opencode or openclaude from before their streams, or a
|
|
535
|
+
# claude that died before emitting its result): the log is the message, so only the tail of a
|
|
483
536
|
# failing run is evidence. A successful run's prose is never scanned.
|
|
484
537
|
if rc != 0:
|
|
485
538
|
return classify_text(_tail(text), now=now)
|
|
@@ -10,17 +10,26 @@ Two opt-in behaviours live here, both off by default so a plain `run` is unchang
|
|
|
10
10
|
network, empty output) up to M times instead of stopping. Without it the scheduler
|
|
11
11
|
stops dispatching, lets running tasks drain and exits 8: nothing was the task's fault
|
|
12
12
|
and no attempt was spent, so the next `run` simply resumes.
|
|
13
|
+
|
|
14
|
+
Both numbers are per invocation, but while the scheduler runs they are mirrored into the
|
|
15
|
+
plan dir (retry_budget, infra_budget, tasks/<id>/retry_budget) and re-read at every
|
|
16
|
+
decision, so `forge control` can change them mid-run. See "live control files" below.
|
|
13
17
|
"""
|
|
14
18
|
from __future__ import annotations
|
|
15
19
|
|
|
16
20
|
import argparse
|
|
21
|
+
import contextlib
|
|
22
|
+
import errno
|
|
17
23
|
import fcntl
|
|
24
|
+
import json
|
|
18
25
|
import os
|
|
19
26
|
from pathlib import Path
|
|
20
27
|
import posixpath
|
|
21
28
|
import signal
|
|
29
|
+
import stat
|
|
22
30
|
import subprocess
|
|
23
31
|
import sys
|
|
32
|
+
import tempfile
|
|
24
33
|
import time
|
|
25
34
|
|
|
26
35
|
|
|
@@ -80,6 +89,157 @@ def infra_wait(info: dict, cycle: int) -> float:
|
|
|
80
89
|
return min(base * (2 ** cycle), max(base, 1800.0))
|
|
81
90
|
|
|
82
91
|
|
|
92
|
+
# --- live control files ------------------------------------------------------------------
|
|
93
|
+
# Created at start and removed at exit, so a later `run` never inherits a budget it did not
|
|
94
|
+
# ask for. Writers replace files atomically, so readers need no lock; the control lock only
|
|
95
|
+
# orders a lifecycle write against `forge control`'s liveness check. It is never held across
|
|
96
|
+
# a model dispatch and never taken together with schedule.lock.
|
|
97
|
+
CONTROL_LOCK_WAIT = 5.0 # seconds; a busy lock must never fail a run
|
|
98
|
+
SWEEP_EVERY = 1.0 # seconds between looks at tasks that already ended FAIL/UNKNOWN
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _warn(message: str) -> None:
|
|
102
|
+
print('forge: ' + message, file=sys.stderr, flush=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _task_id_ok(name: str) -> bool:
|
|
106
|
+
return bool(name) and name not in ('.', '..') and '/' not in name and '\0' not in name
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve(plan: object, *parts: str) -> str:
|
|
110
|
+
"""plan/<parts> if every directory on the way is a real one (no symlink) inside the
|
|
111
|
+
canonical plan dir and an existing target is a regular file. OSError otherwise."""
|
|
112
|
+
if not all(_task_id_ok(part) for part in parts):
|
|
113
|
+
raise OSError(errno.EINVAL, 'invalid path component', '/'.join(parts))
|
|
114
|
+
path = os.path.realpath(plan)
|
|
115
|
+
for part in parts[:-1]:
|
|
116
|
+
path = os.path.join(path, part)
|
|
117
|
+
if not stat.S_ISDIR(os.lstat(path).st_mode):
|
|
118
|
+
raise OSError(errno.ENOTDIR, 'not a real directory', path)
|
|
119
|
+
path = os.path.join(path, parts[-1])
|
|
120
|
+
try:
|
|
121
|
+
if not stat.S_ISREG(os.lstat(path).st_mode):
|
|
122
|
+
raise OSError(errno.EPERM, 'not a regular file', path)
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
pass
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _write_atomic(plan: object, text: str, *parts: str) -> bool:
|
|
129
|
+
"""Temp file in the same directory, then rename. A failure warns and returns False."""
|
|
130
|
+
tmp = None
|
|
131
|
+
try:
|
|
132
|
+
path = _resolve(plan, *parts)
|
|
133
|
+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix='.' + parts[-1] + '.')
|
|
134
|
+
with os.fdopen(fd, 'w') as handle:
|
|
135
|
+
os.fchmod(handle.fileno(), 0o644)
|
|
136
|
+
handle.write(text)
|
|
137
|
+
os.replace(tmp, path)
|
|
138
|
+
return True
|
|
139
|
+
except OSError as error:
|
|
140
|
+
_warn('could not write ' + '/'.join(parts) + ': ' + str(error))
|
|
141
|
+
if tmp:
|
|
142
|
+
with contextlib.suppress(OSError):
|
|
143
|
+
os.unlink(tmp)
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _remove(plan: object, *parts: str) -> None:
|
|
148
|
+
try:
|
|
149
|
+
os.unlink(_resolve(plan, *parts))
|
|
150
|
+
except FileNotFoundError:
|
|
151
|
+
pass
|
|
152
|
+
except OSError as error:
|
|
153
|
+
_warn('could not remove ' + '/'.join(parts) + ': ' + str(error))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_budget(plan: object, *parts: str) -> int | None:
|
|
157
|
+
"""A non-negative integer from a control file; None when it is missing, empty, a
|
|
158
|
+
symlink or anything else, so the caller falls through to its next source."""
|
|
159
|
+
try:
|
|
160
|
+
fd = os.open(_resolve(plan, *parts), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
161
|
+
try:
|
|
162
|
+
data = os.read(fd, 65)
|
|
163
|
+
finally:
|
|
164
|
+
os.close(fd)
|
|
165
|
+
text = data.decode('ascii').strip()
|
|
166
|
+
except (OSError, UnicodeDecodeError):
|
|
167
|
+
return None
|
|
168
|
+
return int(text) if len(data) <= 64 and text.isdigit() else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@contextlib.contextmanager
|
|
172
|
+
def _control_lock(plan: object):
|
|
173
|
+
"""flock on control.lock, retried for CONTROL_LOCK_WAIT seconds. On timeout or any
|
|
174
|
+
error: warn and proceed without it (yielding False), never fail the run."""
|
|
175
|
+
fd = None
|
|
176
|
+
try:
|
|
177
|
+
fd = os.open(_resolve(plan, 'control.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o644)
|
|
178
|
+
deadline = time.monotonic() + CONTROL_LOCK_WAIT
|
|
179
|
+
while True:
|
|
180
|
+
try:
|
|
181
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
182
|
+
break
|
|
183
|
+
except BlockingIOError:
|
|
184
|
+
if time.monotonic() >= deadline:
|
|
185
|
+
raise OSError(errno.EWOULDBLOCK, 'busy for ' + ('%g' % CONTROL_LOCK_WAIT) + 's')
|
|
186
|
+
time.sleep(0.05)
|
|
187
|
+
except OSError as error:
|
|
188
|
+
_warn('control.lock not taken, continuing without it: ' + str(error))
|
|
189
|
+
if fd is not None:
|
|
190
|
+
os.close(fd)
|
|
191
|
+
fd = None
|
|
192
|
+
try:
|
|
193
|
+
yield fd is not None
|
|
194
|
+
finally:
|
|
195
|
+
if fd is not None:
|
|
196
|
+
os.close(fd)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _task_dirs(plan: object, names: object) -> list[str]:
|
|
200
|
+
"""The planned tasks plus any other task dir, since `forge control` accepts an id
|
|
201
|
+
that is not in tasks.tsv and its file must not outlive the invocation either."""
|
|
202
|
+
found = set(names)
|
|
203
|
+
try:
|
|
204
|
+
with os.scandir(os.path.join(os.path.realpath(plan), 'tasks')) as entries:
|
|
205
|
+
found.update(e.name for e in entries if _task_id_ok(e.name) and e.is_dir(follow_symlinks=False))
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
return sorted(found)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _pinned(plan: object, name: str) -> bool:
|
|
212
|
+
try:
|
|
213
|
+
return os.path.lexists(_resolve(plan, 'tasks', name, 'escalation.pin'))
|
|
214
|
+
except OSError:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def live_start(plan: object, names: object, retry_failed: int, infra_retries: int) -> None:
|
|
219
|
+
"""Publish the flags and that this scheduler is live. Stale per-task overrides and
|
|
220
|
+
usage counters from an earlier invocation go first."""
|
|
221
|
+
with _control_lock(plan):
|
|
222
|
+
for name in _task_dirs(plan, names):
|
|
223
|
+
_remove(plan, 'tasks', name, 'retry_budget')
|
|
224
|
+
_remove(plan, 'tasks', name, 'retries.used')
|
|
225
|
+
_write_atomic(plan, str(retry_failed), 'retry_budget')
|
|
226
|
+
_write_atomic(plan, str(infra_retries), 'infra_budget')
|
|
227
|
+
_write_atomic(plan, json.dumps({'pid': os.getpid(), 'started': time.time()}), 'scheduler.live')
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def live_stop(plan: object, names: object) -> None:
|
|
231
|
+
"""Withdraw liveness first, then every budget. retries.used stays for the UI."""
|
|
232
|
+
try:
|
|
233
|
+
with _control_lock(plan):
|
|
234
|
+
_remove(plan, 'scheduler.live')
|
|
235
|
+
_remove(plan, 'retry_budget')
|
|
236
|
+
_remove(plan, 'infra_budget')
|
|
237
|
+
for name in _task_dirs(plan, names):
|
|
238
|
+
_remove(plan, 'tasks', name, 'retry_budget')
|
|
239
|
+
except Exception as error:
|
|
240
|
+
_warn('could not withdraw the live control files: ' + str(error))
|
|
241
|
+
|
|
242
|
+
|
|
83
243
|
def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
84
244
|
infra_retries: int = 0) -> int:
|
|
85
245
|
if capacity <= 0:
|
|
@@ -97,16 +257,18 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
|
97
257
|
if len(tasks) != len(rows):
|
|
98
258
|
raise ValueError('duplicate task IDs')
|
|
99
259
|
for name in tasks:
|
|
100
|
-
if not name
|
|
260
|
+
if not _task_id_ok(name):
|
|
101
261
|
raise ValueError('invalid task ID: ' + name)
|
|
102
262
|
(plan / 'tasks' / name).mkdir(parents=True, exist_ok=True)
|
|
103
263
|
active = {}
|
|
104
264
|
pending = list(tasks)
|
|
105
265
|
interrupted = False
|
|
106
266
|
retries = {} # automatic reviewer-driven retries used per task, this run
|
|
267
|
+
refused = {} # task -> budget at the time its retry could not be prepared
|
|
107
268
|
retried = set() # tasks whose next dispatch is a retry (FORGE_FRACTAL_RETRY=1)
|
|
108
269
|
offsets = {} # how much of each task.out was already echoed under --output full
|
|
109
270
|
infra_seen = [] # tasks stopped by an infrastructure failure, awaiting resume
|
|
271
|
+
held = set() # INFRA tasks not resumed: their pin could not be applied
|
|
110
272
|
infra_cycles = 0 # how many times this run already waited out an infrastructure pause
|
|
111
273
|
paused = False # an infrastructure failure was seen: dispatch nothing new
|
|
112
274
|
resume_at = 0.0
|
|
@@ -127,30 +289,61 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
|
127
289
|
if result.returncode and status(name) == 'PASS':
|
|
128
290
|
mark(name, 'ERROR')
|
|
129
291
|
|
|
292
|
+
def budget(name: str) -> int:
|
|
293
|
+
"""Automatic retries this task may use. Read at every decision, never cached: the
|
|
294
|
+
task's own file, then the run-wide file, then the flag."""
|
|
295
|
+
for parts in (('tasks', name, 'retry_budget'), ('retry_budget',)):
|
|
296
|
+
value = _read_budget(plan, *parts)
|
|
297
|
+
if value is not None:
|
|
298
|
+
return value
|
|
299
|
+
return retry_failed
|
|
300
|
+
|
|
301
|
+
def infra_budget() -> int:
|
|
302
|
+
value = _read_budget(plan, 'infra_budget')
|
|
303
|
+
return infra_retries if value is None else value
|
|
304
|
+
|
|
130
305
|
def retryable(name: str) -> bool:
|
|
306
|
+
root = plan / 'tasks' / name
|
|
131
307
|
return (
|
|
132
|
-
|
|
133
|
-
and retries.get(name, 0) <
|
|
134
|
-
and status(name) in ('FAIL', 'UNKNOWN')
|
|
308
|
+
status(name) in ('FAIL', 'UNKNOWN')
|
|
309
|
+
and retries.get(name, 0) < budget(name)
|
|
135
310
|
# A task that produced no new work (empty diff, byte-identical retry) would
|
|
136
311
|
# only repeat itself; its marker says so and it is left for a human.
|
|
137
|
-
and not (
|
|
312
|
+
and not (root / 'noretry').exists()
|
|
138
313
|
# The retry policy found no stronger model after repeated failures on one: a
|
|
139
|
-
# split, or an explicit `retry --dwarf`, is the human's call.
|
|
140
|
-
|
|
314
|
+
# split, or an explicit `retry --dwarf`, is the human's call. A pin is exactly
|
|
315
|
+
# that call, made ahead of time: the next full retry applies it.
|
|
316
|
+
and (not (root / 'escalation.stop').exists() or _pinned(plan, name))
|
|
141
317
|
)
|
|
142
318
|
|
|
143
319
|
def requeue(name: str) -> bool:
|
|
144
320
|
"""Prepare a retry exactly as `retry` does, then put the task at the queue head."""
|
|
145
321
|
result = subprocess.run(['/bin/bash', script, '_prepare_retry', str(plan), name])
|
|
146
322
|
if result.returncode:
|
|
323
|
+
refused[name] = budget(name)
|
|
147
324
|
return False
|
|
325
|
+
refused.pop(name, None)
|
|
148
326
|
retries[name] = retries.get(name, 0) + 1
|
|
327
|
+
_write_atomic(plan, str(retries[name]), 'tasks', name, 'retries.used')
|
|
149
328
|
retried.add(name)
|
|
150
329
|
if name not in pending:
|
|
151
330
|
pending.insert(0, name)
|
|
152
331
|
print('forge: retrying ' + name + ' (automatic retry ' + str(retries[name]) + '/'
|
|
153
|
-
+ str(
|
|
332
|
+
+ str(budget(name)) + ')', file=sys.stderr, flush=True)
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
def resume(name: str) -> bool:
|
|
336
|
+
"""Prepare an INFRA task's next entry. Not a retry, so nothing is counted, but a
|
|
337
|
+
pending pin applies when that entry is a full implementer attempt. False when a
|
|
338
|
+
claimed pin could not be put in place: the task then stays INFRA, as `retry` would."""
|
|
339
|
+
if not _pinned(plan, name):
|
|
340
|
+
return True
|
|
341
|
+
result = subprocess.run(['/bin/bash', script, '_prepare_retry', str(plan), name, '--resume'])
|
|
342
|
+
if result.returncode:
|
|
343
|
+
held.add(name)
|
|
344
|
+
print('forge: ' + name + ' is not resumed: its escalation pin could not be applied',
|
|
345
|
+
file=sys.stderr, flush=True)
|
|
346
|
+
return False
|
|
154
347
|
return True
|
|
155
348
|
|
|
156
349
|
def echo_output(name: str) -> None:
|
|
@@ -171,6 +364,7 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
|
171
364
|
signal.signal(signal.SIGTERM, stop)
|
|
172
365
|
signal.signal(signal.SIGINT, stop)
|
|
173
366
|
try:
|
|
367
|
+
live_start(plan, tasks, retry_failed, infra_retries)
|
|
174
368
|
# Failures require an explicit retry unless this run asked for automatic ones.
|
|
175
369
|
# Saved PASS results use the same fingerprint checks without paying for
|
|
176
370
|
# implementation or QA again; an INFRA stop is not a failure of the work, so it
|
|
@@ -183,13 +377,19 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
|
183
377
|
mark(name, 'INTERRUPTED')
|
|
184
378
|
if state in ('FAIL', 'UNKNOWN') and retryable(name):
|
|
185
379
|
requeue(name)
|
|
380
|
+
if state == 'INFRA' and not resume(name):
|
|
381
|
+
pending.remove(name)
|
|
382
|
+
continue
|
|
186
383
|
if status(name) not in ('PENDING', 'BLOCKED', 'INFRA'):
|
|
187
384
|
pending.remove(name)
|
|
385
|
+
swept = time.monotonic()
|
|
188
386
|
while pending or active:
|
|
189
387
|
if interrupted:
|
|
190
388
|
break
|
|
389
|
+
ended = set()
|
|
191
390
|
for name, (process, log) in list(active.items()):
|
|
192
391
|
if process.poll() is not None:
|
|
392
|
+
ended.add(name)
|
|
193
393
|
log.close()
|
|
194
394
|
del active[name]
|
|
195
395
|
if os.environ.get('FORGE_OUTPUT') == 'full':
|
|
@@ -205,7 +405,18 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
|
205
405
|
infra_seen.append(name)
|
|
206
406
|
elif state in ('FAIL', 'UNKNOWN') and retryable(name):
|
|
207
407
|
requeue(name)
|
|
208
|
-
if
|
|
408
|
+
if time.monotonic() - swept >= SWEEP_EVERY:
|
|
409
|
+
# A human can raise a budget after a task already ended FAIL while others
|
|
410
|
+
# still run. The initial pass's predicate, for tasks nothing else will look
|
|
411
|
+
# at again; one whose retry could not be prepared waits for a new budget.
|
|
412
|
+
swept = time.monotonic()
|
|
413
|
+
for name in tasks:
|
|
414
|
+
if name in pending or name in active or name in ended:
|
|
415
|
+
continue
|
|
416
|
+
if status(name) in ('FAIL', 'UNKNOWN') and retryable(name) \
|
|
417
|
+
and refused.get(name) != budget(name):
|
|
418
|
+
requeue(name)
|
|
419
|
+
if paused and not active and infra_seen and infra_cycles < infra_budget():
|
|
209
420
|
# Everything running has drained. Wait out the outage once for all the
|
|
210
421
|
# tasks it stopped, then dispatch them again.
|
|
211
422
|
wait = max(infra_wait(infra_info(plan / 'tasks' / n), infra_cycles) for n in infra_seen)
|
|
@@ -218,8 +429,10 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
|
218
429
|
time.sleep(min(0.05, max(resume_at - time.time(), 0)))
|
|
219
430
|
if interrupted:
|
|
220
431
|
break
|
|
432
|
+
# A pin set before or during the wait applies to the entries that run the
|
|
433
|
+
# implementer again; a QA-stage stop keeps it for the next full retry.
|
|
221
434
|
for name in reversed(infra_seen):
|
|
222
|
-
if name not in pending:
|
|
435
|
+
if name not in pending and resume(name):
|
|
223
436
|
pending.insert(0, name)
|
|
224
437
|
infra_seen.clear()
|
|
225
438
|
paused = False
|
|
@@ -249,15 +462,18 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
|
|
|
249
462
|
pending.remove(name)
|
|
250
463
|
print('forge: dispatched ' + name + ' -> ' + str(root), file=sys.stderr, flush=True)
|
|
251
464
|
if not active:
|
|
252
|
-
if paused:
|
|
465
|
+
if paused or held:
|
|
253
466
|
# Infrastructure stop: leave everything not yet run PENDING. Marking it
|
|
254
467
|
# BLOCKED would turn "the provider was down" into a recorded failure.
|
|
468
|
+
# A task held back by its pin is still an infrastructure stop.
|
|
255
469
|
break
|
|
256
470
|
for name in pending:
|
|
257
471
|
mark(name, 'BLOCKED')
|
|
258
472
|
break
|
|
259
473
|
time.sleep(0.05)
|
|
260
474
|
finally:
|
|
475
|
+
# Not live from here on: a late `forge control` must not edit budgets nobody reads.
|
|
476
|
+
live_stop(plan, tasks)
|
|
261
477
|
for process, log in active.values():
|
|
262
478
|
try:
|
|
263
479
|
os.killpg(process.pid, signal.SIGTERM)
|