@giamme/forge 0.11.1 → 0.12.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 (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +44 -11
  5. package/docs/commands.md +45 -7
  6. package/docs/decomposed-runs.md +43 -2
  7. package/docs/forge-ui.md +27 -6
  8. package/docs/reviews.md +31 -6
  9. package/package.json +1 -1
  10. package/references/dataset.md +3 -1
  11. package/references/decompose.md +159 -17
  12. package/references/harnesses.md +13 -1
  13. package/references/ui.md +14 -4
  14. package/scripts/forge-control.py +268 -25
  15. package/scripts/forge-dispatch.sh +80 -3
  16. package/scripts/forge-guard.py +156 -15
  17. package/scripts/forge-lib-plans.sh +9 -3
  18. package/scripts/forge-lib-review.sh +22 -3
  19. package/scripts/forge-lib-task.sh +51 -4
  20. package/scripts/forge-lib-verify.sh +137 -6
  21. package/scripts/forge-parallel-options.sh +59 -0
  22. package/scripts/forge-parallel.sh +212 -28
  23. package/scripts/forge-ripwire.py +1 -1
  24. package/scripts/forge-route.py +157 -5
  25. package/scripts/forge-schedule.py +687 -50
  26. package/scripts/forge-verify.py +878 -0
  27. package/scripts/forge_dataset/forecast.py +29 -4
  28. package/scripts/forge_dataset/harvest.py +10 -4
  29. package/scripts/forge_verify_reports.py +225 -0
  30. package/ui/README.md +18 -3
  31. package/ui/docs/api.md +126 -17
  32. package/ui/forge_ui/control.py +12 -2
  33. package/ui/forge_ui/export.py +20 -7
  34. package/ui/forge_ui/reader.py +307 -11
  35. package/ui/forge_ui/server.py +16 -0
  36. package/ui/forge_ui/static/app.js +2 -1
  37. package/ui/forge_ui/static/controls.js +217 -33
  38. package/ui/forge_ui/static/gantt.js +14 -9
  39. package/ui/forge_ui/static/index.html +1 -0
  40. package/ui/forge_ui/static/panel.js +2 -1
  41. package/ui/forge_ui/static/runmodel.js +92 -11
  42. package/ui/forge_ui/static/scene3d.js +9 -5
  43. package/ui/forge_ui/static/style.css +60 -0
  44. package/ui/forge_ui/static/verifyview.js +324 -0
@@ -23,6 +23,7 @@ import errno
23
23
  import fcntl
24
24
  import json
25
25
  import os
26
+ import re
26
27
  from pathlib import Path
27
28
  import posixpath
28
29
  import signal
@@ -74,19 +75,121 @@ def infra_info(root: Path) -> dict:
74
75
  return info
75
76
 
76
77
 
77
- def infra_wait(info: dict, cycle: int) -> float:
78
- """Seconds to wait before re-dispatching after an infrastructure failure."""
78
+ # --- backoff shapes ----------------------------------------------------------------------
79
+ # One parser for the --infra-backoff flag (through `forge-schedule.py backoff-check`), for
80
+ # `forge control set infra-backoff` (which loads this file) and for the waits themselves.
81
+ BACKOFF_FORMS = 'exp:<base>[,<cap>], linear:<start>,<step>[,<cap>], list:<a>,<b>,... or <a>,<b>,... (seconds, positive)'
82
+ _NUMBER = re.compile(r'(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)')
83
+ _COUNTS = {'exp': (1, 2), 'linear': (2, 3), 'list': (1, 1000)}
84
+
85
+
86
+ def parse_backoff(text: str) -> tuple:
87
+ """(kind, numbers) for a backoff shape; ValueError naming the forms when malformed."""
88
+ shape = (text or '').strip()
89
+ kind, colon, rest = shape.partition(':')
90
+ if not colon:
91
+ kind, rest = 'list', shape
92
+ if kind not in _COUNTS:
93
+ raise ValueError('unknown backoff shape %r: use %s' % (shape, BACKOFF_FORMS))
94
+ numbers = []
95
+ for part in rest.split(','):
96
+ part = part.strip()
97
+ if not _NUMBER.fullmatch(part) or float(part) <= 0:
98
+ raise ValueError('backoff %r needs positive numbers: use %s' % (shape, BACKOFF_FORMS))
99
+ numbers.append(float(part))
100
+ low, high = _COUNTS[kind]
101
+ if not low <= len(numbers) <= high:
102
+ raise ValueError('backoff %r has the wrong number of values: use %s' % (shape, BACKOFF_FORMS))
103
+ return kind, numbers
104
+
105
+
106
+ def backoff_wait(shape: tuple, cycle: int) -> float:
107
+ """Seconds to wait before resuming for a 0-based wait number."""
108
+ kind, numbers = shape
109
+ if kind == 'exp':
110
+ cap = numbers[1] if len(numbers) > 1 else max(numbers[0], 1800.0)
111
+ return min(numbers[0] * 2 ** min(cycle, 64), cap)
112
+ if kind == 'linear':
113
+ wait = numbers[0] + numbers[1] * cycle
114
+ return min(wait, numbers[2]) if len(numbers) > 2 else wait
115
+ return numbers[min(cycle, len(numbers) - 1)]
116
+
117
+
118
+ def default_backoff() -> tuple:
119
+ """Today's shape: exp with FORGE_INFRA_BACKOFF (60 s) as the base. (shape, its name)"""
79
120
  try:
80
121
  base = float(os.environ.get('FORGE_INFRA_BACKOFF', '60'))
81
122
  except ValueError:
82
123
  base = 60.0
124
+ return ('exp', [base]), 'exp:%g' % base
125
+
126
+
127
+ def infra_wait(info: dict, cycle: int, backoff: tuple | None = None) -> tuple:
128
+ """(seconds, what decided it) before re-dispatching after an infrastructure failure. A
129
+ provider's retry-after wins; otherwise the backoff shape (shape, name) for this wait."""
83
130
  try:
84
131
  hinted = float(info.get('retry_after', ''))
85
132
  except ValueError:
86
133
  hinted = None
87
134
  if hinted is not None and hinted >= 0:
88
- return min(hinted, INFRA_WAIT_CAP)
89
- return min(base * (2 ** cycle), max(base, 1800.0))
135
+ return min(hinted, INFRA_WAIT_CAP), 'provider retry-after'
136
+ shape, name = backoff or default_backoff()
137
+ return backoff_wait(shape, cycle), 'backoff ' + name
138
+
139
+
140
+ # --- pause scopes ----------------------------------------------------------------------
141
+ # A capacity error on one model says nothing about another, and a quota or auth stop is one
142
+ # harness's account; only the remaining classes (network, empty output, ...) stop everything.
143
+ def infra_scope(info: dict) -> tuple:
144
+ """(scope, harness, model) for a stop's infra.txt."""
145
+ cls, harness, model = info.get('class', ''), info.get('harness', ''), info.get('model', '')
146
+ if cls == 'rate_limit' and harness and model:
147
+ return 'model:%s/%s' % (harness, model), harness, model
148
+ if cls in ('quota', 'auth') and harness:
149
+ return 'harness:' + harness, harness, ''
150
+ return 'all', harness, model
151
+
152
+
153
+ def load_registry() -> list:
154
+ path = os.environ.get('FORGE_REGISTRY') or str(Path(__file__).resolve().parent.parent / 'registry.tsv')
155
+ rows = []
156
+ try:
157
+ with open(path, errors='replace') as handle:
158
+ for line in handle:
159
+ columns = line.rstrip('\n').split('\t')
160
+ if not line.startswith('#') and len(columns) >= 3:
161
+ rows.append(columns)
162
+ except OSError:
163
+ pass
164
+ return rows
165
+
166
+
167
+ def spec_pair(registry: list, spec: str) -> tuple | None:
168
+ """(harness, model) for a spec, resolved the way forge-dispatch.sh's lookup does: an
169
+ alias's first row is its default harness, alias:effort:harness picks that row, and an
170
+ unknown alias with a harness is a literal model id. None when it cannot be resolved."""
171
+ if not spec or spec in ('-', 'UNASSIGNED') or ',' in spec:
172
+ return None
173
+ parts = spec.split(':')
174
+ alias, harness = parts[0], ':'.join(parts[2:])
175
+ if not alias:
176
+ return None
177
+ for row in registry:
178
+ if row[0] == alias and (not harness or row[1] == harness):
179
+ return row[1], row[2]
180
+ return (harness, alias) if harness else None
181
+
182
+
183
+ def holds(pause: dict, pairs: list) -> bool:
184
+ """True when a task whose next dispatch uses these pairs falls under this pause."""
185
+ for pair in pairs:
186
+ if pair is None:
187
+ continue
188
+ if pause['model'] is not None and pair == (pause['harness'], pause['model']):
189
+ return True
190
+ if pause['model'] is None and pair[0] == pause['harness']:
191
+ return True
192
+ return False
90
193
 
91
194
 
92
195
  # --- live control files ------------------------------------------------------------------
@@ -208,6 +311,50 @@ def _task_dirs(plan: object, names: object) -> list[str]:
208
311
  return sorted(found)
209
312
 
210
313
 
314
+ def _read_small(plan: object, name: str) -> str | None:
315
+ """A short plan-level control file's stripped text; None when missing or unsafe."""
316
+ try:
317
+ fd = os.open(_resolve(plan, name), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
318
+ try:
319
+ return os.read(fd, 65).decode('ascii').strip()
320
+ finally:
321
+ os.close(fd)
322
+ except (OSError, UnicodeDecodeError):
323
+ return None
324
+
325
+
326
+ def stop_mode(plan: object) -> str | None:
327
+ """`forge control stop` wrote `graceful` or `now`; anything else is no stop."""
328
+ text = _read_small(plan, 'stop')
329
+ return text if text in ('graceful', 'now') else None
330
+
331
+
332
+ def drained(plan: object) -> bool:
333
+ return _read_small(plan, 'drain') is not None
334
+
335
+
336
+ def _fractal_plan(plan: object) -> bool:
337
+ """fractal-selection.json enabled (unreadable counts: fail closed, as forge-control does)."""
338
+ try:
339
+ text = Path(_resolve(plan, 'fractal-selection.json')).read_text()
340
+ except FileNotFoundError:
341
+ return False
342
+ except OSError:
343
+ return True
344
+ if not text.strip():
345
+ return False
346
+ try:
347
+ return bool(json.loads(text).get('enabled'))
348
+ except (ValueError, AttributeError):
349
+ return True
350
+
351
+
352
+ def live_capacity(plan: object, default: int) -> int:
353
+ """`forge control set max-parallel` (1-64), else the argument the run started with."""
354
+ text = _read_small(plan, 'max_parallel')
355
+ return int(text) if text and text.isdigit() and 1 <= int(text) <= 64 else default
356
+
357
+
211
358
  def _pinned(plan: object, name: str) -> bool:
212
359
  try:
213
360
  return os.path.lexists(_resolve(plan, 'tasks', name, 'escalation.pin'))
@@ -215,6 +362,135 @@ def _pinned(plan: object, name: str) -> bool:
215
362
  return False
216
363
 
217
364
 
365
+ # --- interrupted tasks ---------------------------------------------------------------------
366
+ # A task's harness leads its own session (forge-guard.py session), so the SIGTERM sent to the
367
+ # task's group reaches dispatch but not the harness: dispatch's EXIT trap stops it. The grace
368
+ # lets that trap finish; the guard's `stop` then acts on whatever <role>.harness record a
369
+ # dispatch that died too early left behind.
370
+ GUARD = str(Path(__file__).resolve().parent / 'forge-guard.py')
371
+ HARNESS_ROLES = ('dwarf', 'qa')
372
+ GUARD_STOP_WAIT = 15.0 # seconds; the guard's own bound is about 3 s plus two ps calls
373
+
374
+
375
+ def interrupt_grace() -> float:
376
+ """Seconds an interrupted task group gets to empty before SIGKILL."""
377
+ try:
378
+ value = float(os.environ.get('FORGE_INTERRUPT_GRACE', '10'))
379
+ except ValueError:
380
+ return 10.0
381
+ return value if 0 <= value < float('inf') else 10.0
382
+
383
+
384
+ def _group_alive(pgid: int) -> bool:
385
+ try:
386
+ os.killpg(pgid, 0)
387
+ except ProcessLookupError:
388
+ return False
389
+ except PermissionError:
390
+ return True
391
+ except OSError:
392
+ return False
393
+ return True
394
+
395
+
396
+ def stop_harnesses(plan: object, names: object) -> None:
397
+ """Run the guard's identity-checked `stop` on every harness record of these tasks, all
398
+ at once, so the bound does not grow with the number of tasks. Never raises."""
399
+ stopping = []
400
+ for name in names:
401
+ for role in HARNESS_ROLES:
402
+ try:
403
+ record = _resolve(plan, 'tasks', name, role + '.harness')
404
+ if not os.path.lexists(record):
405
+ continue
406
+ stopping.append((name, role, subprocess.Popen(
407
+ [sys.executable, GUARD, 'stop', record], stdin=subprocess.DEVNULL,
408
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)))
409
+ except (OSError, ValueError) as error:
410
+ _warn('could not stop the ' + role + ' harness of ' + name + ': ' + str(error))
411
+ deadline = time.monotonic() + GUARD_STOP_WAIT
412
+ for name, role, process in stopping:
413
+ try:
414
+ out, _ = process.communicate(timeout=max(deadline - time.monotonic(), 0.1))
415
+ except subprocess.TimeoutExpired:
416
+ process.kill()
417
+ process.communicate()
418
+ _warn('stopping the ' + role + ' harness of ' + name + ' took too long; gave up')
419
+ continue
420
+ count = (out or '').strip()
421
+ if count.isdigit() and count != '0':
422
+ _warn(name + ': stopped ' + count + ' process(es) its ' + role + ' harness left running')
423
+
424
+
425
+ # --- control requests ----------------------------------------------------------------------
426
+ # `forge control retry|add` queue one JSON file each in control.requests/, under control.lock and
427
+ # only while scheduler.live says a scheduler reads them. The scheduler applies or refuses each,
428
+ # logs one outcome line with the same `request` id, and removes it. live_stop settles whatever
429
+ # is left after it withdraws liveness, under the same lock, so no request outlives its run.
430
+ REQUESTS = 'control.requests'
431
+ REQUEST_MAX = 64 * 1024
432
+ RETRYABLE = ('FAIL', 'UNKNOWN', 'TIMEOUT', 'ERROR', 'INTERRUPTED', 'BLOCKED')
433
+
434
+
435
+ def list_requests(plan: object) -> list:
436
+ """Request file names in order; temp (dot) files and anything not a regular file are skipped."""
437
+ directory = os.path.join(os.path.realpath(plan), REQUESTS)
438
+ try:
439
+ if not stat.S_ISDIR(os.lstat(directory).st_mode):
440
+ return []
441
+ with os.scandir(directory) as entries:
442
+ return sorted(e.name for e in entries
443
+ if not e.name.startswith('.') and e.is_file(follow_symlinks=False) and _task_id_ok(e.name))
444
+ except OSError:
445
+ return []
446
+
447
+
448
+ def read_request(plan: object, name: str) -> dict | None:
449
+ try:
450
+ fd = os.open(_resolve(plan, REQUESTS, name), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
451
+ try:
452
+ data = os.read(fd, REQUEST_MAX + 1)
453
+ finally:
454
+ os.close(fd)
455
+ request = json.loads(data.decode('utf-8'))
456
+ except (OSError, ValueError, UnicodeDecodeError):
457
+ return None
458
+ if len(data) > REQUEST_MAX or not isinstance(request, dict) or request.get('verb') not in ('retry', 'add'):
459
+ return None
460
+ task, dwarf = request.get('task'), request.get('dwarf')
461
+ if not isinstance(task, str) or not _task_id_ok(task):
462
+ return None
463
+ if dwarf is not None and (not isinstance(dwarf, str) or not dwarf or dwarf.startswith('-')
464
+ or any(c.isspace() or c == ',' for c in dwarf)):
465
+ return None
466
+ return request
467
+
468
+
469
+ def settle_request(plan: object, name: str, request: dict | None, outcome: str, reason: str | None) -> None:
470
+ """The outcome line in control.log, then the request goes. Called under control.lock."""
471
+ verb = request.get('verb') if request else None
472
+ task = request.get('task') if request else None
473
+ entry = {'at': time.time(), 'actor': 'scheduler', 'action': verb, 'task': task, 'key': None,
474
+ 'value': request.get('dwarf') if request else None, 'previous': None, 'request': name,
475
+ 'outcome': outcome, 'reason': reason}
476
+ try:
477
+ fd = os.open(_resolve(plan, 'control.log'), os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o644)
478
+ try:
479
+ os.write(fd, (json.dumps(entry) + '\n').encode('utf-8'))
480
+ finally:
481
+ os.close(fd)
482
+ except OSError as error:
483
+ _warn('could not log the outcome of request ' + name + ': ' + str(error))
484
+ _remove(plan, REQUESTS, name)
485
+ print('forge: control: ' + (verb or 'request') + ' ' + (task or name) + ' '
486
+ + (outcome if outcome == 'applied' else 'refused: ' + (reason or '')), file=sys.stderr, flush=True)
487
+
488
+
489
+ def refuse_requests(plan: object, reason: str) -> None:
490
+ for name in list_requests(plan):
491
+ settle_request(plan, name, read_request(plan, name), 'refused', reason)
492
+
493
+
218
494
  def live_start(plan: object, names: object, retry_failed: int, infra_retries: int) -> None:
219
495
  """Publish the flags and that this scheduler is live. Stale per-task overrides and
220
496
  usage counters from an earlier invocation go first."""
@@ -222,18 +498,28 @@ def live_start(plan: object, names: object, retry_failed: int, infra_retries: in
222
498
  for name in _task_dirs(plan, names):
223
499
  _remove(plan, 'tasks', name, 'retry_budget')
224
500
  _remove(plan, 'tasks', name, 'retries.used')
501
+ _remove(plan, 'infra.paused')
502
+ _remove(plan, 'drain')
503
+ _remove(plan, 'stop')
504
+ # Queued for a scheduler that died: never applied to this run.
505
+ refuse_requests(plan, 'left by an earlier run')
225
506
  _write_atomic(plan, str(retry_failed), 'retry_budget')
226
507
  _write_atomic(plan, str(infra_retries), 'infra_budget')
227
508
  _write_atomic(plan, json.dumps({'pid': os.getpid(), 'started': time.time()}), 'scheduler.live')
228
509
 
229
510
 
230
511
  def live_stop(plan: object, names: object) -> None:
231
- """Withdraw liveness first, then every budget. retries.used stays for the UI."""
512
+ """Withdraw liveness first, then every budget. retries.used stays for the UI. A request
513
+ queued before liveness went is settled here: none can arrive once the lock is released."""
232
514
  try:
233
515
  with _control_lock(plan):
234
516
  _remove(plan, 'scheduler.live')
517
+ refuse_requests(plan, 'run ended')
235
518
  _remove(plan, 'retry_budget')
236
519
  _remove(plan, 'infra_budget')
520
+ _remove(plan, 'infra.paused')
521
+ _remove(plan, 'drain')
522
+ _remove(plan, 'stop')
237
523
  for name in _task_dirs(plan, names):
238
524
  _remove(plan, 'tasks', name, 'retry_budget')
239
525
  except Exception as error:
@@ -270,8 +556,19 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
270
556
  infra_seen = [] # tasks stopped by an infrastructure failure, awaiting resume
271
557
  held = set() # INFRA tasks not resumed: their pin could not be applied
272
558
  infra_cycles = 0 # how many times this run already waited out an infrastructure pause
273
- paused = False # an infrastructure failure was seen: dispatch nothing new
559
+ paused = False # an `all`-scope infrastructure failure was seen: dispatch nothing new
274
560
  resume_at = 0.0
561
+ all_class = '' # the class of the first `all`-scope stop, for infra.paused
562
+ pauses = {} # scope -> its pause, for model: and harness: scopes; `all` is the above
563
+ scope_waits = {} # scope -> waits it already completed this run
564
+ pairs_cache = {} # task -> (harness, model) pairs its next dispatch uses
565
+ registry = load_registry()
566
+ paused_text = None # what infra.paused holds now
567
+ backoff_warned = [] # an invalid infra_backoff file is reported once
568
+ graceful = False # `forge control stop`: start nothing more, end once running tasks are done
569
+ human = set() # tasks whose next dispatch is a `forge control retry` (dataset trigger human)
570
+ fractal = _fractal_plan(plan)
571
+ drain_told = False # the drained message was printed for this drain
275
572
 
276
573
  def status(name: str) -> str:
277
574
  root = plan / 'tasks' / name
@@ -323,6 +620,7 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
323
620
  refused[name] = budget(name)
324
621
  return False
325
622
  refused.pop(name, None)
623
+ pairs_cache.pop(name, None) # the preparation may have escalated the implementer
326
624
  retries[name] = retries.get(name, 0) + 1
327
625
  _write_atomic(plan, str(retries[name]), 'tasks', name, 'retries.used')
328
626
  retried.add(name)
@@ -357,6 +655,282 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
357
655
  offsets[name] = len(data)
358
656
  print(data[start:].decode('utf-8', 'replace'), end='', flush=True)
359
657
 
658
+ def row_now(name: str) -> list:
659
+ """The task's tasks.tsv row as it is now: an escalation rewrites its dwarf column."""
660
+ try:
661
+ for line in (plan / 'tasks.tsv').read_text().splitlines():
662
+ columns = line.split('\t')
663
+ if len(columns) == 7 and columns[0] == name:
664
+ return columns
665
+ except OSError:
666
+ pass
667
+ return tasks[name]
668
+
669
+ def next_pairs(name: str) -> list:
670
+ """The pairs the task's next dispatch uses: QA only when its next entry is a QA-only
671
+ resume (decided by forge-parallel.sh's own check), the implementer and QA otherwise."""
672
+ if name not in pairs_cache:
673
+ row = row_now(name)
674
+ qa_only = (plan / 'tasks' / name / 'resume').exists() and subprocess.run(
675
+ ['/bin/bash', script, '_qa_only', str(plan), name],
676
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
677
+ pairs_cache[name] = [spec_pair(registry, spec) for spec in ([row[5]] if qa_only else [row[4], row[5]])]
678
+ return pairs_cache[name]
679
+
680
+ def scope_held(name: str) -> bool:
681
+ return any(holds(pause, next_pairs(name)) for pause in pauses.values())
682
+
683
+ def live_pause(pause: dict) -> bool:
684
+ """A pause still holding something: its own stopped tasks, or a pending task on its pair.
685
+ One whose tasks all fell back to another model holds nothing."""
686
+ return bool(pause['tasks']) or any(holds(pause, next_pairs(name)) for name in pending)
687
+
688
+ def waiting_scopes() -> bool:
689
+ return any(not pause['exhausted'] and live_pause(pause) for pause in pauses.values())
690
+
691
+ def fall_back(name: str, info: dict, scope: str) -> None:
692
+ """--infra-fallback: a dwarf-stage capacity stop moves to the next healthy model of its
693
+ tier pool. The stop was already recorded as its scope's pause, so every paused or
694
+ exhausted pair is excluded and a pool all at capacity cannot bounce the task forever."""
695
+ if interrupted or graceful or fractal or _read_small(plan, 'infra_fallback') != 'on' or _pinned(plan, name):
696
+ return
697
+ if info.get('stage') != 'dwarf' or info.get('class') != 'rate_limit' or not scope.startswith('model:'):
698
+ return
699
+ excluded = {(info['harness'], info['model'])}
700
+ excluded.update((p['harness'], p['model'] or '*') for p in pauses.values())
701
+ command = ['/bin/bash', script, '_prepare_retry', str(plan), name, '--fallback',
702
+ ','.join(sorted(h + '/' + m for h, m in excluded))]
703
+ if subprocess.run(command).returncode:
704
+ return
705
+ pauses[scope]['tasks'].remove(name)
706
+ pairs_cache.pop(name, None)
707
+ pending.insert(0, name)
708
+
709
+ def write_paused(until_all: float | None = None) -> None:
710
+ """infra.paused for forge-ui and `forge control show`; gone when nothing is paused."""
711
+ nonlocal paused_text
712
+ scopes = []
713
+ if paused:
714
+ scopes.append({'scope': 'all', 'class': all_class or None, 'harness': None, 'model': None,
715
+ 'tasks': list(infra_seen), 'cycle': infra_cycles + (1 if until_all else 0),
716
+ 'until': until_all, 'exhausted': not until_all and infra_cycles >= infra_budget()})
717
+ for pause in pauses.values():
718
+ scopes.append({key: pause[key] for key in
719
+ ('scope', 'class', 'harness', 'model', 'tasks', 'cycle', 'until', 'exhausted')})
720
+ text = json.dumps({'scopes': scopes}) if scopes else None
721
+ if text == paused_text:
722
+ return
723
+ if text is None:
724
+ _remove(plan, 'infra.paused')
725
+ else:
726
+ _write_atomic(plan, text, 'infra.paused')
727
+ paused_text = text
728
+
729
+ def backoff() -> tuple:
730
+ """The plan's infra_backoff, read at every wait so a change applies to the next one."""
731
+ try:
732
+ fd = os.open(_resolve(plan, 'infra_backoff'), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
733
+ try:
734
+ text = os.read(fd, 4097).decode('ascii').strip()
735
+ finally:
736
+ os.close(fd)
737
+ except FileNotFoundError:
738
+ return default_backoff()
739
+ except (OSError, UnicodeDecodeError) as error:
740
+ text, problem = None, str(error)
741
+ else:
742
+ problem = None
743
+ if text is not None:
744
+ try:
745
+ return parse_backoff(text), text
746
+ except ValueError as error:
747
+ problem = str(error)
748
+ if not backoff_warned:
749
+ backoff_warned.append(problem)
750
+ _warn('infra_backoff ignored, using the default: ' + problem)
751
+ return default_backoff()
752
+
753
+ def start_wait(pause: dict, info: dict, name: str) -> None:
754
+ """Begin the scope's next wait, or mark it exhausted when its budget is spent."""
755
+ done, budget = scope_waits.get(pause['scope'], 0), infra_budget()
756
+ where = pause['harness'] + ('/' + pause['model'] if pause['model'] else '')
757
+ head = 'forge: infrastructure failure (' + pause['class'] + ') on ' + where + ' for ' + name
758
+ if done >= budget:
759
+ pause.update(cycle=done, until=None, exhausted=True)
760
+ print(head + '; its waits are used up (' + str(done) + ' of ' + str(budget)
761
+ + '), so it stays paused; other tasks continue (no attempt spent)', file=sys.stderr, flush=True)
762
+ return
763
+ wait, source = infra_wait(info, done, backoff())
764
+ pause.update(cycle=done + 1, until=time.time() + wait, exhausted=False)
765
+ print(head + '; pausing it, waiting ' + ('%g' % wait) + 's before resuming (' + source + ', wait '
766
+ + str(done + 1) + ' of ' + str(budget) + '); other tasks continue (no attempt spent)',
767
+ file=sys.stderr, flush=True)
768
+
769
+ def infra_stopped(name: str) -> None:
770
+ nonlocal paused, all_class
771
+ pairs_cache.pop(name, None)
772
+ info = infra_info(plan / 'tasks' / name)
773
+ scope, harness, model = infra_scope(info)
774
+ if scope == 'all':
775
+ paused = True
776
+ all_class = all_class or info.get('class', '')
777
+ if name not in infra_seen:
778
+ infra_seen.append(name)
779
+ elif scope in pauses:
780
+ # A stop in a scope already paused joins its wait and is not counted again.
781
+ if name not in pauses[scope]['tasks']:
782
+ pauses[scope]['tasks'].append(name)
783
+ else:
784
+ pauses[scope] = {'scope': scope, 'class': info.get('class', ''), 'harness': harness,
785
+ 'model': model if scope.startswith('model:') else None, 'tasks': [name],
786
+ 'cycle': 0, 'until': None, 'exhausted': False, 'info': info}
787
+ start_wait(pauses[scope], info, name)
788
+ if scope != 'all':
789
+ fall_back(name, info, scope)
790
+ write_paused()
791
+
792
+ def end_scoped_waits() -> None:
793
+ """Resume the tasks of every scope whose wait is over; an exhausted scope whose
794
+ budget was raised meanwhile starts its next wait."""
795
+ for scope, pause in list(pauses.items()):
796
+ if interrupted:
797
+ return
798
+ if pause['exhausted']:
799
+ if scope_waits.get(scope, 0) < infra_budget():
800
+ start_wait(pause, pause['info'], ', '.join(pause['tasks']))
801
+ continue
802
+ if time.time() < pause['until']:
803
+ continue
804
+ scope_waits[scope] = scope_waits.get(scope, 0) + 1
805
+ del pauses[scope]
806
+ for name in reversed(pause['tasks']):
807
+ pairs_cache.pop(name, None)
808
+ if name not in pending and status(name) == 'INFRA' and resume(name):
809
+ pending.insert(0, name)
810
+ print('forge: the pause on ' + scope.partition(':')[2] + ' is over'
811
+ + ('; resuming ' + ', '.join(pause['tasks']) + ' (no attempt spent)' if pause['tasks'] else ''),
812
+ file=sys.stderr, flush=True)
813
+ write_paused()
814
+
815
+ def apply_retry(request: dict) -> tuple:
816
+ name, dwarf = request['task'], request.get('dwarf')
817
+ if name not in tasks:
818
+ return 'refused', 'this run does not know task ' + name
819
+ if name in active:
820
+ return 'refused', 'the task is running'
821
+ if name in pending:
822
+ return 'refused', 'the task is already waiting to run'
823
+ state = status(name)
824
+ if state == 'MERGED':
825
+ return 'refused', 'the task is merged'
826
+ if state == 'INFRA':
827
+ return 'refused', 'an infrastructure stop is resumed by the scheduler itself'
828
+ if state not in RETRYABLE:
829
+ return 'refused', 'a ' + state + ' task is not retried'
830
+ command = ['/bin/bash', script, '_prepare_retry', str(plan), name] + (['--dwarf', dwarf] if dwarf else [])
831
+ rc = subprocess.run(command).returncode
832
+ if rc == 3:
833
+ try:
834
+ why = (plan / 'tasks' / name / 'split.suggested').read_text().strip()
835
+ except OSError:
836
+ why = ''
837
+ return 'refused', why or 'the retry policy stopped this task: split it or name a --dwarf'
838
+ if rc:
839
+ return 'refused', 'the retry could not be prepared (exit ' + str(rc) + '; see the run output)'
840
+ # A human's retry: no automatic budget is spent, and a stop marker the
841
+ # preparation cleared (an explicit --dwarf) no longer holds the task.
842
+ pairs_cache.pop(name, None)
843
+ refused.pop(name, None)
844
+ retried.add(name)
845
+ human.add(name)
846
+ pending.insert(0, name)
847
+ return 'applied', None
848
+
849
+ def apply_add(request: dict) -> tuple:
850
+ name = request['task']
851
+ if name in tasks:
852
+ return 'refused', 'the task is already in this run'
853
+ try:
854
+ rows = [line.split('\t') for line in (plan / 'tasks.tsv').read_text().splitlines()
855
+ if line and not line.startswith('#')]
856
+ except OSError as error:
857
+ return 'refused', 'tasks.tsv could not be read: ' + str(error)
858
+ if any(len(row) != 7 for row in rows):
859
+ return 'refused', 'tasks.tsv requires seven columns'
860
+ if len({row[0] for row in rows}) != len(rows):
861
+ return 'refused', 'tasks.tsv has duplicate task IDs'
862
+ found = [row for row in rows if row[0] == name]
863
+ if not found:
864
+ return 'refused', 'the row is not in tasks.tsv'
865
+ deps = [d for d in found[0][1].split(',') if d and d != '-']
866
+ unknown = [d for d in deps if d not in tasks]
867
+ if unknown:
868
+ # Every known task was read at start, so a dep among them cannot close a cycle.
869
+ return 'refused', 'it depends on task(s) this run does not know: ' + ', '.join(unknown)
870
+ root = plan / 'tasks' / name
871
+ try:
872
+ root.mkdir(parents=True, exist_ok=True)
873
+ if not stat.S_ISDIR(os.lstat(root).st_mode):
874
+ return 'refused', 'tasks/' + name + ' is not a real directory'
875
+ except OSError as error:
876
+ return 'refused', 'tasks/' + name + ' could not be created: ' + str(error)
877
+ tasks[name] = found[0]
878
+ pending.append(name)
879
+ if subprocess.run(['/bin/bash', script, '_waves', str(plan)]).returncode:
880
+ _warn('waves.tsv was not refreshed for ' + name + '; the task still runs')
881
+ return 'applied', None
882
+
883
+ def handle_requests() -> None:
884
+ """Apply or refuse each queued request; the outcome is logged under the control lock."""
885
+ for name in list_requests(plan):
886
+ request = read_request(plan, name)
887
+ if request is None:
888
+ outcome, reason = 'refused', 'not a valid request'
889
+ elif interrupted or graceful:
890
+ outcome, reason = 'refused', 'run is stopping'
891
+ elif request['verb'] == 'retry':
892
+ outcome, reason = apply_retry(request)
893
+ else:
894
+ outcome, reason = apply_add(request)
895
+ with _control_lock(plan):
896
+ settle_request(plan, name, request, outcome, reason)
897
+
898
+ def reap_exited(into: set[str] | None = None, *, allow_requeue: bool = True) -> None:
899
+ """Reap every active task whose process has already exited.
900
+
901
+ Merging is serial and can take long enough for another task to finish, so
902
+ repeat until a pass finds nothing. PASS is merged and a process that died
903
+ still RUNNING becomes ERROR. Once `interrupted` is set, every other status
904
+ is kept and nothing is requeued: the next `run --retry-failed` decides.
905
+ `finally` passes allow_requeue=False for the same reason. It also runs when
906
+ the loop raises, and a retry prepared on the way out would clear a recorded
907
+ failure that nothing is left to dispatch.
908
+ """
909
+ while True:
910
+ reaped = False
911
+ for name, (process, log) in list(active.items()):
912
+ if process.poll() is None:
913
+ continue
914
+ reaped = True
915
+ if into is not None:
916
+ into.add(name)
917
+ log.close()
918
+ del active[name]
919
+ if os.environ.get('FORGE_OUTPUT') == 'full':
920
+ echo_output(name)
921
+ state = status(name)
922
+ if state == 'PASS':
923
+ merge(name)
924
+ elif state == 'RUNNING':
925
+ mark(name, 'ERROR')
926
+ elif state == 'INFRA':
927
+ infra_stopped(name)
928
+ elif allow_requeue and not interrupted and not graceful \
929
+ and state in ('FAIL', 'UNKNOWN') and retryable(name):
930
+ requeue(name)
931
+ if not reaped:
932
+ return
933
+
360
934
  def stop(signum: int, frame: object) -> None:
361
935
  nonlocal interrupted
362
936
  interrupted = True
@@ -369,42 +943,38 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
369
943
  # Saved PASS results use the same fingerprint checks without paying for
370
944
  # implementation or QA again; an INFRA stop is not a failure of the work, so it
371
945
  # is simply dispatched again.
946
+ # RUNNING at start means a scheduler was killed without its cleanup: its tasks'
947
+ # harnesses may still be running and editing their worktrees.
948
+ stop_harnesses(plan, [name for name in pending if status(name) == 'RUNNING'])
372
949
  for name in pending[:]:
373
950
  state = status(name)
374
951
  if state == 'PASS':
375
952
  merge(name)
376
953
  if state == 'RUNNING':
377
954
  mark(name, 'INTERRUPTED')
378
- if state in ('FAIL', 'UNKNOWN') and retryable(name):
955
+ if state in ('FAIL', 'UNKNOWN') and not interrupted and retryable(name):
379
956
  requeue(name)
380
- if state == 'INFRA' and not resume(name):
957
+ if state == 'INFRA' and not interrupted and not resume(name):
381
958
  pending.remove(name)
382
959
  continue
383
960
  if status(name) not in ('PENDING', 'BLOCKED', 'INFRA'):
384
961
  pending.remove(name)
385
962
  swept = time.monotonic()
386
- while pending or active:
963
+ while pending or active or waiting_scopes():
964
+ # Reap before honouring the stop. A signal during the sleep, and a task
965
+ # that exits while another merge is still running, have already written
966
+ # a result; breaking first used to skip them and mark them INTERRUPTED.
967
+ ended = set()
968
+ # `stop --now` is SIGTERM by another route; a graceful stop lets the running finish.
969
+ mode = stop_mode(plan)
970
+ if mode == 'now':
971
+ interrupted = True
972
+ graceful = graceful or mode == 'graceful'
973
+ reap_exited(ended)
387
974
  if interrupted:
388
975
  break
389
- ended = set()
390
- for name, (process, log) in list(active.items()):
391
- if process.poll() is not None:
392
- ended.add(name)
393
- log.close()
394
- del active[name]
395
- if os.environ.get('FORGE_OUTPUT') == 'full':
396
- echo_output(name)
397
- state = status(name)
398
- if state == 'PASS':
399
- merge(name)
400
- elif state == 'RUNNING':
401
- mark(name, 'ERROR')
402
- elif state == 'INFRA':
403
- paused = True
404
- if name not in infra_seen:
405
- infra_seen.append(name)
406
- elif state in ('FAIL', 'UNKNOWN') and retryable(name):
407
- requeue(name)
976
+ draining = drained(plan)
977
+ drain_told = drain_told and draining
408
978
  if time.monotonic() - swept >= SWEEP_EVERY:
409
979
  # A human can raise a budget after a task already ended FAIL while others
410
980
  # still run. The initial pass's predicate, for tasks nothing else will look
@@ -413,37 +983,59 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
413
983
  for name in tasks:
414
984
  if name in pending or name in active or name in ended:
415
985
  continue
416
- if status(name) in ('FAIL', 'UNKNOWN') and retryable(name) \
417
- and refused.get(name) != budget(name):
986
+ if not interrupted and not graceful and status(name) in ('FAIL', 'UNKNOWN') \
987
+ and retryable(name) and refused.get(name) != budget(name):
418
988
  requeue(name)
419
- if paused and not active and infra_seen and infra_cycles < infra_budget():
989
+ if paused and not active and infra_seen and not graceful and infra_cycles < infra_budget():
420
990
  # Everything running has drained. Wait out the outage once for all the
421
991
  # tasks it stopped, then dispatch them again.
422
- wait = max(infra_wait(infra_info(plan / 'tasks' / n), infra_cycles) for n in infra_seen)
992
+ shape = backoff()
993
+ wait, source = max(infra_wait(infra_info(plan / 'tasks' / n), infra_cycles, shape) for n in infra_seen)
423
994
  infra_cycles += 1
424
995
  print('forge: infrastructure failure on ' + ', '.join(infra_seen)
425
- + '; waiting ' + ('%g' % wait) + 's before resuming (no attempt spent)',
996
+ + '; waiting ' + ('%g' % wait) + 's before resuming (' + source + ', wait '
997
+ + str(infra_cycles) + ' of ' + str(infra_budget()) + ') (no attempt spent)',
426
998
  file=sys.stderr, flush=True)
427
999
  resume_at = time.time() + wait
1000
+ write_paused(resume_at)
428
1001
  while time.time() < resume_at and not interrupted:
1002
+ # A stop of either kind ends the wait: nothing is active, so it ends the run.
1003
+ mode = stop_mode(plan)
1004
+ if mode:
1005
+ interrupted = interrupted or mode == 'now'
1006
+ graceful = graceful or mode == 'graceful'
1007
+ break
429
1008
  time.sleep(min(0.05, max(resume_at - time.time(), 0)))
430
- if interrupted:
1009
+ if interrupted or graceful:
1010
+ if graceful:
1011
+ print('forge: stopped by forge control stop; ' + str(len(pending) + len(infra_seen))
1012
+ + ' task(s) not started', file=sys.stderr, flush=True)
431
1013
  break
432
1014
  # A pin set before or during the wait applies to the entries that run the
433
1015
  # implementer again; a QA-stage stop keeps it for the next full retry.
434
1016
  for name in reversed(infra_seen):
1017
+ if interrupted:
1018
+ break
435
1019
  if name not in pending and resume(name):
436
1020
  pending.insert(0, name)
437
1021
  infra_seen.clear()
438
1022
  paused = False
1023
+ all_class = ''
1024
+ write_paused()
1025
+ end_scoped_waits()
1026
+ handle_requests()
1027
+ cap = live_capacity(plan, capacity)
1028
+ draining = drained(plan) # again: the infrastructure wait above can last minutes
439
1029
  for name in pending[:]:
440
- if paused or len(active) >= capacity or interrupted:
1030
+ if paused or len(active) >= cap or interrupted or graceful or draining:
441
1031
  break
442
1032
  deps = [d for d in tasks[name][1].split(',') if d and d != '-']
443
1033
  if any(status(dep) != 'MERGED' for dep in deps):
444
1034
  continue
445
1035
  if any(overlap(tasks[name][3], tasks[other][3]) for other in active):
446
1036
  continue
1037
+ if pauses and scope_held(name):
1038
+ continue
447
1039
  root = plan / 'tasks' / name
448
1040
  if not (root / 'base_ref').exists():
449
1041
  worktree = Path((plan / 'wt_root').read_text().strip()) / '_integration'
@@ -452,8 +1044,10 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
452
1044
  log = open(root / 'task.out', 'ab')
453
1045
  # A retried task runs with the same environment `retry` gives it, so the
454
1046
  # no-progress guard (byte-identical diff) applies under Fractal too.
455
- env = (dict(os.environ, FORGE_FRACTAL_RETRY='1', FORGE_DATASET_TRIGGER='auto')
1047
+ env = (dict(os.environ, FORGE_FRACTAL_RETRY='1',
1048
+ FORGE_DATASET_TRIGGER='human' if name in human else 'auto')
456
1049
  if name in retried else None)
1050
+ human.discard(name)
457
1051
  process = subprocess.Popen(
458
1052
  ['/bin/bash', script, '_task', str(plan), name],
459
1053
  stdout=log, stderr=subprocess.STDOUT, start_new_session=True, env=env,
@@ -461,8 +1055,18 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
461
1055
  active[name] = process, log
462
1056
  pending.remove(name)
463
1057
  print('forge: dispatched ' + name + ' -> ' + str(root), file=sys.stderr, flush=True)
464
- if not active:
465
- if paused or held:
1058
+ if not active and graceful:
1059
+ # Not BLOCKED: a stop is not a dependency failure, so the next run starts them.
1060
+ print('forge: stopped by forge control stop; ' + str(len(pending)) + ' task(s) not started',
1061
+ file=sys.stderr, flush=True)
1062
+ break
1063
+ if not active and draining and (pending or waiting_scopes()):
1064
+ if not drain_told:
1065
+ drain_told = True
1066
+ print('forge: drained — ' + str(len(pending)) + ' task(s) waiting; forge control resume '
1067
+ + str(plan) + ' continues', file=sys.stderr, flush=True)
1068
+ elif not active and not waiting_scopes():
1069
+ if paused or held or any(live_pause(pause) for pause in pauses.values()):
466
1070
  # Infrastructure stop: leave everything not yet run PENDING. Marking it
467
1071
  # BLOCKED would turn "the provider was down" into a recorded failure.
468
1072
  # A task held back by its pin is still an infrastructure stop.
@@ -474,33 +1078,66 @@ def schedule(script: str, plan: str, capacity: int, retry_failed: int = 0,
474
1078
  finally:
475
1079
  # Not live from here on: a late `forge control` must not edit budgets nobody reads.
476
1080
  live_stop(plan, tasks)
477
- for process, log in active.values():
1081
+ # Poll before any signal. A process that has already exited keeps the result
1082
+ # it wrote (PASS is merged); only a process that is still alive is stopped.
1083
+ # Never requeue here: this block also runs when the loop raises, and a retry
1084
+ # prepared then would erase the recorded failure and spend its budget.
1085
+ reap_exited(allow_requeue=False)
1086
+ stopping = list(active.items())
1087
+ for name, (process, log) in stopping:
478
1088
  try:
479
1089
  os.killpg(process.pid, signal.SIGTERM)
480
- except ProcessLookupError:
1090
+ except OSError:
481
1091
  pass
482
- for name, (process, log) in active.items():
1092
+ # The group outlives its leader while dispatch's EXIT trap stops the harness, so wait
1093
+ # for the group to empty, not for the leader. Reaping the leader matters too: its
1094
+ # zombie would keep the group alive.
1095
+ deadline = time.monotonic() + interrupt_grace()
1096
+ waiting = {name for name, _ in stopping}
1097
+ while waiting:
1098
+ for name, (process, log) in stopping:
1099
+ if name in waiting:
1100
+ process.poll()
1101
+ if not _group_alive(process.pid):
1102
+ waiting.discard(name)
1103
+ if not waiting or time.monotonic() >= deadline:
1104
+ break
1105
+ time.sleep(0.05)
1106
+ for name, (process, log) in stopping:
1107
+ if name in waiting:
1108
+ try:
1109
+ os.killpg(process.pid, signal.SIGKILL)
1110
+ except OSError:
1111
+ pass
483
1112
  try:
484
1113
  process.wait(timeout=5)
485
1114
  except subprocess.TimeoutExpired:
486
1115
  pass
487
- # Remove descendants even if the shell exited before its backend.
488
- try:
489
- os.killpg(process.pid, signal.SIGKILL)
490
- except ProcessLookupError:
491
- pass
492
- process.wait()
493
1116
  log.close()
494
- mark(name, 'INTERRUPTED')
495
- if interrupted:
1117
+ stop_harnesses(plan, [name for name, _ in stopping])
1118
+ for name, _ in stopping:
1119
+ try:
1120
+ mark(name, 'INTERRUPTED')
1121
+ except OSError as error:
1122
+ _warn('could not mark ' + name + ' INTERRUPTED: ' + str(error))
1123
+ if interrupted or (graceful and not all(status(name) == 'MERGED' for name in tasks)):
496
1124
  return 130
497
- if paused or any(status(name) == 'INFRA' for name in tasks):
1125
+ if paused or any(live_pause(pause) for pause in pauses.values()) \
1126
+ or any(status(name) == 'INFRA' for name in tasks):
498
1127
  return INFRA_EXIT
499
1128
  return 0 if all(status(name) == 'MERGED' for name in tasks) else 5
500
1129
 
501
1130
 
502
1131
  def main() -> None:
503
1132
  try:
1133
+ if sys.argv[1] == 'backoff-check':
1134
+ # The --infra-backoff flag's validation: one parser for every reader of the shape.
1135
+ try:
1136
+ parse_backoff(sys.argv[2] if len(sys.argv) > 2 else '')
1137
+ except ValueError as error:
1138
+ print('forge: --infra-backoff: ' + str(error), file=sys.stderr)
1139
+ sys.exit(2)
1140
+ sys.exit(0)
504
1141
  if sys.argv[1] == 'lock':
505
1142
  _, script, command, plan, *args = sys.argv[1:]
506
1143
  # The shell inherits the lock for its entire operation, including