@giamme/forge 0.11.0 → 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.
- package/CHANGELOG.md +47 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +46 -12
- package/docs/commands.md +48 -9
- package/docs/decomposed-runs.md +43 -2
- package/docs/forge-ui.md +27 -6
- package/docs/reviews.md +31 -6
- package/package.json +1 -1
- package/references/dataset.md +3 -1
- package/references/decompose.md +159 -17
- package/references/harnesses.md +13 -1
- package/references/ui.md +14 -4
- package/scripts/forge-control.py +268 -25
- package/scripts/forge-dispatch.sh +80 -3
- package/scripts/forge-guard.py +156 -15
- package/scripts/forge-issues.py +1 -1
- package/scripts/forge-lib-plans.sh +9 -3
- package/scripts/forge-lib-review.sh +22 -3
- package/scripts/forge-lib-task.sh +51 -4
- package/scripts/forge-lib-verify.sh +137 -6
- package/scripts/forge-parallel-options.sh +59 -0
- package/scripts/forge-parallel.sh +212 -28
- package/scripts/forge-ripwire.py +1 -1
- package/scripts/forge-route.py +157 -5
- package/scripts/forge-schedule.py +687 -50
- package/scripts/forge-ui.py +8 -4
- package/scripts/forge-verify.py +878 -0
- package/scripts/forge_dataset/forecast.py +29 -4
- package/scripts/forge_dataset/harvest.py +10 -4
- package/scripts/forge_verify_reports.py +225 -0
- package/ui/README.md +19 -4
- package/ui/docs/api.md +139 -25
- package/ui/forge_ui/control.py +26 -7
- package/ui/forge_ui/export.py +20 -7
- package/ui/forge_ui/reader.py +307 -11
- package/ui/forge_ui/server.py +16 -0
- package/ui/forge_ui/static/app.js +2 -1
- package/ui/forge_ui/static/controls.js +217 -33
- package/ui/forge_ui/static/findingsview.js +37 -12
- package/ui/forge_ui/static/gantt.js +14 -9
- package/ui/forge_ui/static/index.html +1 -0
- package/ui/forge_ui/static/panel.js +2 -1
- package/ui/forge_ui/static/runmodel.js +92 -11
- package/ui/forge_ui/static/scene3d.js +9 -5
- package/ui/forge_ui/static/settings.js +48 -12
- package/ui/forge_ui/static/style.css +60 -0
- 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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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')
|
|
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
|
-
|
|
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 (
|
|
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) >=
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1090
|
+
except OSError:
|
|
481
1091
|
pass
|
|
482
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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(
|
|
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
|