outerloop-science 0.1.0.dev1__py3-none-any.whl → 0.1.0.dev2__py3-none-any.whl
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.
- outerloop/__init__.py +7 -7
- outerloop/appauth.py +3 -3
- outerloop/appmanifest.py +1 -1
- outerloop/attempt.py +55 -49
- outerloop/cli.py +150 -74
- outerloop/compute.py +59 -9
- outerloop/contract.py +3 -2
- outerloop/followup.py +14 -17
- outerloop/github.py +4 -4
- outerloop/harness.py +18 -7
- outerloop/image.py +372 -0
- outerloop/init.py +229 -14
- outerloop/limits.py +1 -1
- outerloop/measure.py +2 -2
- outerloop/orchestrator.py +1 -1
- outerloop/paths.py +2 -1
- outerloop/review.py +1 -1
- outerloop/steward.py +3 -3
- outerloop/syscall.py +1 -0
- outerloop/tick.py +302 -77
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/METADATA +2 -2
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/RECORD +26 -25
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/NOTICE +0 -0
outerloop/tick.py
CHANGED
|
@@ -36,6 +36,7 @@ from outerloop.compute import (
|
|
|
36
36
|
SlurmError,
|
|
37
37
|
SlurmQueryError,
|
|
38
38
|
compute_from_env,
|
|
39
|
+
gpus_in_gres,
|
|
39
40
|
is_pending,
|
|
40
41
|
is_terminal,
|
|
41
42
|
local_mode,
|
|
@@ -74,7 +75,7 @@ log = logging.getLogger(__name__)
|
|
|
74
75
|
PAUSE_SENTINEL = "PAUSE"
|
|
75
76
|
# Operator on-switch for dispatched-wake, mirroring PAUSE: a root-relative
|
|
76
77
|
# sentinel an operator arms/disarms with a touch/rm — no chain restart, no
|
|
77
|
-
# env-var surgery on a live tick. The
|
|
78
|
+
# env-var surgery on a live tick. The OUTERLOOP_DISPATCH_WAKE env var still
|
|
78
79
|
# works too (either arms it); the sentinel is the reversible, restart-free path.
|
|
79
80
|
DISPATCH_WAKE_SENTINEL = "DISPATCH_WAKE"
|
|
80
81
|
HEARTBEAT_NAME = "heartbeat.json"
|
|
@@ -88,6 +89,34 @@ DEFAULT_GRACE_S = 15 * 60
|
|
|
88
89
|
# a blind park (no job ids to poll) waits its eval walltime plus this queue
|
|
89
90
|
# slack before a follow-up is sent to look for the result
|
|
90
91
|
BLIND_PARK_SLACK_MIN = 12 * 60
|
|
92
|
+
|
|
93
|
+
# Slurm pending reasons that mean "the job will run when its turn comes": the
|
|
94
|
+
# queue is busy, a reservation or dependency is ahead of it, or the account's
|
|
95
|
+
# OWN jobs hold a per-user/per-account/group cap that clears as they finish. A
|
|
96
|
+
# job in this state keeps accruing priority age; cancelling and resubmitting
|
|
97
|
+
# would put it at the back of the queue behind itself. Per-job limits
|
|
98
|
+
# (`...PerJob...`), a dependency that can never be satisfied, an invalid
|
|
99
|
+
# account/QOS or a held job are NOT waits: those never clear on their own.
|
|
100
|
+
# JobHeldUser is the kernel's own launch admission (service_admission): a
|
|
101
|
+
# held launch waits for a GPU slot, and an operator's hold is theirs to lift.
|
|
102
|
+
QUEUE_WAIT_REASONS = frozenset(
|
|
103
|
+
{"Priority", "Resources", "Reservation", "Dependency", "ReqNodeNotAvail", "JobHeldUser"}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_queue_wait(reason: str) -> bool:
|
|
108
|
+
"""Whether a squeue pending reason describes a healthy wait (see
|
|
109
|
+
QUEUE_WAIT_REASONS). Reasons arrive as `Name` or `Name, detail...`."""
|
|
110
|
+
head = reason.strip().split(",")[0].split(" ")[0].strip()
|
|
111
|
+
if not head:
|
|
112
|
+
return False
|
|
113
|
+
if head in QUEUE_WAIT_REASONS:
|
|
114
|
+
return True
|
|
115
|
+
if "PerJob" in head:
|
|
116
|
+
return False
|
|
117
|
+
return any(tag in head for tag in ("PerUser", "PerAccount", "Grp"))
|
|
118
|
+
|
|
119
|
+
|
|
91
120
|
# A held lease is stale after the session timeout plus slack.
|
|
92
121
|
DEFAULT_LEASE_TTL_S = 3600 + 15 * 60
|
|
93
122
|
# Coalesce guard: skip a tick's work if another ran within this window. Under
|
|
@@ -95,7 +124,7 @@ DEFAULT_LEASE_TTL_S = 3600 + 15 * 60
|
|
|
95
124
|
# (serialized by the singleton dependency), so they would run back-to-back and
|
|
96
125
|
# redundantly re-sweep. The chain schedules ticks a full cadence apart by
|
|
97
126
|
# begin-time, so only late-bunched pile-ups fall inside this window; keep it
|
|
98
|
-
# well BELOW the cadence (default 30 min). 0 disables. Env:
|
|
127
|
+
# well BELOW the cadence (default 30 min). 0 disables. Env: OUTERLOOP_MIN_TICK_MINUTES.
|
|
99
128
|
DEFAULT_MIN_TICK_S = 10 * 60
|
|
100
129
|
# Ceiling for the coalesce window: a value above this is almost certainly a typo
|
|
101
130
|
# (a window near/over the cadence would coalesce every on-cadence tick and stall
|
|
@@ -154,8 +183,8 @@ class TickReport:
|
|
|
154
183
|
# The submitted walltime must never exceed the job partition's MaxTime —
|
|
155
184
|
# sbatch REJECTS a longer request outright, which would ground every climb.
|
|
156
185
|
# The DEFAULT matches cpu_short (6 h); an operator moving work jobs to a
|
|
157
|
-
# longer partition (
|
|
158
|
-
#
|
|
186
|
+
# longer partition (OUTERLOOP_JOB_PARTITION=cpu48) raises the cap with
|
|
187
|
+
# OUTERLOOP_MAX_JOB_MINUTES. Code-side ceiling: the cap must stay under
|
|
159
188
|
# STRANDED_IMPLEMENTING_S or the picker declares live runs stranded — jobs
|
|
160
189
|
# longer than 10 h need that window made spec-aware first (named gap). The
|
|
161
190
|
# self-deadline arms at the CLAMPED value, so a job that wanted more time
|
|
@@ -181,12 +210,12 @@ class FollowupSpec:
|
|
|
181
210
|
partition: str
|
|
182
211
|
run_root: Path
|
|
183
212
|
image: str
|
|
184
|
-
home: Path #
|
|
213
|
+
home: Path # OUTERLOOP_HOME: cwd for the submitted job
|
|
185
214
|
bot_login: str = field(default_factory=_bot_login_default)
|
|
186
215
|
time_minutes: int = 90 # min()'d with the contract's followup_job_minutes
|
|
187
216
|
max_turns: int = DEFAULT_MAX_TURNS # session turn budget for follow-up jobs
|
|
188
217
|
pat_file: str = "" # forwarded to the job; "" = the followup CLI default
|
|
189
|
-
# GitHub App config path; jobs inherit
|
|
218
|
+
# GitHub App config path; jobs inherit OUTERLOOP_GITHUB_APP_FILE from
|
|
190
219
|
# the tick environment, so it is never threaded through argv
|
|
191
220
|
github_app_file: str = ""
|
|
192
221
|
target: str = "" # the repo the intake pass scans for requested-lane issues
|
|
@@ -194,7 +223,7 @@ class FollowupSpec:
|
|
|
194
223
|
# until the operator provisions it
|
|
195
224
|
steward_key_file: str = ""
|
|
196
225
|
# Pre-PR verification panel for climb jobs (docs/design/orchestrator-verify.md).
|
|
197
|
-
# DEFAULT ON — the flip is code, the off-switch is
|
|
226
|
+
# DEFAULT ON — the flip is code, the off-switch is OUTERLOOP_PANEL="".
|
|
198
227
|
# The climb CLI fails LOUDLY on a bad panel config (a configured gate must
|
|
199
228
|
# never silently vanish); the tick preflights the same rules — lens
|
|
200
229
|
# grammar AND key file — before claiming or submitting so nothing is
|
|
@@ -215,6 +244,11 @@ class FollowupSpec:
|
|
|
215
244
|
# are minutes, work jobs can be hours, and Slurm prices walltime into
|
|
216
245
|
# scheduling priority, so the two deserve independent placement.
|
|
217
246
|
job_partition: str = ""
|
|
247
|
+
# Launch admission: the most GPUs this user's jobs may hold at once
|
|
248
|
+
# (OUTERLOOP_MAX_LAUNCH_GPUS, e.g. the QOS's MaxTRESPerUser). 0 = off:
|
|
249
|
+
# launches queue as submitted. On, GPU launches are submitted held and
|
|
250
|
+
# service_admission releases them oldest-first under the cap.
|
|
251
|
+
max_launch_gpus: int = 0
|
|
218
252
|
# Partition MaxTime for work jobs — the panel-augmented walltime clamps
|
|
219
253
|
# here (see MAX_ATTEMPT_JOB_MINUTES). Raise together with job_partition.
|
|
220
254
|
max_job_minutes: int = MAX_ATTEMPT_JOB_MINUTES
|
|
@@ -286,7 +320,7 @@ def _gpu_lane_error(contract: Any, benchmark: str, spec: FollowupSpec) -> str:
|
|
|
286
320
|
if gpu_benches:
|
|
287
321
|
return (
|
|
288
322
|
f"contract has GPU benchmarks ({', '.join(gpu_benches)}) but no GPU lane is "
|
|
289
|
-
"configured (set
|
|
323
|
+
"configured (set OUTERLOOP_GPU_PARTITION)"
|
|
290
324
|
)
|
|
291
325
|
return ""
|
|
292
326
|
|
|
@@ -961,7 +995,7 @@ def dispatch_wake_armed(root: Path) -> bool:
|
|
|
961
995
|
sentinel file (touch/rm, no chain restart). Read by the tick and by every
|
|
962
996
|
park, so a disarm takes effect at once."""
|
|
963
997
|
return (
|
|
964
|
-
bool(os.environ.get("
|
|
998
|
+
bool(os.environ.get("OUTERLOOP_DISPATCH_WAKE", "").strip())
|
|
965
999
|
or (root / DISPATCH_WAKE_SENTINEL).exists()
|
|
966
1000
|
)
|
|
967
1001
|
|
|
@@ -1612,6 +1646,32 @@ def _sweep_one(
|
|
|
1612
1646
|
if now - record.terminal_seen >= grace_s:
|
|
1613
1647
|
wake(record, f"experiment {state}", state)
|
|
1614
1648
|
elif any(is_pending(s) for s in states) and record.deadline > 0 and now > record.deadline:
|
|
1649
|
+
# Past the deadline with a job still queued. Ask Slurm WHY before
|
|
1650
|
+
# calling it unschedulable: a busy queue or the account's own cap is
|
|
1651
|
+
# a wait a scientist would sit out, so the deadline moves out by one
|
|
1652
|
+
# slack window instead (Torch 2026-09-06: four launches pending on
|
|
1653
|
+
# QOSMaxGRESPerUser were about to be cancelled and re-launched into
|
|
1654
|
+
# the same cap). Local compute has no queue and no reasons.
|
|
1655
|
+
pending = [j for j, s in zip(job_ids, states, strict=True) if is_pending(s)]
|
|
1656
|
+
reason_of = getattr(compute, "pending_reason", None)
|
|
1657
|
+
reasons: dict[str, str] = {}
|
|
1658
|
+
if reason_of is not None:
|
|
1659
|
+
try:
|
|
1660
|
+
reasons = {j: str(reason_of(j)) for j in pending}
|
|
1661
|
+
except SlurmQueryError:
|
|
1662
|
+
deferred.append(record.run_id) # unknown is never "cancel"
|
|
1663
|
+
return
|
|
1664
|
+
if reasons and all(is_queue_wait(r) for r in reasons.values()):
|
|
1665
|
+
extended = now + BLIND_PARK_SLACK_MIN * 60
|
|
1666
|
+
if not dry_run:
|
|
1667
|
+
save_record(root, replace(record, deadline=extended), now)
|
|
1668
|
+
log.info(
|
|
1669
|
+
"sweep: %s waits in the queue (%s); deadline extended by %d min",
|
|
1670
|
+
record.run_id,
|
|
1671
|
+
", ".join(f"{j}={r}" for j, r in reasons.items()),
|
|
1672
|
+
BLIND_PARK_SLACK_MIN,
|
|
1673
|
+
)
|
|
1674
|
+
return
|
|
1615
1675
|
# Unschedulable in practice: cancel every non-terminal job
|
|
1616
1676
|
# (best-effort — scancel trouble must not abort the sweep), then
|
|
1617
1677
|
# wake with that fact.
|
|
@@ -1803,6 +1863,12 @@ def tick(
|
|
|
1803
1863
|
allow_submit=launch_ok,
|
|
1804
1864
|
contract=contract,
|
|
1805
1865
|
)
|
|
1866
|
+
# ended runs are known now, so a held launch of an ended run is dropped
|
|
1867
|
+
# and a GPU slot freed by a finished eval is handed to the oldest held one
|
|
1868
|
+
try:
|
|
1869
|
+
service_admission(root, compute, spec, dry_run=followup_dry_run)
|
|
1870
|
+
except Exception as exc: # admission is advisory to the tick
|
|
1871
|
+
log.warning("admission service failed: %s", exc)
|
|
1806
1872
|
try:
|
|
1807
1873
|
service_research_log(root, github, spec, now)
|
|
1808
1874
|
except Exception as exc: # the ledger is advisory; the tick continues
|
|
@@ -1854,6 +1920,120 @@ def tick(
|
|
|
1854
1920
|
return report
|
|
1855
1921
|
|
|
1856
1922
|
|
|
1923
|
+
LAUNCH_MARK = "-launch-" # every author launch job is named <run_id>-launch-<name>
|
|
1924
|
+
|
|
1925
|
+
|
|
1926
|
+
@dataclass(frozen=True)
|
|
1927
|
+
class AdmissionReport:
|
|
1928
|
+
released: tuple[str, ...] = () # job ids let into the queue this tick
|
|
1929
|
+
dropped: tuple[str, ...] = () # held launches of runs that already ended
|
|
1930
|
+
held: int = 0 # still waiting after this tick
|
|
1931
|
+
gpus_in_use: int = 0 # GPUs held by this user's eligible jobs, after releases
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _cap_reason(reason: str) -> bool:
|
|
1935
|
+
head = reason.strip().split(",")[0].split(" ")[0]
|
|
1936
|
+
return any(tag in head for tag in ("PerUser", "PerAccount", "Grp")) and "PerJob" not in head
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def service_admission(
|
|
1940
|
+
root: Path, compute: Compute, spec: FollowupSpec, *, dry_run: bool = False
|
|
1941
|
+
) -> AdmissionReport:
|
|
1942
|
+
"""Launch admission (Torch 2026-09-06: six 8-GPU launches queued against a
|
|
1943
|
+
16-GPU per-user cap, four of them pending for a day on QOSMaxGRESPerUser).
|
|
1944
|
+
Author launches are submitted HELD when a cap is configured; every tick
|
|
1945
|
+
this releases them oldest-first while the user's eligible GPU jobs (evals
|
|
1946
|
+
and launches alike) fit under the cap, and cancels held launches whose run
|
|
1947
|
+
has already ended. A released launch that Slurm then parks on a per-user
|
|
1948
|
+
reason means the configured cap is too high: nothing more is released
|
|
1949
|
+
that tick. Off (cap 0) and in local mode this does nothing. A failed
|
|
1950
|
+
queue read releases nothing (blind is never "free")."""
|
|
1951
|
+
if local_mode():
|
|
1952
|
+
return AdmissionReport() # local jobs are synchronous; nothing is ever held
|
|
1953
|
+
try:
|
|
1954
|
+
rows = compute.queue_snapshot()
|
|
1955
|
+
except SlurmQueryError as exc:
|
|
1956
|
+
log.warning("admission: cannot read the queue (%s); releasing nothing", exc)
|
|
1957
|
+
return AdmissionReport()
|
|
1958
|
+
# Only the kernel's own LAUNCH jobs are ever released or cancelled here,
|
|
1959
|
+
# attributed by JOB ID through each record's launch ids (a candidate park's
|
|
1960
|
+
# afterany also carries the gate's evals, which are never launches). Names
|
|
1961
|
+
# are never parsed: they are cut to 60 characters at submission, and
|
|
1962
|
+
# `squeue --me` lists every job of the Unix user, ours or not. A held job
|
|
1963
|
+
# no record claims is left exactly as it is.
|
|
1964
|
+
from outerloop.attempt import _stage_launch_job_ids
|
|
1965
|
+
|
|
1966
|
+
owner_state: dict[str, str] = {}
|
|
1967
|
+
for record in list_runs(root):
|
|
1968
|
+
for jid in _stage_launch_job_ids(record):
|
|
1969
|
+
owner_state[jid] = record.state
|
|
1970
|
+
held: list[dict[str, str]] = []
|
|
1971
|
+
in_use = 0
|
|
1972
|
+
at_cap = False
|
|
1973
|
+
dropped: list[str] = []
|
|
1974
|
+
for row in rows:
|
|
1975
|
+
state, reason, jid = row.get("state", ""), row.get("reason", ""), row.get("id", "")
|
|
1976
|
+
gpus = gpus_in_gres(row.get("gres", ""))
|
|
1977
|
+
owner = owner_state.get(jid)
|
|
1978
|
+
if is_pending(state) and reason.startswith("JobHeldUser"):
|
|
1979
|
+
if owner is None:
|
|
1980
|
+
continue # not ours, or not a launch: never touched
|
|
1981
|
+
if owner != WAITING:
|
|
1982
|
+
# the run ended (PR landed, abandoned, stuck) before its
|
|
1983
|
+
# launch ever ran: nothing will read the result
|
|
1984
|
+
if not dry_run:
|
|
1985
|
+
with contextlib.suppress(Exception):
|
|
1986
|
+
compute.cancel(jid)
|
|
1987
|
+
dropped.append(jid)
|
|
1988
|
+
continue
|
|
1989
|
+
held.append(row)
|
|
1990
|
+
continue
|
|
1991
|
+
# every GPU job that is not finished counts against the cap, including
|
|
1992
|
+
# one still COMPLETING: Slurm holds its allocation until it exits
|
|
1993
|
+
if gpus and not is_terminal(state) and state != GONE:
|
|
1994
|
+
in_use += gpus
|
|
1995
|
+
if is_pending(state) and owner is not None and _cap_reason(reason):
|
|
1996
|
+
at_cap = True
|
|
1997
|
+
held.sort(key=lambda r: int(r["id"]) if r["id"].isdigit() else 0)
|
|
1998
|
+
released: list[str] = []
|
|
1999
|
+
if spec.max_launch_gpus <= 0:
|
|
2000
|
+
# admission switched off after these were submitted held: let every
|
|
2001
|
+
# claimed launch go, or they would wait as JobHeldUser forever
|
|
2002
|
+
for row in held:
|
|
2003
|
+
if not dry_run:
|
|
2004
|
+
compute.release(row["id"])
|
|
2005
|
+
released.append(row["id"])
|
|
2006
|
+
elif at_cap:
|
|
2007
|
+
log.info(
|
|
2008
|
+
"admission: a released launch waits on a per-user cap; %d held launch(es) stay held",
|
|
2009
|
+
len(held),
|
|
2010
|
+
)
|
|
2011
|
+
else:
|
|
2012
|
+
for row in held:
|
|
2013
|
+
gpus = gpus_in_gres(row.get("gres", ""))
|
|
2014
|
+
if in_use + gpus > spec.max_launch_gpus:
|
|
2015
|
+
break
|
|
2016
|
+
if not dry_run:
|
|
2017
|
+
compute.release(row["id"])
|
|
2018
|
+
released.append(row["id"])
|
|
2019
|
+
in_use += gpus
|
|
2020
|
+
if released or dropped:
|
|
2021
|
+
log.info(
|
|
2022
|
+
"admission: released %s; dropped %s; %d still held; %d/%d GPUs in use",
|
|
2023
|
+
",".join(released) or "-",
|
|
2024
|
+
",".join(dropped) or "-",
|
|
2025
|
+
len(held) - len(released),
|
|
2026
|
+
in_use,
|
|
2027
|
+
spec.max_launch_gpus,
|
|
2028
|
+
)
|
|
2029
|
+
return AdmissionReport(
|
|
2030
|
+
released=tuple(released),
|
|
2031
|
+
dropped=tuple(dropped),
|
|
2032
|
+
held=len(held) - len(released),
|
|
2033
|
+
gpus_in_use=in_use,
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
|
|
1857
2037
|
def service_syncs(root: Path, spec: Any, now: float) -> None:
|
|
1858
2038
|
"""Honor mid-leg sync requests: a LIVE session asked for fresh origin/*
|
|
1859
2039
|
refs and is waiting inside its own clock. The fetch pins the canonical
|
|
@@ -2171,13 +2351,13 @@ def _climb_panel_argv(spec: FollowupSpec) -> list[str]:
|
|
|
2171
2351
|
def _author_config_error(spec: FollowupSpec) -> str:
|
|
2172
2352
|
"""Why the config-driven author would die at the climb's startup ("" when it
|
|
2173
2353
|
won't), checked on the tick host BEFORE a claim/submit so a codex misconfig
|
|
2174
|
-
(e.g.
|
|
2354
|
+
(e.g. OUTERLOOP_AUTHOR_BACKEND=codex with no non-claude model) never
|
|
2175
2355
|
strands a claimed intake issue. Reads the fleet author config from env — the
|
|
2176
2356
|
same source the climb defaults from — and the image the tick already knows."""
|
|
2177
2357
|
from outerloop.attempt import codex_author_config_error
|
|
2178
2358
|
|
|
2179
|
-
backend = os.environ.get("
|
|
2180
|
-
model = os.environ.get("
|
|
2359
|
+
backend = os.environ.get("OUTERLOOP_AUTHOR_BACKEND") or "claude"
|
|
2360
|
+
model = os.environ.get("OUTERLOOP_AUTHOR_MODEL") or "claude-opus-5"
|
|
2181
2361
|
return codex_author_config_error(backend, model, spec.image)
|
|
2182
2362
|
|
|
2183
2363
|
|
|
@@ -2208,8 +2388,8 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
|
|
|
2208
2388
|
# neither the author's nor the claude panel key + readable), and for
|
|
2209
2389
|
# hermes its pinned clone.
|
|
2210
2390
|
shelled = {
|
|
2211
|
-
"codex": "
|
|
2212
|
-
"hermes": "
|
|
2391
|
+
"codex": "OUTERLOOP_PANEL_CODEX_KEY_FILE",
|
|
2392
|
+
"hermes": "OUTERLOOP_PANEL_HERMES_KEY_FILE",
|
|
2213
2393
|
}
|
|
2214
2394
|
for lens_backend, key_env in shelled.items():
|
|
2215
2395
|
if not any(backend == lens_backend for _, backend, _ in lenses):
|
|
@@ -2217,7 +2397,7 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
|
|
|
2217
2397
|
if not spec.image or not Path(spec.image).is_file():
|
|
2218
2398
|
return (
|
|
2219
2399
|
f"a {lens_backend} panel lens requires a real container image "
|
|
2220
|
-
f"(
|
|
2400
|
+
f"(OUTERLOOP_IMAGE={spec.image!r})"
|
|
2221
2401
|
)
|
|
2222
2402
|
key_raw = os.environ.get(key_env, "").strip()
|
|
2223
2403
|
if not key_raw:
|
|
@@ -2273,7 +2453,7 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
|
|
|
2273
2453
|
# (claude vs codex keys coexist), config-driven like the climb itself — so
|
|
2274
2454
|
# the role-separation check compares the panel key against the RIGHT author
|
|
2275
2455
|
# key, and a codex run is never judged by a stray Claude key.
|
|
2276
|
-
fleet_backend = os.environ.get("
|
|
2456
|
+
fleet_backend = os.environ.get("OUTERLOOP_AUTHOR_BACKEND") or "claude"
|
|
2277
2457
|
author = Path(resolve_author_key_file(fleet_backend))
|
|
2278
2458
|
if not author.is_absolute():
|
|
2279
2459
|
# same rule as the panel key: the climb resolves paths from a
|
|
@@ -2462,7 +2642,7 @@ def service_self_initiated(
|
|
|
2462
2642
|
# that would publish panel-less self-merging PRs (terra #171)
|
|
2463
2643
|
log.error(
|
|
2464
2644
|
"attempt on %s not launched: contract sets merge:auto but "
|
|
2465
|
-
"no panel is configured (set
|
|
2645
|
+
"no panel is configured (set OUTERLOOP_PANEL, or the "
|
|
2466
2646
|
"contract back to merge:manual)",
|
|
2467
2647
|
benchmark,
|
|
2468
2648
|
)
|
|
@@ -2474,7 +2654,7 @@ def service_self_initiated(
|
|
|
2474
2654
|
if author_error:
|
|
2475
2655
|
log.error(
|
|
2476
2656
|
"climb on %s not launched: author misconfigured — %s "
|
|
2477
|
-
"(fix
|
|
2657
|
+
"(fix OUTERLOOP_AUTHOR_BACKEND/_MODEL)",
|
|
2478
2658
|
benchmark,
|
|
2479
2659
|
author_error,
|
|
2480
2660
|
)
|
|
@@ -2483,7 +2663,7 @@ def service_self_initiated(
|
|
|
2483
2663
|
if panel_error:
|
|
2484
2664
|
log.error(
|
|
2485
2665
|
"climb on %s not launched: panel misconfigured — %s "
|
|
2486
|
-
"(fix it, or set
|
|
2666
|
+
"(fix it, or set OUTERLOOP_PANEL='' to disable the panel)",
|
|
2487
2667
|
benchmark,
|
|
2488
2668
|
panel_error,
|
|
2489
2669
|
)
|
|
@@ -2510,7 +2690,7 @@ def service_self_initiated(
|
|
|
2510
2690
|
if spec.pat_file:
|
|
2511
2691
|
argv += ["--pat-file", spec.pat_file]
|
|
2512
2692
|
# config-driven author: climb resolves the author backend/model/key from
|
|
2513
|
-
#
|
|
2693
|
+
# OUTERLOOP_AUTHOR_* env (inherited by the job), so the tick threads
|
|
2514
2694
|
# neither the backend nor its key — a new backend needs zero tick change.
|
|
2515
2695
|
job_id = compute.submit(
|
|
2516
2696
|
JobSpec(
|
|
@@ -2714,7 +2894,7 @@ def service_intake(
|
|
|
2714
2894
|
# that would publish panel-less self-merging PRs (terra #171)
|
|
2715
2895
|
log.error(
|
|
2716
2896
|
"attempt on %s not launched: contract sets merge:auto but "
|
|
2717
|
-
"no panel is configured (set
|
|
2897
|
+
"no panel is configured (set OUTERLOOP_PANEL, or the "
|
|
2718
2898
|
"contract back to merge:manual)",
|
|
2719
2899
|
task.benchmark,
|
|
2720
2900
|
)
|
|
@@ -2726,7 +2906,7 @@ def service_intake(
|
|
|
2726
2906
|
if author_error:
|
|
2727
2907
|
log.error(
|
|
2728
2908
|
"issue #%d not claimed: author misconfigured — %s "
|
|
2729
|
-
"(fix
|
|
2909
|
+
"(fix OUTERLOOP_AUTHOR_BACKEND/_MODEL)",
|
|
2730
2910
|
task.number,
|
|
2731
2911
|
author_error,
|
|
2732
2912
|
)
|
|
@@ -2735,7 +2915,7 @@ def service_intake(
|
|
|
2735
2915
|
if panel_error:
|
|
2736
2916
|
log.error(
|
|
2737
2917
|
"issue #%d not claimed: panel misconfigured — %s "
|
|
2738
|
-
"(fix it, or set
|
|
2918
|
+
"(fix it, or set OUTERLOOP_PANEL='' to disable the panel)",
|
|
2739
2919
|
task.number,
|
|
2740
2920
|
panel_error,
|
|
2741
2921
|
)
|
|
@@ -2777,7 +2957,7 @@ def service_intake(
|
|
|
2777
2957
|
if spec.pat_file:
|
|
2778
2958
|
argv += ["--pat-file", spec.pat_file]
|
|
2779
2959
|
# config-driven author: climb resolves the author key from the
|
|
2780
|
-
#
|
|
2960
|
+
# OUTERLOOP_AUTHOR_* env by backend; the tick does not thread it.
|
|
2781
2961
|
try:
|
|
2782
2962
|
job_id = compute.submit(
|
|
2783
2963
|
JobSpec(
|
|
@@ -2921,7 +3101,7 @@ def _wake_dispatcher_from_env(
|
|
|
2921
3101
|
"""The wake delivery for this tick, behind an EXPLICIT on-switch so the
|
|
2922
3102
|
dispatched-wake path lands DARK. Returns `(dispatcher, live)`:
|
|
2923
3103
|
|
|
2924
|
-
* armed (the `
|
|
3104
|
+
* armed (the `OUTERLOOP_DISPATCH_WAKE` env var OR a `<root>/DISPATCH_WAKE`
|
|
2925
3105
|
sentinel file) AND the chain env carries what a wake job needs -> the real
|
|
2926
3106
|
`JobWakeDispatcher` and a LIVE sweep;
|
|
2927
3107
|
* otherwise -> the `LoggingDispatcher` and a DRY sweep.
|
|
@@ -2939,8 +3119,23 @@ def _wake_dispatcher_from_env(
|
|
|
2939
3119
|
return JobWakeDispatcher(compute, followup_spec, now), True
|
|
2940
3120
|
|
|
2941
3121
|
|
|
3122
|
+
def max_launch_gpus_from_env() -> int:
|
|
3123
|
+
"""OUTERLOOP_MAX_LAUNCH_GPUS: the per-user GPU cap launch admission keeps
|
|
3124
|
+
the fleet under; unset, empty or 0 = admission off. Read by the tick (to
|
|
3125
|
+
release) and by the attempt (to submit held), so the two agree."""
|
|
3126
|
+
raw = os.environ.get("OUTERLOOP_MAX_LAUNCH_GPUS", "").strip()
|
|
3127
|
+
if not raw:
|
|
3128
|
+
return 0
|
|
3129
|
+
try:
|
|
3130
|
+
value = int(raw)
|
|
3131
|
+
except ValueError:
|
|
3132
|
+
log.warning("OUTERLOOP_MAX_LAUNCH_GPUS=%r is not an integer; admission off", raw)
|
|
3133
|
+
return 0
|
|
3134
|
+
return max(value, 0)
|
|
3135
|
+
|
|
3136
|
+
|
|
2942
3137
|
def _max_job_minutes_from_env() -> int:
|
|
2943
|
-
"""
|
|
3138
|
+
"""OUTERLOOP_MAX_JOB_MINUTES, clamped into what the code can honor:
|
|
2944
3139
|
at least the climb-job floor (an operator on a short-MaxTime partition
|
|
2945
3140
|
must be able to LOWER the cap below cpu_short's 6h, or every submit is
|
|
2946
3141
|
rejected), at most the ceiling the stranded window allows. A clamped
|
|
@@ -2948,24 +3143,24 @@ def _max_job_minutes_from_env() -> int:
|
|
|
2948
3143
|
rejecting jobs for no reason."""
|
|
2949
3144
|
from outerloop.limits import ATTEMPT_JOB_MINUTES_FLOOR
|
|
2950
3145
|
|
|
2951
|
-
raw = os.environ.get("
|
|
3146
|
+
raw = os.environ.get("OUTERLOOP_MAX_JOB_MINUTES", "").strip()
|
|
2952
3147
|
if not raw:
|
|
2953
3148
|
return MAX_ATTEMPT_JOB_MINUTES
|
|
2954
3149
|
try:
|
|
2955
3150
|
value = int(raw)
|
|
2956
3151
|
except ValueError:
|
|
2957
|
-
log.warning("
|
|
3152
|
+
log.warning("OUTERLOOP_MAX_JOB_MINUTES=%r is not an integer; using default", raw)
|
|
2958
3153
|
return MAX_ATTEMPT_JOB_MINUTES
|
|
2959
3154
|
clamped = max(ATTEMPT_JOB_MINUTES_FLOOR, min(value, MAX_JOB_MINUTES_CEILING))
|
|
2960
3155
|
if clamped != value:
|
|
2961
|
-
log.warning("
|
|
3156
|
+
log.warning("OUTERLOOP_MAX_JOB_MINUTES=%d clamped to %d", value, clamped)
|
|
2962
3157
|
return clamped
|
|
2963
3158
|
|
|
2964
3159
|
|
|
2965
3160
|
def _cadence_s() -> float:
|
|
2966
|
-
"""The chain's tick cadence in seconds (
|
|
3161
|
+
"""The chain's tick cadence in seconds (OUTERLOOP_CADENCE_MIN, the same
|
|
2967
3162
|
knob tick_chain.sbatch uses), defaulting to 30 min when unset/invalid."""
|
|
2968
|
-
raw = os.environ.get("
|
|
3163
|
+
raw = os.environ.get("OUTERLOOP_CADENCE_MIN", "").strip()
|
|
2969
3164
|
try:
|
|
2970
3165
|
cadence_s = float(raw) * 60 if raw else 30 * 60
|
|
2971
3166
|
except ValueError:
|
|
@@ -2975,7 +3170,7 @@ def _cadence_s() -> float:
|
|
|
2975
3170
|
|
|
2976
3171
|
def _coalesce_ceiling_s() -> float:
|
|
2977
3172
|
"""The largest SAFE coalesce window, bounding both the default and an
|
|
2978
|
-
explicit
|
|
3173
|
+
explicit OUTERLOOP_MIN_TICK_MINUTES: half the cadence (so an on-cadence
|
|
2979
3174
|
tick is never coalesced even when the previous one ran a little late), and
|
|
2980
3175
|
never above the absolute MAX_MIN_TICK_S. A window at/above the cadence would
|
|
2981
3176
|
swallow every normal tick and stall the loop — this is what forbids it."""
|
|
@@ -2991,29 +3186,29 @@ def _default_min_tick_s() -> float:
|
|
|
2991
3186
|
|
|
2992
3187
|
|
|
2993
3188
|
def _min_tick_s_from_env() -> float:
|
|
2994
|
-
"""
|
|
3189
|
+
"""OUTERLOOP_MIN_TICK_MINUTES -> the coalesce window in seconds. Unset
|
|
2995
3190
|
derives a cadence-aware default; non-numeric/non-finite also fall back to it;
|
|
2996
3191
|
negative clamps to 0 (coalesce disabled); a value at/above the safe ceiling
|
|
2997
3192
|
(half the cadence, capped at MAX_MIN_TICK_S) clamps down so it cannot stall
|
|
2998
3193
|
the loop."""
|
|
2999
|
-
raw = os.environ.get("
|
|
3194
|
+
raw = os.environ.get("OUTERLOOP_MIN_TICK_MINUTES", "").strip()
|
|
3000
3195
|
if not raw:
|
|
3001
3196
|
return _default_min_tick_s()
|
|
3002
3197
|
try:
|
|
3003
3198
|
minutes = float(raw)
|
|
3004
3199
|
except ValueError:
|
|
3005
|
-
log.warning("
|
|
3200
|
+
log.warning("OUTERLOOP_MIN_TICK_MINUTES=%r is not a number; using default", raw)
|
|
3006
3201
|
return _default_min_tick_s()
|
|
3007
3202
|
# reject inf/nan: an infinite window would coalesce every future tick and
|
|
3008
3203
|
# freeze the loop (a finite elapsed time is always < inf)
|
|
3009
3204
|
if not math.isfinite(minutes):
|
|
3010
|
-
log.warning("
|
|
3205
|
+
log.warning("OUTERLOOP_MIN_TICK_MINUTES=%r is not finite; using default", raw)
|
|
3011
3206
|
return _default_min_tick_s()
|
|
3012
3207
|
seconds = max(0.0, minutes * 60)
|
|
3013
3208
|
ceiling = _coalesce_ceiling_s()
|
|
3014
3209
|
if seconds > ceiling:
|
|
3015
3210
|
log.warning(
|
|
3016
|
-
"
|
|
3211
|
+
"OUTERLOOP_MIN_TICK_MINUTES=%s exceeds the safe ceiling "
|
|
3017
3212
|
"(%.0f min, ~half the cadence); clamping so normal ticks are not coalesced",
|
|
3018
3213
|
raw,
|
|
3019
3214
|
ceiling / 60,
|
|
@@ -3022,25 +3217,41 @@ def _min_tick_s_from_env() -> float:
|
|
|
3022
3217
|
return seconds
|
|
3023
3218
|
|
|
3024
3219
|
|
|
3220
|
+
def _default_image() -> str:
|
|
3221
|
+
"""~/outerloop-images/agent-py312.sif, or the pre-rename ~/autoresearch-images
|
|
3222
|
+
path when only that one exists. The fallback is dropped in the release after
|
|
3223
|
+
0.1."""
|
|
3224
|
+
new = os.path.expanduser("~/outerloop-images/agent-py312.sif")
|
|
3225
|
+
old = os.path.expanduser("~/autoresearch-images/agent-py312.sif")
|
|
3226
|
+
return old if (not os.path.isfile(new) and os.path.isfile(old)) else new
|
|
3227
|
+
|
|
3228
|
+
|
|
3025
3229
|
def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
3026
3230
|
"""GitHub client + FollowupSpec from the chain environment, or Nones when
|
|
3027
3231
|
the environment is incomplete (the tick then runs without in-review
|
|
3028
3232
|
servicing, and logs what is absent)."""
|
|
3029
|
-
pat_file = os.environ.get("
|
|
3030
|
-
app_file = os.environ.get("
|
|
3031
|
-
account = os.environ.get("
|
|
3032
|
-
partition = os.environ.get("
|
|
3033
|
-
image = os.environ.get(
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3233
|
+
pat_file = os.environ.get("OUTERLOOP_PAT_FILE", "")
|
|
3234
|
+
app_file = os.environ.get("OUTERLOOP_GITHUB_APP_FILE", "")
|
|
3235
|
+
account = os.environ.get("OUTERLOOP_ACCOUNT", "")
|
|
3236
|
+
partition = os.environ.get("OUTERLOOP_PARTITION", "")
|
|
3237
|
+
image = os.environ.get("OUTERLOOP_IMAGE", _default_image())
|
|
3238
|
+
home = os.environ.get("OUTERLOOP_HOME", "")
|
|
3239
|
+
# Account and partition are optional on Slurm: empty ones leave the billing
|
|
3240
|
+
# association and the partition to Slurm's defaults, as `start` already
|
|
3241
|
+
# does for the resident. Local compute has no placement at all.
|
|
3242
|
+
target = os.environ.get("OUTERLOOP_TARGET", "")
|
|
3243
|
+
if not os.environ.get("OUTERLOOP_BOT_LOGIN", "").strip():
|
|
3244
|
+
# the default is the lab's bot account; under any other credential
|
|
3245
|
+
# the kernel would not recognize its own comments and PRs (#298)
|
|
3246
|
+
from outerloop.github import DEFAULT_BOT_LOGIN
|
|
3247
|
+
|
|
3248
|
+
log.warning(
|
|
3249
|
+
"OUTERLOOP_BOT_LOGIN is not set; posting as the default %r. Set it to the "
|
|
3250
|
+
"login the kernel posts as (init records it) unless that is your account.",
|
|
3251
|
+
DEFAULT_BOT_LOGIN,
|
|
3252
|
+
)
|
|
3042
3253
|
image_ok = Path(image).is_file()
|
|
3043
|
-
panel = os.environ.get("
|
|
3254
|
+
panel = os.environ.get("OUTERLOOP_PANEL", "verify,review")
|
|
3044
3255
|
if not image_ok and local_mode():
|
|
3045
3256
|
# The local loop on a machine with no container: sessions run under
|
|
3046
3257
|
# the harness's own sandbox and evaluations run bare, on the
|
|
@@ -3056,16 +3267,16 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3056
3267
|
"sandbox and evaluations run bare on this machine; the panel is %s; a "
|
|
3057
3268
|
"codex author needs the image (docs/install.md, local mode)",
|
|
3058
3269
|
image,
|
|
3059
|
-
"on by
|
|
3060
|
-
if os.environ.get("
|
|
3061
|
-
else "off (
|
|
3270
|
+
"on by OUTERLOOP_PANEL_UNCONTAINED=1"
|
|
3271
|
+
if os.environ.get("OUTERLOOP_PANEL_UNCONTAINED") == "1"
|
|
3272
|
+
else "off (OUTERLOOP_PANEL_UNCONTAINED=1 turns it on)",
|
|
3062
3273
|
)
|
|
3063
|
-
if os.environ.get("
|
|
3274
|
+
if os.environ.get("OUTERLOOP_PANEL_UNCONTAINED") != "1":
|
|
3064
3275
|
panel = ""
|
|
3065
3276
|
# the jobs must not inherit a path to an image that is not there
|
|
3066
|
-
os.environ.pop("
|
|
3277
|
+
os.environ.pop("OUTERLOOP_IMAGE", None)
|
|
3067
3278
|
image, image_ok = "", True
|
|
3068
|
-
if (pat_file or app_file) and
|
|
3279
|
+
if (pat_file or app_file) and home and target and image_ok:
|
|
3069
3280
|
from outerloop.appauth import resolve_bot_auth
|
|
3070
3281
|
from outerloop.github import GitHubClient
|
|
3071
3282
|
|
|
@@ -3080,13 +3291,14 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3080
3291
|
pat_file=pat_file,
|
|
3081
3292
|
github_app_file=app_file,
|
|
3082
3293
|
target=target,
|
|
3083
|
-
steward_key_file=os.environ.get("
|
|
3294
|
+
steward_key_file=os.environ.get("OUTERLOOP_STEWARD_KEY_FILE", ""),
|
|
3084
3295
|
panel=panel,
|
|
3085
|
-
panel_key_file=os.environ.get("
|
|
3086
|
-
job_partition=os.environ.get("
|
|
3087
|
-
gpu_partition=os.environ.get("
|
|
3088
|
-
gpu_account=os.environ.get("
|
|
3296
|
+
panel_key_file=os.environ.get("OUTERLOOP_PANEL_KEY_FILE", ""),
|
|
3297
|
+
job_partition=os.environ.get("OUTERLOOP_JOB_PARTITION", ""),
|
|
3298
|
+
gpu_partition=os.environ.get("OUTERLOOP_GPU_PARTITION", ""),
|
|
3299
|
+
gpu_account=os.environ.get("OUTERLOOP_GPU_ACCOUNT", ""),
|
|
3089
3300
|
max_job_minutes=_max_job_minutes_from_env(),
|
|
3301
|
+
max_launch_gpus=max_launch_gpus_from_env(),
|
|
3090
3302
|
)
|
|
3091
3303
|
return github, followup_spec
|
|
3092
3304
|
except Exception as exc:
|
|
@@ -3095,11 +3307,9 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3095
3307
|
absent = [
|
|
3096
3308
|
name
|
|
3097
3309
|
for name, value in [
|
|
3098
|
-
("
|
|
3099
|
-
("
|
|
3100
|
-
("
|
|
3101
|
-
("AUTORESEARCH_HOME", home),
|
|
3102
|
-
("AUTORESEARCH_TARGET", target),
|
|
3310
|
+
("OUTERLOOP_PAT_FILE or _GITHUB_APP_FILE", pat_file or app_file),
|
|
3311
|
+
("OUTERLOOP_HOME", home),
|
|
3312
|
+
("OUTERLOOP_TARGET", target),
|
|
3103
3313
|
]
|
|
3104
3314
|
if not value
|
|
3105
3315
|
]
|
|
@@ -3112,7 +3322,7 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3112
3322
|
def _loop_cadence_s(cadence_min: float) -> float:
|
|
3113
3323
|
"""The --loop sleep, clamped to [60s, 24h]: argparse accepts inf (which
|
|
3114
3324
|
would OverflowError out of time.sleep) and sub-minute values would spin.
|
|
3115
|
-
A non-positive argument defers to
|
|
3325
|
+
A non-positive argument defers to OUTERLOOP_CADENCE_MIN."""
|
|
3116
3326
|
return min(24 * 3600.0, max(60.0, cadence_min * 60 if cadence_min > 0 else _cadence_s()))
|
|
3117
3327
|
|
|
3118
3328
|
|
|
@@ -3120,15 +3330,30 @@ def main() -> int:
|
|
|
3120
3330
|
import argparse
|
|
3121
3331
|
import time
|
|
3122
3332
|
|
|
3123
|
-
parser = argparse.ArgumentParser(
|
|
3333
|
+
parser = argparse.ArgumentParser(
|
|
3334
|
+
prog="outerloop tick",
|
|
3335
|
+
description="One tick of the loop: service the open runs, launch new work, wake "
|
|
3336
|
+
"parked runs. The chain runs this every cadence; --loop does the same in the "
|
|
3337
|
+
"foreground.",
|
|
3338
|
+
)
|
|
3124
3339
|
parser.add_argument("--root", required=True, type=Path, help="state root on the shared FS")
|
|
3125
|
-
parser.add_argument(
|
|
3126
|
-
|
|
3340
|
+
parser.add_argument(
|
|
3341
|
+
"--grace-s",
|
|
3342
|
+
type=float,
|
|
3343
|
+
default=DEFAULT_GRACE_S,
|
|
3344
|
+
help="seconds a finished experiment's delivery job gets before the sweep steps in",
|
|
3345
|
+
)
|
|
3346
|
+
parser.add_argument(
|
|
3347
|
+
"--lease-ttl-s",
|
|
3348
|
+
type=float,
|
|
3349
|
+
default=DEFAULT_LEASE_TTL_S,
|
|
3350
|
+
help="seconds after which a held run lease counts as stale",
|
|
3351
|
+
)
|
|
3127
3352
|
parser.add_argument(
|
|
3128
3353
|
"--min-free-gb",
|
|
3129
3354
|
type=float,
|
|
3130
3355
|
default=DEFAULT_MIN_FREE_BYTES / 1024**3,
|
|
3131
|
-
help="skip launching new work when the state filesystem has less free",
|
|
3356
|
+
help="skip launching new work when the state filesystem has less than this many GB free",
|
|
3132
3357
|
)
|
|
3133
3358
|
parser.add_argument(
|
|
3134
3359
|
"--loop",
|
|
@@ -3141,23 +3366,23 @@ def main() -> int:
|
|
|
3141
3366
|
type=float,
|
|
3142
3367
|
default=0.0,
|
|
3143
3368
|
help="minutes between --loop ticks; unset defers to "
|
|
3144
|
-
"
|
|
3369
|
+
"OUTERLOOP_CADENCE_MIN via the chain's own parser (default 30)",
|
|
3145
3370
|
)
|
|
3146
3371
|
args = parser.parse_args()
|
|
3147
3372
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
|
3148
3373
|
|
|
3149
3374
|
args.root.mkdir(parents=True, exist_ok=True)
|
|
3150
3375
|
# The tick's --root is the authority; children (and this process's own
|
|
3151
|
-
# LocalCompute) read
|
|
3376
|
+
# LocalCompute) read OUTERLOOP_ROOT, so a bare `tick --loop --root X`
|
|
3152
3377
|
# must not split-brain them: local job states would land nowhere and
|
|
3153
3378
|
# every finished job would read GONE until the park deadline.
|
|
3154
3379
|
# RESOLVED: local jobs cd into flight checkouts, so a relative root
|
|
3155
3380
|
# would scatter their state dirs across working directories
|
|
3156
|
-
if os.environ.get("
|
|
3157
|
-
os.environ["
|
|
3381
|
+
if os.environ.get("OUTERLOOP_ROOT", "") != str(args.root.resolve()):
|
|
3382
|
+
os.environ["OUTERLOOP_ROOT"] = str(args.root.resolve())
|
|
3158
3383
|
# In-review servicing is LIVE when credentials + image are available in the
|
|
3159
3384
|
# chain environment. The waiting-run sweep delivers real wakes only when the
|
|
3160
|
-
# operator arms it — the
|
|
3385
|
+
# operator arms it — the OUTERLOOP_DISPATCH_WAKE env var or a
|
|
3161
3386
|
# <root>/DISPATCH_WAKE sentinel — and the env is complete; by default it
|
|
3162
3387
|
# stays dry with the LoggingDispatcher — dispatched climbing lands DARK.
|
|
3163
3388
|
# ONE compute for the process: LocalCompute remembers its jobs' states
|