@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. 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())
@@ -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 ends in the same `result` event, token buckets included.
128
- if harness in ('claude', 'grok') and event.get('type') == 'result':
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 ('codex', 'claude', 'grok')
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 ('codex', 'claude', 'openclaude', 'grok') else ([], [])
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 (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
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 or name in ('.', '..') or '/' in 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
- retry_failed > 0
133
- and retries.get(name, 0) < retry_failed
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 (plan / 'tasks' / name / 'noretry').exists()
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
- and not (plan / 'tasks' / name / 'escalation.stop').exists()
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(retry_failed) + ')', file=sys.stderr, flush=True)
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 paused and not active and infra_seen and infra_cycles < infra_retries:
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)