@giamme/forge 0.0.0-stage → 0.8.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 +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offline runtime support; no provider requests or third-party dependencies."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_json(path: Path, value: dict) -> None:
|
|
19
|
+
temporary = path.with_name(path.name + '.' + uuid.uuid4().hex)
|
|
20
|
+
temporary.write_text(json.dumps(value, indent=2) + '\n')
|
|
21
|
+
temporary.replace(path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _dataset(hook: str, *args) -> None:
|
|
25
|
+
"""Hand an execution to the run dataset (scripts/forge_dataset/).
|
|
26
|
+
|
|
27
|
+
Fail-open by contract: a capture problem lands in the dataset's errors.log and never
|
|
28
|
+
changes what this command prints, returns or exits with. begin()'s stdout is the
|
|
29
|
+
attempt path the shell reads, so nothing here may print.
|
|
30
|
+
"""
|
|
31
|
+
if os.environ.get('FORGE_DATASET', 'on').strip().lower() in ('off', '0', 'no', 'false'):
|
|
32
|
+
return
|
|
33
|
+
try:
|
|
34
|
+
scripts = str(Path(__file__).resolve().parent)
|
|
35
|
+
if scripts not in sys.path:
|
|
36
|
+
sys.path.insert(0, scripts)
|
|
37
|
+
from forge_dataset import capture
|
|
38
|
+
getattr(capture, hook)(*args)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def capability_help(cache: str, registry: str, recipe: str, command: list[str]) -> str:
|
|
44
|
+
binary = Path(command[0]).resolve()
|
|
45
|
+
stat = binary.stat()
|
|
46
|
+
identity = [
|
|
47
|
+
str(binary), stat.st_dev, stat.st_ino, stat.st_size,
|
|
48
|
+
stat.st_mtime_ns, stat.st_ctime_ns, command[1:],
|
|
49
|
+
hashlib.sha256(Path(registry).read_bytes()).hexdigest(),
|
|
50
|
+
hashlib.sha256(Path(recipe).read_bytes()).hexdigest(),
|
|
51
|
+
]
|
|
52
|
+
key = hashlib.sha256(json.dumps(identity).encode()).hexdigest()
|
|
53
|
+
root = Path(cache)
|
|
54
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
path = root / key
|
|
56
|
+
if path.exists():
|
|
57
|
+
return path.read_text()
|
|
58
|
+
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
59
|
+
if result.returncode:
|
|
60
|
+
raise RuntimeError(result.stdout.decode(errors='replace'))
|
|
61
|
+
temporary = root / (key + '.' + uuid.uuid4().hex)
|
|
62
|
+
temporary.write_bytes(result.stdout)
|
|
63
|
+
temporary.replace(path)
|
|
64
|
+
return result.stdout.decode(errors='replace')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def begin(root: str, role: str) -> Path:
|
|
68
|
+
attempt = Path(root) / 'attempts' / (role + '-' + uuid.uuid4().hex)
|
|
69
|
+
attempt.mkdir(parents=True)
|
|
70
|
+
now = time.monotonic()
|
|
71
|
+
write_json(attempt / 'metrics.json', {
|
|
72
|
+
'role': role, 'started': now, 'wall_started': time.time(),
|
|
73
|
+
'phase': 'preparation', 'phase_started': now,
|
|
74
|
+
'elapsed_s': {key: 0.0 for key in (
|
|
75
|
+
'preparation', 'preflight', 'model', 'snapshot', 'verification',
|
|
76
|
+
)},
|
|
77
|
+
'exit_code': None,
|
|
78
|
+
# A shadow review (forge-shadow.py) is spend like any other dispatch, but no
|
|
79
|
+
# result: report keeps it apart from the reviews whose verdicts decided something.
|
|
80
|
+
**({'shadow': True} if os.environ.get('FORGE_SHADOW') == '1' else {}),
|
|
81
|
+
})
|
|
82
|
+
_dataset('on_begin', str(attempt), root, role)
|
|
83
|
+
return attempt
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def phase(attempt: str, name: str, rc: str | None = None) -> None:
|
|
87
|
+
path = Path(attempt) / 'metrics.json'
|
|
88
|
+
data = json.loads(path.read_text())
|
|
89
|
+
now = time.monotonic()
|
|
90
|
+
previous = data['phase']
|
|
91
|
+
duration = now - data['phase_started']
|
|
92
|
+
data['elapsed_s'][previous] = data['elapsed_s'].get(previous, 0) + duration
|
|
93
|
+
data.update(phase=name, phase_started=now)
|
|
94
|
+
if rc is not None:
|
|
95
|
+
data['exit_code'] = int(rc)
|
|
96
|
+
data['elapsed_s']['total'] = now - data['started']
|
|
97
|
+
write_json(path, data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def token_count(value: object) -> int | None:
|
|
101
|
+
return value if type(value) is int and value >= 0 else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
|
|
105
|
+
usage = dict(input_tokens=None, cached_input_tokens=None, output_tokens=None)
|
|
106
|
+
final = None
|
|
107
|
+
for line in log.splitlines():
|
|
108
|
+
try:
|
|
109
|
+
event = json.loads(line)
|
|
110
|
+
except ValueError:
|
|
111
|
+
continue
|
|
112
|
+
if not isinstance(event, dict):
|
|
113
|
+
continue
|
|
114
|
+
native = event.get('usage')
|
|
115
|
+
if not isinstance(native, dict):
|
|
116
|
+
native = {}
|
|
117
|
+
if harness == 'codex' and event.get('type') == 'turn.completed':
|
|
118
|
+
for key in usage:
|
|
119
|
+
value = token_count(native.get(key))
|
|
120
|
+
if value is not None:
|
|
121
|
+
usage[key] = (usage[key] or 0) + value
|
|
122
|
+
if harness == 'codex' and event.get('type') == 'item.completed':
|
|
123
|
+
item = event.get('item')
|
|
124
|
+
if isinstance(item, dict) and item.get('type') == 'agent_message':
|
|
125
|
+
if isinstance(item.get('text'), str):
|
|
126
|
+
final = item['text']
|
|
127
|
+
# grok's streaming-messages-json ends in the same `result` event, token buckets included.
|
|
128
|
+
if harness in ('claude', 'grok') and event.get('type') == 'result':
|
|
129
|
+
if isinstance(event.get('result'), str):
|
|
130
|
+
final = event['result']
|
|
131
|
+
# Claude reports uncached, cache-read and cache-creation separately.
|
|
132
|
+
parts = [token_count(native.get(key)) for key in (
|
|
133
|
+
'input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens',
|
|
134
|
+
)]
|
|
135
|
+
usage['input_tokens'] = sum(parts) if all(v is not None for v in parts) else None
|
|
136
|
+
usage['cached_input_tokens'] = token_count(native.get('cache_read_input_tokens'))
|
|
137
|
+
usage['output_tokens'] = token_count(native.get('output_tokens'))
|
|
138
|
+
return final, usage
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def finish(attempt: str, root: str, role: str, harness: str, rc: str) -> None:
|
|
142
|
+
try:
|
|
143
|
+
_finish(Path(attempt), Path(root), role, harness, rc)
|
|
144
|
+
finally:
|
|
145
|
+
# In `finally`, so an extraction or copy error above cannot lose the record.
|
|
146
|
+
_dataset('on_finish', str(attempt), str(root), role, harness, rc)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None:
|
|
150
|
+
log = root / (role + '.log')
|
|
151
|
+
last = root / (role + '.last')
|
|
152
|
+
structured = harness in ('codex', 'claude', 'grok')
|
|
153
|
+
text = log.read_text(errors='replace') if structured and log.exists() else ''
|
|
154
|
+
final, usage = extract(text, harness)
|
|
155
|
+
if not last.exists() or not last.stat().st_size:
|
|
156
|
+
if final is not None:
|
|
157
|
+
last.write_text(final + '\n')
|
|
158
|
+
elif not structured and harness != 'pipeline' and log.exists():
|
|
159
|
+
shutil.copyfile(log, last)
|
|
160
|
+
path = attempt / 'metrics.json'
|
|
161
|
+
data = json.loads(path.read_text())
|
|
162
|
+
prompt = root / (role + '.prompt')
|
|
163
|
+
prompt_ready = (attempt / 'prompt.ready').exists() and prompt.exists()
|
|
164
|
+
data.update(
|
|
165
|
+
harness=harness,
|
|
166
|
+
prompt_bytes=prompt.stat().st_size if prompt_ready else None,
|
|
167
|
+
**usage,
|
|
168
|
+
)
|
|
169
|
+
infra = root / (role + '.infra')
|
|
170
|
+
data['infra_class'] = None
|
|
171
|
+
if infra.exists():
|
|
172
|
+
for line in infra.read_text(errors='replace').splitlines():
|
|
173
|
+
if line.startswith('class='):
|
|
174
|
+
data['infra_class'] = line[len('class='):].strip() or None
|
|
175
|
+
for suffix in ('prompt', 'last', 'log', 'resolved', 'cmd', 'timedout', 'infra', 'orphans'):
|
|
176
|
+
if suffix == 'prompt' and not prompt_ready:
|
|
177
|
+
continue
|
|
178
|
+
source = root / (role + '.' + suffix)
|
|
179
|
+
if source.exists():
|
|
180
|
+
shutil.copyfile(source, attempt / source.name)
|
|
181
|
+
write_json(path, data)
|
|
182
|
+
# Include extraction and artifact-copy cost in total, even on failed attempts.
|
|
183
|
+
phase(str(attempt), 'complete', rc)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- infrastructure failure classification --------------------------------------
|
|
187
|
+
# Called by forge-dispatch.sh right after the harness exits, from the LOG: $ROLE.last
|
|
188
|
+
# only exists later (the EXIT trap's `finish` writes it), and a harness that failed on
|
|
189
|
+
# quota or auth never produced a final message to read anyway.
|
|
190
|
+
#
|
|
191
|
+
# The point of the class is to tell "the model's work was bad" apart from "the model
|
|
192
|
+
# never got to work" so a caller can pause and retry instead of spending an attempt. A
|
|
193
|
+
# false positive pauses a run that should have failed; a false negative is today's
|
|
194
|
+
# behaviour. So every pattern is a strong phrase, and prose is never scanned when the
|
|
195
|
+
# harness exited 0 with a real final message.
|
|
196
|
+
INFRA_TAIL_BYTES = 2048
|
|
197
|
+
RETRY_CAP_S = 24 * 3600
|
|
198
|
+
DETAIL_MAX = 200
|
|
199
|
+
|
|
200
|
+
_P = re.compile
|
|
201
|
+
# "HTTP 401", "HTTP/1.1 401", "status code 401", `"status": 429`, "error: 401", "code 401".
|
|
202
|
+
_HTTP_CODE = r"\b(?:http(?:/\d(?:\.\d)?)?|status(?:[ _]code)?|error(?: code)?|code)\b\W{0,10}%s\b"
|
|
203
|
+
INFRA_PATTERNS = (
|
|
204
|
+
('auth', (
|
|
205
|
+
_P(r"\bunauthori[sz]ed\b", re.I),
|
|
206
|
+
_P(r"\binvalid[ _-]?(?:x-)?api[ _-]?key\b", re.I),
|
|
207
|
+
_P(r"\binvalid (?:access |auth |bearer )?(?:token|credentials?)\b", re.I),
|
|
208
|
+
_P(r"\b(?:not|never) (?:logged|signed) in\b", re.I),
|
|
209
|
+
_P(r"\blog ?in required\b|\bplease (?:run )?/?log ?in\b|\brun /login\b", re.I),
|
|
210
|
+
_P(r"\bauthenticat\w* (?:failed|failure|error|required)\b|\bfailed to authenticate\b|\bauthentication_error\b", re.I),
|
|
211
|
+
_P(r"\bAuthenticateToken\b"),
|
|
212
|
+
_P(r"\b(?:oauth |access |auth |refresh |api )?token (?:has |is |was )?(?:expired|revoked)\b|\bexpired (?:oauth |access |auth )?token\b", re.I),
|
|
213
|
+
_P(r"\b(?:no|missing) api[ _-]?key\b|\bcould not resolve authentication method\b", re.I),
|
|
214
|
+
_P(_HTTP_CODE % '40[13]' + r"|\b40[13]\b\W{0,3}(?:unauthori[sz]ed|forbidden)\b", re.I),
|
|
215
|
+
)),
|
|
216
|
+
('quota', (
|
|
217
|
+
_P(r"\busage[ _-]?limit\b", re.I),
|
|
218
|
+
# "You've hit your limit", "hit your session limit", "reached the weekly limit"; but not
|
|
219
|
+
# "reached the context limit", which is the model's problem rather than the account's.
|
|
220
|
+
_P(r"\b(?:hit|reached|exceeded) (?:your|the) (?:(?:usage|session|weekly|daily|monthly|hourly|"
|
|
221
|
+
r"5-hour|five-hour|spend\w*|credit|token|message|request|plan|org\w*) )?limits?\b", re.I),
|
|
222
|
+
_P(r"(?<!context )(?<!rate )(?<!rate-)(?<!rate_)\blimit (?:reached|exceeded)\b", re.I),
|
|
223
|
+
_P(r"\b(?:session|weekly|daily|monthly|5-hour|five-hour) limit\b", re.I),
|
|
224
|
+
_P(r"\bquota (?:exceeded|exhausted|reached)\b|\bexceeded (?:your |the )?(?:current )?quota\b|\binsufficient[ _-]quota\b|\bout of (?:credits|quota)\b", re.I),
|
|
225
|
+
_P(r"\binsufficient (?:balance|credits?|funds)\b", re.I),
|
|
226
|
+
_P(r"\bcredit balance (?:is )?too low\b", re.I),
|
|
227
|
+
_P(r"\bpayment required\b|\bbilling (?:hard )?limit\b|\bbilling (?:issue|error|details|not active)\b|\bcheck your (?:plan and )?billing\b", re.I),
|
|
228
|
+
)),
|
|
229
|
+
('rate_limit', (
|
|
230
|
+
_P(r"\brate[ _-]?limit(?:s|ed|ing)?\b", re.I),
|
|
231
|
+
_P(r"\btoo many requests\b", re.I),
|
|
232
|
+
_P(r"\boverloaded\b", re.I),
|
|
233
|
+
_P(r"\bat capacity\b", re.I),
|
|
234
|
+
_P(_HTTP_CODE % '429' + r"|\b429\b\W{0,3}too many\b", re.I),
|
|
235
|
+
)),
|
|
236
|
+
('network', (
|
|
237
|
+
_P(r"\b(?:ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|ENETDOWN)\b"),
|
|
238
|
+
_P(r"\bgetaddrinfo\b", re.I),
|
|
239
|
+
_P(r"\bconnection (?:refused|reset|timed out)\b|\bnetwork is unreachable\b|\bsocket hang up\b", re.I),
|
|
240
|
+
_P(r"\bcan['’]?t reach the api server\b|\bunable to connect\b|\bfailed to connect\b|\bfetch failed\b", re.I),
|
|
241
|
+
_P(r"\btemporary failure in name resolution\b|\bname or service not known\b", re.I),
|
|
242
|
+
_P(r"\bbad gateway\b|\bservice unavailable\b|\bgateway time-?out\b", re.I),
|
|
243
|
+
_P(r"\binvalid peer certificate\b", re.I),
|
|
244
|
+
)),
|
|
245
|
+
)
|
|
246
|
+
# api_error_status as claude reports it, used only when the text itself matched nothing.
|
|
247
|
+
STATUS_CLASS = {401: 'auth', 403: 'auth', 402: 'quota', 429: 'rate_limit', 529: 'rate_limit',
|
|
248
|
+
502: 'network', 503: 'network', 504: 'network'}
|
|
249
|
+
|
|
250
|
+
_MONTHS = {m: i + 1 for i, m in enumerate(
|
|
251
|
+
('jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'))}
|
|
252
|
+
_UNIT_S = {'d': 86400, 'h': 3600, 'm': 60, 's': 1}
|
|
253
|
+
_DURATION = r"(?:\d+(?:\.\d+)?\s*(?:days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![A-Za-z])[\s,]*(?:and\s+)?)+"
|
|
254
|
+
_EPOCH_RE = _P(r"\|\s*(\d{9,13})\b")
|
|
255
|
+
_IN_RE = _P(r"\b(?:try again|retry(?:ing)?|resets?|available|wait(?:ing)?|again|back)\s+(?:again\s+)?(?:in|after|for)\s+(?P<d>%s)" % _DURATION, re.I)
|
|
256
|
+
_WAIT_RE = _P(r"\bwait(?:ing)?\s+(?P<d>%s)" % _DURATION, re.I)
|
|
257
|
+
_AFTER_RE = _P(r"\bretry[ _-]?after\W{0,4}(?P<d>%s|\d+(?:\.\d+)?)" % _DURATION, re.I)
|
|
258
|
+
_CLOCK_RE = _P(
|
|
259
|
+
r"\b(?:resets?|try again|available again|retry)\s*(?:at|on|after)?\s*"
|
|
260
|
+
r"(?:(?P<mon>jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(?P<day>\d{1,2})(?:st|nd|rd|th)?,?\s*(?:at\s+)?)?"
|
|
261
|
+
r"(?P<h>\d{1,2})(?::(?P<m>\d{2}))?\s*(?:(?P<ap>[ap]\.?m\.?)(?![A-Za-z]))?"
|
|
262
|
+
r"(?:\s*\((?P<tz>[A-Za-z0-9_/+-]+)\))?", re.I)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _duration_seconds(text: str) -> float | None:
|
|
266
|
+
total, found = 0.0, False
|
|
267
|
+
for number, unit in re.findall(r"(\d+(?:\.\d+)?)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![A-Za-z])", text, re.I):
|
|
268
|
+
total += float(number) * _UNIT_S[unit[0].lower()]
|
|
269
|
+
found = True
|
|
270
|
+
if not found and re.fullmatch(r"\s*\d+(?:\.\d+)?\s*", text):
|
|
271
|
+
return float(text) # bare "retry-after: 120" is seconds
|
|
272
|
+
return total if found else None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _clock_seconds(match: 're.Match', now: float) -> float | None:
|
|
276
|
+
hour, minute, ap = int(match.group('h')), int(match.group('m') or 0), match.group('ap')
|
|
277
|
+
if ap:
|
|
278
|
+
if not 1 <= hour <= 12:
|
|
279
|
+
return None
|
|
280
|
+
hour = hour % 12 + (12 if ap.lower().startswith('p') else 0)
|
|
281
|
+
elif match.group('m') is None or hour > 23:
|
|
282
|
+
return None # "resets 3" with no am/pm and no minutes is not a time
|
|
283
|
+
if minute > 59:
|
|
284
|
+
return None
|
|
285
|
+
tz = None
|
|
286
|
+
if match.group('tz'):
|
|
287
|
+
try:
|
|
288
|
+
from zoneinfo import ZoneInfo
|
|
289
|
+
tz = ZoneInfo(match.group('tz'))
|
|
290
|
+
except Exception:
|
|
291
|
+
tz = None # unknown zone name or no tz database: fall back to local time
|
|
292
|
+
current = datetime.datetime.fromtimestamp(now, tz)
|
|
293
|
+
if match.group('mon'):
|
|
294
|
+
try:
|
|
295
|
+
target = current.replace(month=_MONTHS[match.group('mon').lower()[:3]], day=int(match.group('day')),
|
|
296
|
+
hour=hour, minute=minute, second=0, microsecond=0)
|
|
297
|
+
except ValueError:
|
|
298
|
+
return None
|
|
299
|
+
if target <= current:
|
|
300
|
+
target = target.replace(year=target.year + 1)
|
|
301
|
+
else:
|
|
302
|
+
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
303
|
+
if target <= current:
|
|
304
|
+
target += datetime.timedelta(days=1)
|
|
305
|
+
return target.timestamp() - now
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def retry_after(text: str, now: float | None = None) -> int | None:
|
|
309
|
+
"""Seconds until the limit lifts, from the message text; None when it says nothing."""
|
|
310
|
+
now = time.time() if now is None else now
|
|
311
|
+
found: float | None = None
|
|
312
|
+
epoch = _EPOCH_RE.search(text)
|
|
313
|
+
if epoch:
|
|
314
|
+
stamp = float(epoch.group(1))
|
|
315
|
+
found = max(0.0, (stamp / 1000 if stamp > 1e11 else stamp) - now)
|
|
316
|
+
if found is None:
|
|
317
|
+
for regex in (_IN_RE, _WAIT_RE, _AFTER_RE):
|
|
318
|
+
match = regex.search(text)
|
|
319
|
+
if match:
|
|
320
|
+
found = _duration_seconds(match.group('d'))
|
|
321
|
+
if found is not None:
|
|
322
|
+
break
|
|
323
|
+
if found is None:
|
|
324
|
+
for match in _CLOCK_RE.finditer(text):
|
|
325
|
+
found = _clock_seconds(match, now)
|
|
326
|
+
if found is not None:
|
|
327
|
+
break
|
|
328
|
+
if found is None:
|
|
329
|
+
return None
|
|
330
|
+
return int(min(RETRY_CAP_S, max(0, found) + 0.999))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _one_line(text: str, limit: int = DETAIL_MAX) -> str:
|
|
334
|
+
return ' '.join(text.split())[:limit]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def classify_text(text: str, status: object = None, now: float | None = None):
|
|
338
|
+
"""(class, retry_after_or_None, detail) for failure text, or None. auth > quota > rate_limit > network."""
|
|
339
|
+
for name, patterns in INFRA_PATTERNS:
|
|
340
|
+
for pattern in patterns:
|
|
341
|
+
match = pattern.search(text)
|
|
342
|
+
if match:
|
|
343
|
+
start = text.rfind('\n', 0, match.start()) + 1
|
|
344
|
+
end = text.find('\n', match.end())
|
|
345
|
+
line = text[start:end if end >= 0 else len(text)]
|
|
346
|
+
if len(line) > DETAIL_MAX: # a minified blob: excerpt around the match
|
|
347
|
+
line = text[max(0, match.start() - 80):match.end() + 80]
|
|
348
|
+
return name, retry_after(text, now), _one_line(line)
|
|
349
|
+
if type(status) is int and status in STATUS_CLASS:
|
|
350
|
+
return STATUS_CLASS[status], retry_after(text, now), _one_line(text or 'HTTP %d' % status)
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _events(text: str):
|
|
355
|
+
"""JSON objects in the log (JSONL, one JSON document, or a JSON array) and the non-JSON lines."""
|
|
356
|
+
events, stray = [], []
|
|
357
|
+
stripped = text.strip()
|
|
358
|
+
if stripped[:1] in ('{', '['):
|
|
359
|
+
try:
|
|
360
|
+
whole = json.loads(stripped)
|
|
361
|
+
except ValueError:
|
|
362
|
+
whole = None
|
|
363
|
+
if isinstance(whole, dict):
|
|
364
|
+
return [whole], []
|
|
365
|
+
if isinstance(whole, list):
|
|
366
|
+
return [x for x in whole if isinstance(x, dict)], []
|
|
367
|
+
for line in text.splitlines():
|
|
368
|
+
candidate = line.strip()
|
|
369
|
+
if not candidate:
|
|
370
|
+
continue
|
|
371
|
+
if candidate[0] == '{':
|
|
372
|
+
try:
|
|
373
|
+
event = json.loads(candidate)
|
|
374
|
+
except ValueError:
|
|
375
|
+
continue # a damaged JSON line is not prose either
|
|
376
|
+
if isinstance(event, dict):
|
|
377
|
+
events.append(event)
|
|
378
|
+
continue
|
|
379
|
+
if candidate[0] == '[':
|
|
380
|
+
continue
|
|
381
|
+
stray.append(candidate)
|
|
382
|
+
return events, stray
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _codex_terminal_errors(events: list) -> list[str]:
|
|
386
|
+
"""Messages of error/turn.failed events with nothing but more errors after them.
|
|
387
|
+
|
|
388
|
+
codex emits non-fatal `error` events (reconnect notices) and then carries on, so an
|
|
389
|
+
error counts only when it is where the run stopped: any later item, message or
|
|
390
|
+
completed turn shows it recovered.
|
|
391
|
+
"""
|
|
392
|
+
pending: list[str] = []
|
|
393
|
+
for event in events:
|
|
394
|
+
kind = event.get('type')
|
|
395
|
+
if kind == 'error':
|
|
396
|
+
message = event.get('message')
|
|
397
|
+
pending.append(message if isinstance(message, str) else json.dumps(event))
|
|
398
|
+
elif kind == 'turn.failed':
|
|
399
|
+
error = event.get('error')
|
|
400
|
+
message = error.get('message') if isinstance(error, dict) else error
|
|
401
|
+
pending.append(message if isinstance(message, str) else json.dumps(event))
|
|
402
|
+
else:
|
|
403
|
+
pending = [] # any later activity means the run went on past the error
|
|
404
|
+
return pending
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _codex_final(events: list, last: str) -> str:
|
|
408
|
+
try:
|
|
409
|
+
path = Path(last) if last else None
|
|
410
|
+
if path is not None and path.is_file():
|
|
411
|
+
text = path.read_text(errors='replace')
|
|
412
|
+
if text.strip():
|
|
413
|
+
return text
|
|
414
|
+
except OSError:
|
|
415
|
+
pass
|
|
416
|
+
final = ''
|
|
417
|
+
for event in events:
|
|
418
|
+
item = event.get('item')
|
|
419
|
+
if event.get('type') == 'item.completed' and isinstance(item, dict) and item.get('type') == 'agent_message':
|
|
420
|
+
if isinstance(item.get('text'), str) and item['text'].strip():
|
|
421
|
+
final = item['text']
|
|
422
|
+
return final
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _tail(lines_or_text, size: int = INFRA_TAIL_BYTES) -> str:
|
|
426
|
+
text = lines_or_text if isinstance(lines_or_text, str) else '\n'.join(lines_or_text)
|
|
427
|
+
return text[-size:]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def classify(log: str, harness: str, rc: object, last: str = '', role: str = '', now: float | None = None):
|
|
431
|
+
"""(class, retry_after_or_None, detail) when this exit was infrastructure, else None."""
|
|
432
|
+
try:
|
|
433
|
+
rc = int(rc)
|
|
434
|
+
except (TypeError, ValueError):
|
|
435
|
+
rc = 1
|
|
436
|
+
if harness == 'codex' and rc == 0 and _codex_final([], last).strip():
|
|
437
|
+
return None # the common case, and codex logs get large: don't even read it
|
|
438
|
+
try:
|
|
439
|
+
text = Path(log).read_text(errors='replace')
|
|
440
|
+
except OSError:
|
|
441
|
+
text = ''
|
|
442
|
+
events, stray = _events(text) if harness in ('codex', 'claude', 'openclaude', 'grok') else ([], [])
|
|
443
|
+
|
|
444
|
+
if harness == 'codex':
|
|
445
|
+
final = _codex_final(events, last)
|
|
446
|
+
pending = _codex_terminal_errors(events)
|
|
447
|
+
if rc == 0:
|
|
448
|
+
if final.strip():
|
|
449
|
+
return None
|
|
450
|
+
if pending:
|
|
451
|
+
hit = classify_text('\n'.join(pending), now=now)
|
|
452
|
+
if hit:
|
|
453
|
+
return hit
|
|
454
|
+
return 'empty', None, 'codex finished with exit 0 but produced no final message'
|
|
455
|
+
return classify_text('\n'.join(pending + [_tail(stray)]), now=now)
|
|
456
|
+
|
|
457
|
+
results = [e for e in events if e.get('type') == 'result'] if harness in ('claude', 'openclaude', 'grok') else []
|
|
458
|
+
if results:
|
|
459
|
+
event = results[-1]
|
|
460
|
+
body = event.get('result')
|
|
461
|
+
body = body if isinstance(body, str) else ''
|
|
462
|
+
# grok's error subtypes put the reason in errors[] and may leave result empty.
|
|
463
|
+
if not body.strip() and isinstance(event.get('errors'), list):
|
|
464
|
+
body = '\n'.join(str(e) for e in event['errors'])
|
|
465
|
+
if event.get('is_error') is True:
|
|
466
|
+
hit = classify_text(body, event.get('api_error_status'), now)
|
|
467
|
+
if hit or harness != 'grok' or event.get('stop_reason') != 'cancelled':
|
|
468
|
+
return hit
|
|
469
|
+
# Headless grok cancels the whole session when a tool call needs approval it
|
|
470
|
+
# cannot ask for, and exits 0 anyway. Not infrastructure: the model was working
|
|
471
|
+
# when the CLI stopped it, so what it did is still judged. Named, so nobody
|
|
472
|
+
# mistakes the cut-off for a finished run.
|
|
473
|
+
asked = [line.strip() for line in stray if line.strip().startswith('permission required:')]
|
|
474
|
+
if asked:
|
|
475
|
+
return 'cancelled', None, 'grok cancelled the session after a tool call needed approval: ' + asked[-1]
|
|
476
|
+
return 'cancelled', None, 'grok cancelled the session before it finished'
|
|
477
|
+
if rc == 0 and not body.strip():
|
|
478
|
+
return 'empty', None, '%s finished with exit 0 but its final result is empty' % harness
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
# Raw-log harness (opencode, antigravity, openclaude's plain text, or a claude that
|
|
482
|
+
# died before emitting its result): the log is the message, so only the tail of a
|
|
483
|
+
# failing run is evidence. A successful run's prose is never scanned.
|
|
484
|
+
if rc != 0:
|
|
485
|
+
return classify_text(_tail(text), now=now)
|
|
486
|
+
if not text.strip():
|
|
487
|
+
return 'empty', None, '%s finished with exit 0 but wrote nothing' % harness
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def classify_command(log: str, harness: str, rc: str, last: str = '', role: str = '') -> None:
|
|
492
|
+
try:
|
|
493
|
+
hit = classify(log, harness, rc, last, role)
|
|
494
|
+
except Exception: # classification is advisory and must never change an exit code
|
|
495
|
+
hit = None
|
|
496
|
+
if hit:
|
|
497
|
+
name, after, detail = hit
|
|
498
|
+
print('%s\t%s\t%s' % (name, '' if after is None else after, detail.replace('\t', ' ')))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def report(root: str) -> None:
|
|
502
|
+
records = []
|
|
503
|
+
for path in sorted(Path(root).rglob('attempts/*/metrics.json')):
|
|
504
|
+
record = json.loads(path.read_text())
|
|
505
|
+
record['artifact'] = str(path)
|
|
506
|
+
records.append(record)
|
|
507
|
+
print(json.dumps({'attempts': records}, indent=2))
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def main() -> None:
|
|
511
|
+
command, *args = sys.argv[1:]
|
|
512
|
+
try:
|
|
513
|
+
if command == 'help':
|
|
514
|
+
print(capability_help(*args[:3], args[3:]), end='')
|
|
515
|
+
elif command == 'begin':
|
|
516
|
+
print(begin(*args))
|
|
517
|
+
elif command == 'phase':
|
|
518
|
+
phase(*args)
|
|
519
|
+
elif command == 'finish':
|
|
520
|
+
finish(*args)
|
|
521
|
+
elif command == 'report':
|
|
522
|
+
report(*args)
|
|
523
|
+
elif command == 'classify':
|
|
524
|
+
classify_command(*args)
|
|
525
|
+
else:
|
|
526
|
+
raise ValueError('unknown runtime command: ' + command)
|
|
527
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
528
|
+
print('forge: ' + str(error), file=sys.stderr)
|
|
529
|
+
sys.exit(3)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
if __name__ == '__main__':
|
|
533
|
+
main()
|