outerloop-science 0.1.0.dev0__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 +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +213 -0
- outerloop/appmanifest.py +198 -0
- outerloop/attempt.py +3481 -0
- outerloop/brief.py +515 -0
- outerloop/cli.py +439 -0
- outerloop/climbboard.py +1145 -0
- outerloop/compute.py +482 -0
- outerloop/contract.py +483 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +586 -0
- outerloop/followup.py +2143 -0
- outerloop/github.py +1486 -0
- outerloop/harness.py +1449 -0
- outerloop/housekeeping.py +167 -0
- outerloop/init.py +313 -0
- outerloop/intake.py +129 -0
- outerloop/limits.py +80 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +523 -0
- outerloop/orchestrator.py +1901 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +27 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +611 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +163 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +247 -0
- outerloop/rolespec.py +89 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +852 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +977 -0
- outerloop/syscall_cli.py +531 -0
- outerloop/tick.py +3166 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +149 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
- outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
- outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/rolespec.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""RoleSpec: the manifest that turns the generic harness into one role.
|
|
2
|
+
|
|
3
|
+
Data, not code. Adding a role is a new RoleSpec plus its skills and a
|
|
4
|
+
result-policy — no kernel change (docs/design/consolidation.md). The kernel
|
|
5
|
+
reads a RoleSpec to decide what a session sees (skills, tools), how it is
|
|
6
|
+
constrained (key, scope, execution), and how its output is checked
|
|
7
|
+
(`output_schema`).
|
|
8
|
+
|
|
9
|
+
Every role runs the same way — a session inside the deployment's boundary (a
|
|
10
|
+
container where one exists, the ephemeral runner where one doesn't) with no
|
|
11
|
+
write credential in reach (a judge's session job holds at most a read-scoped
|
|
12
|
+
token; writes happen in a separate post job) — and roles differ by prompt,
|
|
13
|
+
verbs, and output handling, never by a bespoke containment posture. The
|
|
14
|
+
invariant kept here is consistency: a spec that declares itself non-executing
|
|
15
|
+
may not hold a mutating tool or a write scope.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
|
|
23
|
+
RoleName = Literal["author", "reviewer", "verifier", "summarizer", "steward", "followup"]
|
|
24
|
+
KeyFamily = Literal["author", "reviewer", "verifier", "steward"]
|
|
25
|
+
Environment = Literal["apptainer", "gh-runner", "local"]
|
|
26
|
+
|
|
27
|
+
# Tools that change files or run code. A spec that declares can_execute=False
|
|
28
|
+
# must not hold any of these — the declaration and the tool set must agree.
|
|
29
|
+
MUTATING_TOOLS: frozenset[str] = frozenset({"Write", "Edit", "Bash"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RoleSpecError(ValueError):
|
|
33
|
+
"""A RoleSpec violates a hard invariant."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Execution:
|
|
38
|
+
# `environment` is declarative deployment metadata: it names where the
|
|
39
|
+
# role is meant to run, but binding it (the apptainer image, the runner)
|
|
40
|
+
# is the harness builder's job at the deployment site. `can_execute` is
|
|
41
|
+
# the enforced half — the RoleSpec invariant and the builders check it.
|
|
42
|
+
environment: Environment
|
|
43
|
+
can_execute: bool # may the session run code (Bash)?
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SessionBudget:
|
|
48
|
+
max_turns: int
|
|
49
|
+
walltime_s: int
|
|
50
|
+
cost_cap_usd: float | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class RoleSpec:
|
|
55
|
+
name: RoleName
|
|
56
|
+
instructions: str # standing role text; skills add know-how on top
|
|
57
|
+
key: KeyFamily # credential family (isolation)
|
|
58
|
+
tools: tuple[str, ...] # allowed tool ids (native + harness-provided)
|
|
59
|
+
execution: Execution
|
|
60
|
+
budget: SessionBudget
|
|
61
|
+
skills: tuple[str, ...] = ()
|
|
62
|
+
# A role WITH a schema is a judge: it records findings through the
|
|
63
|
+
# installed syscall tool (`finding` / `conclude`, docs/design/role-cli.md)
|
|
64
|
+
# and the kernel reads the committed verdict back authoritatively
|
|
65
|
+
# (`syscall.read_verdict`, which owns the one canonical verdict shape; the
|
|
66
|
+
# schema here marks the role and documents the downstream shape). None for
|
|
67
|
+
# editing roles, whose artifact is a workspace diff, not a verdict.
|
|
68
|
+
output_schema: dict[str, Any] | None = None
|
|
69
|
+
# Editing roles only: repo-relative write allowlist. None for judges —
|
|
70
|
+
# they investigate and record a verdict; they do not edit the tree.
|
|
71
|
+
scope: tuple[str, ...] | None = None
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
if not self.tools:
|
|
75
|
+
raise RoleSpecError(f"role {self.name!r} has no tools")
|
|
76
|
+
if not self.execution.can_execute:
|
|
77
|
+
mutating = MUTATING_TOOLS.intersection(self.tools)
|
|
78
|
+
if mutating:
|
|
79
|
+
raise RoleSpecError(
|
|
80
|
+
f"read-only role {self.name!r} may not hold mutating tools {sorted(mutating)}"
|
|
81
|
+
)
|
|
82
|
+
if self.scope is not None:
|
|
83
|
+
raise RoleSpecError(
|
|
84
|
+
f"read-only role {self.name!r} edits nothing; scope must be None"
|
|
85
|
+
)
|
|
86
|
+
if self.output_schema is not None and self.scope is not None:
|
|
87
|
+
raise RoleSpecError(
|
|
88
|
+
f"judge {self.name!r} records a verdict, never edits; scope must be None"
|
|
89
|
+
)
|
outerloop/runstate.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Run state on the shared filesystem: the agent's durable half.
|
|
2
|
+
|
|
3
|
+
A run is one hypothesis (docs/design/architecture.md, "The life of a run").
|
|
4
|
+
Its record is a single JSON file written by atomic rename; the sweep reasons
|
|
5
|
+
only from these files plus Slurm — never from process memory — so a crash
|
|
6
|
+
anywhere leaves a file that says what happens next.
|
|
7
|
+
|
|
8
|
+
Leases serialize wake delivery: whoever wants to wake a run acquires the
|
|
9
|
+
lease first (atomic O_EXCL create). Leases expire — a holder that died keeps
|
|
10
|
+
the lease only until the sweep notices (holder job dead, or age past TTL) —
|
|
11
|
+
so a wake killed mid-session delays the retry by one grace window; it cannot
|
|
12
|
+
strand the run.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Live states.
|
|
27
|
+
IMPLEMENTING = "implementing" # a session is (or will be) working
|
|
28
|
+
WAITING = "waiting" # experiment submitted; hibernating until results
|
|
29
|
+
IN_REVIEW = "in-review" # PR open; wakes on qualifying comments
|
|
30
|
+
CONCLUDING = "concluding" # results in hand; final session(s)
|
|
31
|
+
ENDED = "ended"
|
|
32
|
+
|
|
33
|
+
STATES = (IMPLEMENTING, WAITING, IN_REVIEW, CONCLUDING, ENDED)
|
|
34
|
+
|
|
35
|
+
# The six endings ("The life of a run" — every one produces a report).
|
|
36
|
+
MERGED = "merged"
|
|
37
|
+
REJECTED = "rejected"
|
|
38
|
+
NEGATIVE_RESULT = "negative-result"
|
|
39
|
+
BUDGET_EXHAUSTED = "budget-exhausted"
|
|
40
|
+
ABORTED = "aborted"
|
|
41
|
+
STUCK = "stuck"
|
|
42
|
+
|
|
43
|
+
ENDINGS = (MERGED, REJECTED, NEGATIVE_RESULT, BUDGET_EXHAUSTED, ABORTED, STUCK)
|
|
44
|
+
|
|
45
|
+
RECORD_NAME = "state.json"
|
|
46
|
+
LEASE_NAME = "lease.json"
|
|
47
|
+
|
|
48
|
+
# How long the session-spawning lanes stay paused after an API outage is
|
|
49
|
+
# stamped. 45 minutes skips roughly one tick, so during a sustained outage
|
|
50
|
+
# one canary session per ~hour re-probes the API instead of every lane
|
|
51
|
+
# burning attempts every half hour. Throttling (429/529) is transient by
|
|
52
|
+
# nature and gets a short pause instead — a momentary spike must not idle
|
|
53
|
+
# the orchestrator for most of an hour (review finding).
|
|
54
|
+
OUTAGE_COOLDOWN_S = 45 * 60
|
|
55
|
+
THROTTLE_COOLDOWN_S = 5 * 60
|
|
56
|
+
_THROTTLE_HINTS = ("rate_limit", "overloaded")
|
|
57
|
+
# Stamps are written on compute nodes and read on other hosts: a stamp a
|
|
58
|
+
# few seconds "in the future" is NTP skew and must count as active, while
|
|
59
|
+
# a far-future timestamp is corruption and must not pause forever.
|
|
60
|
+
MAX_CLOCK_SKEW_S = 5 * 60
|
|
61
|
+
|
|
62
|
+
MAX_WAKE_ATTEMPTS = 3
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _outage_path(root: Path, role: str) -> Path:
|
|
66
|
+
# per-ROLE latches: roles hold separate keys, and a permanently dead
|
|
67
|
+
# steward key re-stamping its latch every cooldown must not keep the
|
|
68
|
+
# solver lanes paused forever (review finding). Role names are ours
|
|
69
|
+
# ("solver"/"steward"), sanitized only as filename hygiene.
|
|
70
|
+
safe = "".join(ch for ch in role if ch.isalnum() or ch == "-") or "solver"
|
|
71
|
+
return root / f"outage-{safe}.json"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stamp_outage(root: Path, detail: str, now: float, role: str = "solver") -> None:
|
|
75
|
+
"""Record that the API refused this ROLE's key (atomic rename)."""
|
|
76
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
# pid in the tmp name, like save_record: several lanes' jobs can fail
|
|
78
|
+
# to the same outage in one window, and interleaved writers must not
|
|
79
|
+
# install a truncated stamp — an unreadable latch reads as NO pause,
|
|
80
|
+
# which is exactly the failure the latch exists to prevent
|
|
81
|
+
path = _outage_path(root, role)
|
|
82
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
83
|
+
cooldown = (
|
|
84
|
+
THROTTLE_COOLDOWN_S
|
|
85
|
+
if any(hint in detail.casefold() for hint in _THROTTLE_HINTS)
|
|
86
|
+
else OUTAGE_COOLDOWN_S
|
|
87
|
+
)
|
|
88
|
+
tmp.write_text(json.dumps({"detail": detail[:300], "time": now, "cooldown_s": cooldown}))
|
|
89
|
+
os.replace(tmp, path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def outage_active(root: Path, now: float, role: str = "solver") -> str:
|
|
93
|
+
"""The stamped detail while this role's cooldown holds, else "". The
|
|
94
|
+
cooldown lives IN the stamp (decided at stamp time from the failure
|
|
95
|
+
class); unreadable or stale stamps read as inactive — a corrupt latch
|
|
96
|
+
must never brick the loop; cross-host clock skew within
|
|
97
|
+
MAX_CLOCK_SKEW_S counts as active, anything further future as
|
|
98
|
+
corrupt."""
|
|
99
|
+
path = _outage_path(root, role)
|
|
100
|
+
try:
|
|
101
|
+
data = json.loads(path.read_text())
|
|
102
|
+
stamped = float(data["time"])
|
|
103
|
+
detail = str(data.get("detail", ""))
|
|
104
|
+
cooldown_s = float(data.get("cooldown_s", OUTAGE_COOLDOWN_S))
|
|
105
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
106
|
+
return ""
|
|
107
|
+
if -MAX_CLOCK_SKEW_S <= now - stamped < cooldown_s:
|
|
108
|
+
return detail or "api outage"
|
|
109
|
+
return ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class RunRecord:
|
|
114
|
+
"""Everything the sweep needs to act on a run, and nothing more."""
|
|
115
|
+
|
|
116
|
+
run_id: str
|
|
117
|
+
target: str # owner/repo
|
|
118
|
+
task_title: str
|
|
119
|
+
state: str
|
|
120
|
+
agent_id: str = "agent-01"
|
|
121
|
+
experiment_job_id: str = ""
|
|
122
|
+
run_job_id: str = "" # slurm job running the attempt itself; lets the
|
|
123
|
+
# sweep end records whose job was KILLED (walltime/preemption/node
|
|
124
|
+
# death) rather than crashed — signals leave no exception to contain.
|
|
125
|
+
# INVARIANT: any future path that re-enters `implementing` from a NEW
|
|
126
|
+
# job must re-stamp this field, or the sweep will judge the run by a
|
|
127
|
+
# stale terminal job. (No such path exists today.)
|
|
128
|
+
resume_session_id: str = "" # harness session to resume on wake
|
|
129
|
+
pr_url: str = "" # the run's open PR, once one exists
|
|
130
|
+
benchmark: str = "" # contract benchmark this run works on
|
|
131
|
+
# The author this run was STARTED with ("" backend = legacy/claude). A wake or
|
|
132
|
+
# follow-up reproduces the run's OWN author from these, not the current fleet
|
|
133
|
+
# default, so a fleet backend flip never resumes a run on the wrong backend,
|
|
134
|
+
# model, or key. backend and model are a PAIR — a claude backend needs a
|
|
135
|
+
# claude model and vice versa — so both are persisted together.
|
|
136
|
+
author_backend: str = ""
|
|
137
|
+
author_model: str = ""
|
|
138
|
+
# The resolved author key FILE PATH (not the key) this run used, so a wake or
|
|
139
|
+
# follow-up reproduces the exact key — an explicit --key-file survives, and an
|
|
140
|
+
# in-flight run is immune to a later env change. "" = resolve per backend
|
|
141
|
+
# (legacy records, and the common config-driven case).
|
|
142
|
+
author_key_file: str = ""
|
|
143
|
+
# Per-source comment cursors: issue comments, top-level reviews, and
|
|
144
|
+
# inline review comments are three REST collections with independent id
|
|
145
|
+
# sequences — one cursor across them drops comments forever.
|
|
146
|
+
last_comment_id: int = 0
|
|
147
|
+
last_review_id: int = 0
|
|
148
|
+
last_review_comment_id: int = 0
|
|
149
|
+
# head sha the last conflict wake was issued for: a dirty PR wakes the
|
|
150
|
+
# author ONCE per head — a new push (or new conflict) re-arms it
|
|
151
|
+
dirty_wake_head: str = ""
|
|
152
|
+
# The exact PR head the auto-arm may merge: set at publish to the pushed
|
|
153
|
+
# head when the PR was published UNDER merge:auto with a CLEAN panel
|
|
154
|
+
# (#171's arming condition), carried forward by signature-clean syncs
|
|
155
|
+
# (same measured bytes), cleared by any code-changing push. Binding the
|
|
156
|
+
# blessing to a sha — not a flag — means a crashed responder, a live
|
|
157
|
+
# one, or any unrecorded push simply fails the equality: the tick arms
|
|
158
|
+
# only when GitHub's head IS this sha. Empty = never arm (legacy too).
|
|
159
|
+
auto_blessed_head: str = ""
|
|
160
|
+
# A BLOCKING follow-up re-read wakes the author (docs/design/orchestrator-
|
|
161
|
+
# verify.md): the panel's findings, data-fenced, and the pushed head they
|
|
162
|
+
# were read on. The wake fires only while GitHub's head IS that sha, is
|
|
163
|
+
# cleared when a follow-up services it, and is set again by the next read
|
|
164
|
+
# if findings remain — bounded by `panel_wake_rounds`.
|
|
165
|
+
panel_wake_head: str = ""
|
|
166
|
+
panel_wake_text: str = ""
|
|
167
|
+
panel_wake_rounds: int = 0
|
|
168
|
+
# A follow-up's DISPATCHED re-measure in flight (docs/design/
|
|
169
|
+
# orchestrator-verify.md, "Measuring a follow-up's change"): the sealed
|
|
170
|
+
# candidate (sha + retaining ref), the eval job ids the tick polls, the
|
|
171
|
+
# seed, and the reply/cursor context the resume needs to finish — push,
|
|
172
|
+
# ledger, comment, re-read. Empty = no re-measure pending. While set, no
|
|
173
|
+
# comment is serviced and nothing is armed: the sealed change lands first.
|
|
174
|
+
followup_stage: dict[str, object] = field(default_factory=dict)
|
|
175
|
+
followup_job_id: str = "" # slurm job servicing this run's review comments
|
|
176
|
+
issue_number: int = 0 # the requesting issue, when the requested lane started this run
|
|
177
|
+
wake_attempts: int = 0
|
|
178
|
+
deadline: float = 0.0 # unix; submit+walltime+slack, re-based on start
|
|
179
|
+
terminal_seen: float = 0.0 # when the sweep first saw the experiment terminal
|
|
180
|
+
# A WAITING climb's re-entry point: the committed shas, drawn seeds, and the
|
|
181
|
+
# candidate snapshot ref a fresh process reconstructs the measure-and-decide
|
|
182
|
+
# phase from. `phase` says WHICH park (baseline, before the session; or
|
|
183
|
+
# candidate, after it). A JSON dict — small, forward-compatible — not the
|
|
184
|
+
# session state (that is `resume_session_id`).
|
|
185
|
+
stage: dict[str, object] = field(default_factory=dict)
|
|
186
|
+
ending: str = "" # one of ENDINGS once state == ENDED
|
|
187
|
+
ending_note: str = ""
|
|
188
|
+
created: float = 0.0
|
|
189
|
+
updated: float = 0.0
|
|
190
|
+
# when housekeeping removed this ended run's ws/ and ws-home/ (0 = never);
|
|
191
|
+
# the record, report, transcripts, and ledger stay (housekeeping.py)
|
|
192
|
+
workspace_shed: float = 0.0
|
|
193
|
+
|
|
194
|
+
def ended(self) -> bool:
|
|
195
|
+
return self.state == ENDED
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class Lease:
|
|
200
|
+
holder: str # e.g. "wake-job:12345" or "tick:12345"
|
|
201
|
+
holder_job_id: str # Slurm job id of the holder, "" if none
|
|
202
|
+
acquired: float # unix timestamp
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def run_dir(root: Path, run_id: str) -> Path:
|
|
206
|
+
return root / "runs" / run_id
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def save_record(root: Path, record: RunRecord, now: float) -> None:
|
|
210
|
+
"""Atomic write: a crash mid-save leaves the previous record intact."""
|
|
211
|
+
if record.state not in STATES:
|
|
212
|
+
raise ValueError(f"unknown state {record.state!r}")
|
|
213
|
+
if record.state == ENDED and record.ending not in ENDINGS:
|
|
214
|
+
raise ValueError(f"ended run needs a valid ending, got {record.ending!r}")
|
|
215
|
+
if record.state == WAITING and record.experiment_job_id and record.deadline <= 0:
|
|
216
|
+
# A waiting run without a deadline is invisible to the deadline floor
|
|
217
|
+
# — the exact "silently immortal run" the fail-safe design forbids.
|
|
218
|
+
raise ValueError("waiting run with an experiment needs a deadline")
|
|
219
|
+
directory = run_dir(root, record.run_id)
|
|
220
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
stamped = replace(record, updated=now, created=record.created or now)
|
|
222
|
+
# unique tmp name: two concurrent writers must not interleave into the
|
|
223
|
+
# same tmp file before the atomic replace
|
|
224
|
+
tmp = directory / f".{RECORD_NAME}.{os.getpid()}.tmp"
|
|
225
|
+
tmp.write_text(json.dumps(asdict(stamped), indent=2, sort_keys=True))
|
|
226
|
+
os.replace(tmp, directory / RECORD_NAME)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def load_record(root: Path, run_id: str) -> RunRecord:
|
|
230
|
+
raw = json.loads((run_dir(root, run_id) / RECORD_NAME).read_text())
|
|
231
|
+
if not isinstance(raw, dict):
|
|
232
|
+
raise ValueError(f"record is not a JSON object: {type(raw).__name__}")
|
|
233
|
+
# Back-compat: a record written by pre-rename code carries `climb_job_id`
|
|
234
|
+
# for what is now `run_job_id`. Map it on load so an in-flight run started
|
|
235
|
+
# before the rename still wakes/ends correctly (the deploy is atomic, but
|
|
236
|
+
# its already-parked records are not). Only when the new key is absent, so
|
|
237
|
+
# a genuine new record always wins.
|
|
238
|
+
if "climb_job_id" in raw and "run_job_id" not in raw:
|
|
239
|
+
raw["run_job_id"] = raw["climb_job_id"]
|
|
240
|
+
# Ignore unknown keys: after a bad-merge revert, older code must still be
|
|
241
|
+
# able to read records written by newer code — a "corrupt" verdict here
|
|
242
|
+
# would blind the sweep to the whole run.
|
|
243
|
+
known = {k: v for k, v in raw.items() if k in RunRecord.__dataclass_fields__}
|
|
244
|
+
return RunRecord(**known)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def list_runs(root: Path) -> list[RunRecord]:
|
|
248
|
+
"""Every readable run record; unreadable ones are logged, not fatal —
|
|
249
|
+
one corrupt file must not stop the sweep."""
|
|
250
|
+
records = []
|
|
251
|
+
runs_root = root / "runs"
|
|
252
|
+
if not runs_root.is_dir():
|
|
253
|
+
return []
|
|
254
|
+
for directory in sorted(runs_root.iterdir()):
|
|
255
|
+
# Skip entries that are not runs (no record file) — e.g. the `baselines`
|
|
256
|
+
# eval cache lives under runs/ but has no state.json and is not a run.
|
|
257
|
+
# A dir that HAS a record which fails to parse still logs below: a
|
|
258
|
+
# corrupt run is a real signal; a missing record is not.
|
|
259
|
+
if not (directory / RECORD_NAME).is_file():
|
|
260
|
+
continue
|
|
261
|
+
try:
|
|
262
|
+
records.append(load_record(root, directory.name))
|
|
263
|
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
|
264
|
+
log.warning("unreadable run record %s: %s", directory, exc)
|
|
265
|
+
return records
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# --- leases ---
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def acquire_lease(root: Path, run_id: str, holder: str, holder_job_id: str, now: float) -> bool:
|
|
272
|
+
"""Take the run's wake lease. True if acquired; False if held.
|
|
273
|
+
|
|
274
|
+
O_EXCL makes acquisition atomic: exactly one contender wins, the rest see
|
|
275
|
+
False and no-op. (O_EXCL is reliable on NFSv4/GPFS/Lustre; if the state
|
|
276
|
+
root ever lands on NFSv3, this needs a link(2)-based lock instead —
|
|
277
|
+
verify the cluster filesystem before trusting the lease.)
|
|
278
|
+
"""
|
|
279
|
+
directory = run_dir(root, run_id)
|
|
280
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
payload = json.dumps(asdict(Lease(holder, holder_job_id, now)))
|
|
282
|
+
try:
|
|
283
|
+
fd = os.open(directory / LEASE_NAME, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
284
|
+
except FileExistsError:
|
|
285
|
+
return False
|
|
286
|
+
with os.fdopen(fd, "w") as handle:
|
|
287
|
+
handle.write(payload)
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_lease(root: Path, run_id: str) -> Lease | None:
|
|
292
|
+
path = run_dir(root, run_id) / LEASE_NAME
|
|
293
|
+
try:
|
|
294
|
+
raw = json.loads(path.read_text())
|
|
295
|
+
if not isinstance(raw, dict):
|
|
296
|
+
raise ValueError("lease is not a JSON object")
|
|
297
|
+
known = {k: v for k, v in raw.items() if k in Lease.__dataclass_fields__}
|
|
298
|
+
return Lease(**known)
|
|
299
|
+
except FileNotFoundError:
|
|
300
|
+
return None
|
|
301
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
302
|
+
# A crash between O_EXCL create and write leaves an empty/corrupt
|
|
303
|
+
# lease. Synthesize one from the file mtime so the TTL path can
|
|
304
|
+
# still reap it — otherwise the run is stranded forever behind a
|
|
305
|
+
# lease nobody can read.
|
|
306
|
+
try:
|
|
307
|
+
mtime = path.stat().st_mtime
|
|
308
|
+
except OSError:
|
|
309
|
+
return None # vanished between read and stat
|
|
310
|
+
return Lease(holder="unreadable", holder_job_id="", acquired=mtime)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def update_lease_holder(
|
|
314
|
+
root: Path, run_id: str, holder: str, holder_job_id: str, now: float
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Hand a HELD lease to a new holder (e.g. tick → the wake job it just
|
|
317
|
+
submitted). Atomic replace; only valid while the caller holds the lease."""
|
|
318
|
+
directory = run_dir(root, run_id)
|
|
319
|
+
tmp = directory / f".{LEASE_NAME}.{os.getpid()}.tmp"
|
|
320
|
+
tmp.write_text(json.dumps(asdict(Lease(holder, holder_job_id, now))))
|
|
321
|
+
os.replace(tmp, directory / LEASE_NAME)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def release_lease(root: Path, run_id: str) -> None:
|
|
325
|
+
"""For the lease HOLDER only. Non-holders must use reap_lease."""
|
|
326
|
+
with contextlib.suppress(FileNotFoundError):
|
|
327
|
+
(run_dir(root, run_id) / LEASE_NAME).unlink()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def reap_lease(root: Path, run_id: str, reaper: str, expected: Lease) -> bool:
|
|
331
|
+
"""Remove the stale lease you observed (and do NOT hold). True if THIS
|
|
332
|
+
caller reaped exactly that lease.
|
|
333
|
+
|
|
334
|
+
Rename-to-tombstone makes removal atomic (one of N concurrent reapers
|
|
335
|
+
wins the rename); the identity check afterwards makes it a compare-and-
|
|
336
|
+
swap: if the file we renamed is NOT the stale lease we observed — a
|
|
337
|
+
faster reaper already reaped and a fresh lease was written — we restore
|
|
338
|
+
it via link (which cannot clobber a newer lease) and stand down. The
|
|
339
|
+
remaining hole needs a 3-party race inside this microsecond window and
|
|
340
|
+
the singleton tick serialization makes that effectively unreachable;
|
|
341
|
+
if it ever fires, the symptom is one duplicate wake, which the resumed
|
|
342
|
+
session tolerates (sequential re-resume is safe).
|
|
343
|
+
"""
|
|
344
|
+
directory = run_dir(root, run_id)
|
|
345
|
+
tombstone = directory / f".{LEASE_NAME}.reaped.{reaper}"
|
|
346
|
+
try:
|
|
347
|
+
os.rename(directory / LEASE_NAME, tombstone)
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
return False
|
|
350
|
+
try:
|
|
351
|
+
raw = json.loads(tombstone.read_text())
|
|
352
|
+
got: Lease | None = (
|
|
353
|
+
Lease(**{k: v for k, v in raw.items() if k in Lease.__dataclass_fields__})
|
|
354
|
+
if isinstance(raw, dict)
|
|
355
|
+
else None
|
|
356
|
+
)
|
|
357
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
358
|
+
got = None # unreadable — the corrupt lease we came to reap
|
|
359
|
+
if got is not None and (got.holder != expected.holder or got.acquired != expected.acquired):
|
|
360
|
+
# we grabbed someone's FRESH lease; put it back without clobbering
|
|
361
|
+
try:
|
|
362
|
+
os.link(tombstone, directory / LEASE_NAME)
|
|
363
|
+
except FileExistsError:
|
|
364
|
+
log.warning("lease race on %s: fresh lease displaced during reap", run_id)
|
|
365
|
+
tombstone.unlink(missing_ok=True)
|
|
366
|
+
return False
|
|
367
|
+
tombstone.unlink(missing_ok=True)
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def lease_is_stale(lease: Lease, now: float, ttl_s: float, holder_alive: bool | None) -> bool:
|
|
372
|
+
"""A lease is stale when its holder is known-dead, or — when Slurm cannot
|
|
373
|
+
say — too old. A holder Slurm reports alive is never stale by age: a wake
|
|
374
|
+
armed at park time waits in the queue for as long as the evals run, and
|
|
375
|
+
its walltime bounds it once it starts.
|
|
376
|
+
|
|
377
|
+
`holder_alive` is None when Slurm could not answer (query failure) — in
|
|
378
|
+
that case only the TTL can prove staleness, never the holder check:
|
|
379
|
+
an outage must not look like a dead holder.
|
|
380
|
+
"""
|
|
381
|
+
if holder_alive is False:
|
|
382
|
+
return True
|
|
383
|
+
if holder_alive is True:
|
|
384
|
+
return False
|
|
385
|
+
return (now - lease.acquired) > ttl_s
|