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.
Files changed (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. 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