outerloop-science 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev3__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 (44) hide show
  1. outerloop/__init__.py +2 -2
  2. outerloop/attempt.py +310 -93
  3. outerloop/brief.py +38 -25
  4. outerloop/cli.py +40 -5
  5. outerloop/climbboard.py +3 -0
  6. outerloop/compute.py +148 -53
  7. outerloop/contract.py +8 -0
  8. outerloop/dispatch.py +63 -18
  9. outerloop/evalcache.py +147 -0
  10. outerloop/followup.py +38 -16
  11. outerloop/github.py +38 -13
  12. outerloop/harness.py +1 -18
  13. outerloop/housekeeping.py +1 -17
  14. outerloop/image.py +0 -4
  15. outerloop/init.py +19 -1
  16. outerloop/intake.py +4 -7
  17. outerloop/launchlog.py +239 -0
  18. outerloop/maintain.py +325 -0
  19. outerloop/maintain_agent_cli.py +81 -0
  20. outerloop/maintain_post_cli.py +140 -0
  21. outerloop/measure.py +6 -0
  22. outerloop/orchestrator.py +141 -31
  23. outerloop/panel.py +3 -3
  24. outerloop/review.py +4 -0
  25. outerloop/review_agent.py +7 -7
  26. outerloop/review_agent_cli.py +2 -2
  27. outerloop/review_post_cli.py +2 -2
  28. outerloop/review_summarize_cli.py +7 -5
  29. outerloop/roles.py +27 -0
  30. outerloop/rolespec.py +3 -1
  31. outerloop/steward.py +5 -5
  32. outerloop/syscall.py +261 -47
  33. outerloop/syscall_cli.py +243 -12
  34. outerloop/tick.py +90 -178
  35. outerloop/verify_agent.py +8 -6
  36. outerloop/verify_post_cli.py +2 -2
  37. outerloop/watcher.py +203 -0
  38. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/METADATA +4 -1
  39. outerloop_science-0.1.0.dev3.dist-info/RECORD +59 -0
  40. outerloop_science-0.1.0.dev2.dist-info/RECORD +0 -53
  41. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/WHEEL +0 -0
  42. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/entry_points.txt +0 -0
  43. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/licenses/LICENSE +0 -0
  44. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/licenses/NOTICE +0 -0
outerloop/tick.py CHANGED
@@ -36,7 +36,6 @@ from outerloop.compute import (
36
36
  SlurmError,
37
37
  SlurmQueryError,
38
38
  compute_from_env,
39
- gpus_in_gres,
40
39
  is_pending,
41
40
  is_terminal,
42
41
  local_mode,
@@ -97,8 +96,7 @@ BLIND_PARK_SLACK_MIN = 12 * 60
97
96
  # would put it at the back of the queue behind itself. Per-job limits
98
97
  # (`...PerJob...`), a dependency that can never be satisfied, an invalid
99
98
  # 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.
99
+ # JobHeldUser: an operator's hold is theirs to lift; the job is waiting, not stuck.
102
100
  QUEUE_WAIT_REASONS = frozenset(
103
101
  {"Priority", "Resources", "Reservation", "Dependency", "ReqNodeNotAvail", "JobHeldUser"}
104
102
  )
@@ -148,18 +146,6 @@ class WakeDispatcher(Protocol):
148
146
  def dispatch(self, record: RunRecord, reason: str) -> str: ...
149
147
 
150
148
 
151
- @dataclass
152
- class RecordingDispatcher:
153
- """Test/dry-run dispatcher: records what would have been woken."""
154
-
155
- dispatched: list[tuple[str, str]] = field(default_factory=list)
156
- holder_job_id: str = "" # set to simulate async dispatch
157
-
158
- def dispatch(self, record: RunRecord, reason: str) -> str:
159
- self.dispatched.append((record.run_id, reason))
160
- return self.holder_job_id
161
-
162
-
163
149
  @dataclass(frozen=True)
164
150
  class TickReport:
165
151
  paused: bool = False
@@ -244,11 +230,6 @@ class FollowupSpec:
244
230
  # are minutes, work jobs can be hours, and Slurm prices walltime into
245
231
  # scheduling priority, so the two deserve independent placement.
246
232
  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
252
233
  # Partition MaxTime for work jobs — the panel-augmented walltime clamps
253
234
  # here (see MAX_ATTEMPT_JOB_MINUTES). Raise together with job_partition.
254
235
  max_job_minutes: int = MAX_ATTEMPT_JOB_MINUTES
@@ -424,7 +405,7 @@ def contract_alarm(
424
405
  target: str,
425
406
  error: str | None,
426
407
  now: float,
427
- bot_login: str = "agentic-learning-bot",
408
+ bot_login: str = "",
428
409
  ) -> None:
429
410
  """Persistent contract failure must surface where humans look.
430
411
 
@@ -433,6 +414,7 @@ def contract_alarm(
433
414
  opens ONE issue on the target repo; the next successful load closes
434
415
  it and says so. Alarm plumbing is best-effort by construction: it
435
416
  must never break the tick it reports for."""
417
+ bot_login = bot_login or _bot_login_default()
436
418
  state_path = root / "contract-alarm.json"
437
419
  try:
438
420
  state = json.loads(state_path.read_text())
@@ -894,7 +876,8 @@ def _last_worked_ts(root: Path) -> float | None:
894
876
  return None
895
877
  val = float(ts)
896
878
  return val if math.isfinite(val) else None
897
- except Exception:
879
+ except Exception as exc:
880
+ log.debug("work marker unreadable, coalescing as if absent: %s", exc)
898
881
  return None
899
882
 
900
883
 
@@ -1192,6 +1175,11 @@ def sweep(
1192
1175
  except Exception as exc:
1193
1176
  log.warning("sweep failed on %s: %s: %s", record.run_id, type(exc).__name__, exc)
1194
1177
 
1178
+ try:
1179
+ cancel_ended_launches(root, compute, now, dry_run)
1180
+ except Exception as exc:
1181
+ log.warning("cancel-on-end failed: %s: %s", type(exc).__name__, exc)
1182
+
1195
1183
  return TickReport(
1196
1184
  swept=len(records),
1197
1185
  woken=tuple(woken),
@@ -1205,6 +1193,64 @@ def sweep(
1205
1193
  )
1206
1194
 
1207
1195
 
1196
+ CANCEL_ON_END_WINDOW_S = 7 * 86400
1197
+
1198
+
1199
+ def cancel_ended_launches(
1200
+ root: Path, compute: Compute, now: float, dry_run: bool = False
1201
+ ) -> list[str]:
1202
+ """Cancel on end: a run that ended while its author's launches are still
1203
+ queued or running has nothing left to read their results, so the jobs are
1204
+ cancelled — a running one included, since that is the one holding GPUs for
1205
+ a result nobody will read. The stage is stamped once every cancel
1206
+ succeeded, so an ended run costs no query afterwards; a failed scancel
1207
+ leaves the run unstamped for the next tick, and runs ended longer ago than
1208
+ the window are stamped without a query (their jobs have long left the
1209
+ queue). Returns the cancelled ids."""
1210
+ from outerloop.attempt import stage_launch_job_ids
1211
+
1212
+ cancelled: list[str] = []
1213
+ for record in list_runs(root):
1214
+ if record.state != ENDED:
1215
+ continue
1216
+ stage = dict(record.stage or {})
1217
+ if stage.get("launches_cancelled"):
1218
+ continue
1219
+ job_ids = stage_launch_job_ids(record)
1220
+ recent = now - float(getattr(record, "updated", 0.0) or 0.0) <= CANCEL_ON_END_WINDOW_S
1221
+ live: list[str] = []
1222
+ if job_ids and recent:
1223
+ try:
1224
+ for jid in job_ids:
1225
+ state = compute.status(jid)
1226
+ if state != GONE and not is_terminal(state):
1227
+ live.append(jid)
1228
+ except SlurmQueryError as exc:
1229
+ log.warning("cancel-on-end: %s: query failed (%s); next tick", record.run_id, exc)
1230
+ continue
1231
+ if dry_run:
1232
+ cancelled.extend(live)
1233
+ continue
1234
+ failed = False
1235
+ for jid in live:
1236
+ try:
1237
+ ok = compute.cancel(jid)
1238
+ except Exception as exc:
1239
+ ok = False
1240
+ log.warning("cancel-on-end: %s: scancel %s failed: %s", record.run_id, jid, exc)
1241
+ if ok:
1242
+ cancelled.append(jid)
1243
+ else:
1244
+ failed = True
1245
+ if failed:
1246
+ continue # unstamped: the next tick tries again, until the window closes
1247
+ stage["launches_cancelled"] = True
1248
+ save_record(root, replace(record, stage=stage), now)
1249
+ if cancelled:
1250
+ log.info("cancel-on-end: cancelled %d launch job(s) of ended runs", len(cancelled))
1251
+ return cancelled
1252
+
1253
+
1208
1254
  RESEARCH_LOG_BRANCH = "research-log"
1209
1255
  RESEARCH_LOG_MARKER = marker("research-log")
1210
1256
  RESEARCH_LOG_PER_TICK = 3
@@ -1793,6 +1839,12 @@ def tick(
1793
1839
  # whether github/contract loaded this tick.
1794
1840
  if followup_spec is not None:
1795
1841
  service_syncs(root, followup_spec, now)
1842
+ # a dry run reports and writes nothing: no download, no seed
1843
+ if github is not None and followup_spec is not None and followup_spec.target and not dry_run:
1844
+ try:
1845
+ service_eval_cache(root, github, followup_spec.target)
1846
+ except Exception as exc: # advisory: a cold cache costs a download, never a tick
1847
+ log.warning("eval cache warm failed: %s: %s", type(exc).__name__, exc)
1796
1848
  if github is not None and followup_spec is not None:
1797
1849
  # expired flight snapshots die with their TTL, not with a human.
1798
1850
  # One home suffices: every lane's spec derives from followup_spec
@@ -1863,12 +1915,6 @@ def tick(
1863
1915
  allow_submit=launch_ok,
1864
1916
  contract=contract,
1865
1917
  )
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)
1872
1918
  try:
1873
1919
  service_research_log(root, github, spec, now)
1874
1920
  except Exception as exc: # the ledger is advisory; the tick continues
@@ -1920,118 +1966,16 @@ def tick(
1920
1966
  return report
1921
1967
 
1922
1968
 
1923
- LAUNCH_MARK = "-launch-" # every author launch job is named <run_id>-launch-<name>
1969
+ def service_eval_cache(root: Path, github: Any, target: str) -> str:
1970
+ """Warm the target's seed cache from its default branch's lockfile
1971
+ (docs/design/eval-cache.md). Cheap when nothing changed: two file reads
1972
+ and a hash. Returns the warmer's one-word status."""
1973
+ from outerloop.evalcache import warm
1924
1974
 
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
- )
1975
+ status = warm(root, target, github, github.default_branch(target))
1976
+ if status != "unchanged":
1977
+ log.info("eval cache for %s: %s", target, status)
1978
+ return status
2035
1979
 
2036
1980
 
2037
1981
  def service_syncs(root: Path, spec: Any, now: float) -> None:
@@ -2041,7 +1985,7 @@ def service_syncs(root: Path, spec: Any, now: float) -> None:
2041
1985
  are written — safe next to the session's local git use. Best-effort per
2042
1986
  run; a failure leaves the request standing for the next cycle."""
2043
1987
  from outerloop.appauth import resolve_bot_auth
2044
- from outerloop.attempt import _target_clone_url
1988
+ from outerloop.attempt import target_clone_url
2045
1989
  from outerloop.github import Workspace
2046
1990
  from outerloop.syscall import mark_synced, sync_requested
2047
1991
 
@@ -2062,7 +2006,7 @@ def service_syncs(root: Path, spec: Any, now: float) -> None:
2062
2006
  if (spec.pat_file or spec.github_app_file)
2063
2007
  else None
2064
2008
  ),
2065
- url=_target_clone_url(record.target),
2009
+ url=target_clone_url(record.target),
2066
2010
  )
2067
2011
  ws.fetch_origin()
2068
2012
  mark_synced(workspace, requested_at)
@@ -2271,16 +2215,6 @@ def _pending_path(root: Path, target: str, agent: str = "") -> Path:
2271
2215
  return root / "pending" / (target.replace("/", "__") + suffix + ".json")
2272
2216
 
2273
2217
 
2274
- def read_pending(root: Path, target: str, agent: str = "") -> dict[str, Any] | None:
2275
- """The submit-time marker for a climb whose run record may not exist yet."""
2276
- path = _pending_path(root, target, agent)
2277
- try:
2278
- data = json.loads(path.read_text())
2279
- except (OSError, ValueError):
2280
- return None
2281
- return data if isinstance(data, dict) and "submitted_at" in data else None
2282
-
2283
-
2284
2218
  def list_pendings(root: Path, target: str) -> list[tuple[str, dict[str, Any]]]:
2285
2219
  """(agent, marker) for every live pending marker of `target` — one per
2286
2220
  WIDTH slot, plus the legacy un-suffixed marker from a pre-width deploy
@@ -3119,21 +3053,6 @@ def _wake_dispatcher_from_env(
3119
3053
  return JobWakeDispatcher(compute, followup_spec, now), True
3120
3054
 
3121
3055
 
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
-
3137
3056
  def _max_job_minutes_from_env() -> int:
3138
3057
  """OUTERLOOP_MAX_JOB_MINUTES, clamped into what the code can honor:
3139
3058
  at least the climb-job floor (an operator on a short-MaxTime partition
@@ -3240,16 +3159,9 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3240
3159
  # association and the partition to Slurm's defaults, as `start` already
3241
3160
  # does for the resident. Local compute has no placement at all.
3242
3161
  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
- )
3162
+ # no default identity (#298): every own-comment and own-PR filter keys on
3163
+ # this login, and a wrong one is worse than none
3164
+ bot_login = os.environ.get("OUTERLOOP_BOT_LOGIN", "").strip()
3253
3165
  image_ok = Path(image).is_file()
3254
3166
  panel = os.environ.get("OUTERLOOP_PANEL", "verify,review")
3255
3167
  if not image_ok and local_mode():
@@ -3276,7 +3188,7 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3276
3188
  # the jobs must not inherit a path to an image that is not there
3277
3189
  os.environ.pop("OUTERLOOP_IMAGE", None)
3278
3190
  image, image_ok = "", True
3279
- if (pat_file or app_file) and home and target and image_ok:
3191
+ if (pat_file or app_file) and home and target and bot_login and image_ok:
3280
3192
  from outerloop.appauth import resolve_bot_auth
3281
3193
  from outerloop.github import GitHubClient
3282
3194
 
@@ -3298,7 +3210,6 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3298
3210
  gpu_partition=os.environ.get("OUTERLOOP_GPU_PARTITION", ""),
3299
3211
  gpu_account=os.environ.get("OUTERLOOP_GPU_ACCOUNT", ""),
3300
3212
  max_job_minutes=_max_job_minutes_from_env(),
3301
- max_launch_gpus=max_launch_gpus_from_env(),
3302
3213
  )
3303
3214
  return github, followup_spec
3304
3215
  except Exception as exc:
@@ -3310,6 +3221,7 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3310
3221
  ("OUTERLOOP_PAT_FILE or _GITHUB_APP_FILE", pat_file or app_file),
3311
3222
  ("OUTERLOOP_HOME", home),
3312
3223
  ("OUTERLOOP_TARGET", target),
3224
+ ("OUTERLOOP_BOT_LOGIN", bot_login),
3313
3225
  ]
3314
3226
  if not value
3315
3227
  ]
outerloop/verify_agent.py CHANGED
@@ -20,7 +20,7 @@ from outerloop.contract import find_contract
20
20
  from outerloop.github import GitHubClient
21
21
  from outerloop.harness import Harness, backend_id, budget_exhausted, outage
22
22
  from outerloop.posting import EXPECTED_FAILURES, post_round, post_skip_stub
23
- from outerloop.review_agent import _emit, _pull_request
23
+ from outerloop.review_agent import emit_envelope, pull_request
24
24
  from outerloop.role_runner import run_role
25
25
  from outerloop.roles import verifier_spec, verify_result_from_role
26
26
  from outerloop.rolespec import RoleSpec
@@ -81,19 +81,21 @@ def run_agent_verify(
81
81
  # `detail` is already api-key-redacted by the harness (it owns its own
82
82
  # secret), so no secret is passed here.
83
83
  if emit_path is not None:
84
- _emit(emit_path, repo, number, kind="skip-stub", detail=detail, reviewed_by=reviewed_by)
84
+ emit_envelope(
85
+ emit_path, repo, number, kind="skip-stub", detail=detail, reviewed_by=reviewed_by
86
+ )
85
87
  else:
86
88
  post_skip_stub(client, repo, number, "verification", RuntimeError(detail))
87
89
 
88
90
  try:
89
- pr, pr_data = _pull_request(client, repo, number)
91
+ pr, pr_data = pull_request(client, repo, number)
90
92
  skip = verify_skip_reason(pr, bot_login)
91
93
  if skip is not None:
92
94
  log.info("skipping verification of %s#%s: %s", repo, number, skip)
93
95
  # a clean skip still leaves an envelope so the post job can REQUIRE
94
96
  # an artifact — a missing one then always means a broken session
95
97
  if emit_path is not None:
96
- _emit(emit_path, repo, number, kind="skip-clean", detail=skip)
98
+ emit_envelope(emit_path, repo, number, kind="skip-clean", detail=skip)
97
99
  return None
98
100
 
99
101
  contract_text = _base_contract(client, repo, pr_data)
@@ -122,11 +124,11 @@ def run_agent_verify(
122
124
  elif emit_path is not None:
123
125
  # nothing worth posting, but the post job still needs an
124
126
  # artifact so a MISSING one always means a broken session
125
- _emit(emit_path, repo, number, kind="skip-clean", detail=detail)
127
+ emit_envelope(emit_path, repo, number, kind="skip-clean", detail=detail)
126
128
  return None
127
129
 
128
130
  if emit_path is not None:
129
- _emit(
131
+ emit_envelope(
130
132
  emit_path,
131
133
  repo,
132
134
  number,
@@ -20,7 +20,7 @@ from pathlib import Path
20
20
 
21
21
  from outerloop.github import EnvTokenProvider, GitHubClient
22
22
  from outerloop.posting import EXPECTED_FAILURES, post_round, post_skip_stub
23
- from outerloop.review_agent import _pull_request
23
+ from outerloop.review_agent import pull_request
24
24
  from outerloop.verifier import (
25
25
  VERIFY_MARKER,
26
26
  format_verify_comment,
@@ -58,7 +58,7 @@ def post_from_file(
58
58
  # the write authority re-checks the bot-only rule for EVERY kind: this
59
59
  # side of the artifact boundary is the one that must never post on a
60
60
  # non-bot PR — a forged envelope is still a post
61
- pr, pr_data = _pull_request(client, repo, number)
61
+ pr, pr_data = pull_request(client, repo, number)
62
62
  skip = verify_skip_reason(pr, bot_login)
63
63
  if skip is not None:
64
64
  log.info("skipping post on %s#%s: %s", repo, number, skip)
outerloop/watcher.py ADDED
@@ -0,0 +1,203 @@
1
+ """The session watcher: a thread beside a live author session that answers its
2
+ questions through the syscall channel in seconds — `queue` (the kernel's jobs
3
+ in the cluster queue, every agent's) and `history` (this run's launches) —
4
+ where the tick would take a cadence. It changes no lifecycle state: it writes
5
+ answer files into the channel and nothing else; submitting, cancelling,
6
+ sealing and parking stay at the sleep boundary (docs/design/session-watcher.md).
7
+
8
+ Failure never reaches the session: an exception is logged and the request is
9
+ left standing, the tool times out and says so. A watcher that dies leaves the
10
+ session exactly as it is without one."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from outerloop.climbboard import queue_rows
24
+ from outerloop.launchlog import history, why_by_job
25
+ from outerloop.runstate import run_dir
26
+ from outerloop.syscall import MAX_WHY_CHARS, mark_done, marker_requested, write_channel_json
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+ VERBS = ("queue", "history")
31
+ POLL_S = 2.0
32
+ # one scheduler query per this many seconds however often the marker is
33
+ # rewritten: a session cannot turn the watcher into a squeue loop
34
+ QUERY_GAP_S = 5.0
35
+ MAX_QUEUE_ROWS = 500
36
+ # squeue names a pending array `<id>_[0-15%4]` and a running task `<id>_3`
37
+ _ARRAY_THROTTLE = re.compile(r"_\[[^\]]*%(\d+)\]$")
38
+ # the lane's node states move slowly; one sinfo a minute is plenty
39
+ LANE_GAP_S = 60.0
40
+ # a stopped watcher may still be inside a scheduler query (its own timeout,
41
+ # a minute at most); the attempt waits this long for it, then moves on — the
42
+ # thread is a daemon and writes nothing once stopped
43
+ JOIN_S = 15.0
44
+
45
+
46
+ @dataclass
47
+ class WatcherContext:
48
+ """What the watcher needs to answer: where the channel is, which run it
49
+ serves, and the compute backend to ask (None = no queue, local mode)."""
50
+
51
+ workspace: Path
52
+ run_root: Path
53
+ run_id: str
54
+ target: str
55
+ agent_id: str
56
+ compute: Any = None
57
+ gpu_partition: str = ""
58
+ poll_s: float = POLL_S
59
+ query_gap_s: float = QUERY_GAP_S
60
+ lane_gap_s: float = LANE_GAP_S
61
+ clock: Callable[[], float] = field(default=time.time)
62
+
63
+
64
+ def _kind(name: str) -> str:
65
+ """A short label for a kernel job that is not a launch."""
66
+ for key in ("wake", "followup", "eval", "resident", "tick"):
67
+ if key in name:
68
+ return key
69
+ return name
70
+
71
+
72
+ class SessionWatcher:
73
+ """`with SessionWatcher(ctx):` around the harness subprocess. `service()`
74
+ is one pass, public so the loop is testable without the thread."""
75
+
76
+ def __init__(self, ctx: WatcherContext) -> None:
77
+ self.ctx = ctx
78
+ self._stop = threading.Event()
79
+ self._thread: threading.Thread | None = None
80
+ self._queue_cache: dict[str, Any] | None = None
81
+ self._queue_at = 0.0
82
+ self._lane_cache: dict[str, Any] | None = None
83
+ self._lane_at = 0.0
84
+
85
+ def __enter__(self) -> SessionWatcher:
86
+ self._thread = threading.Thread(target=self._loop, name="session-watcher", daemon=True)
87
+ self._thread.start()
88
+ return self
89
+
90
+ def __exit__(self, *exc: object) -> None:
91
+ self._stop.set()
92
+ if self._thread is not None:
93
+ self._thread.join(timeout=JOIN_S)
94
+ if self._thread.is_alive():
95
+ log.warning("session watcher still inside a scheduler query at session end")
96
+
97
+ def _loop(self) -> None:
98
+ # wait() is False on timeout: poll; True once stopped
99
+ while not self._stop.wait(self.ctx.poll_s):
100
+ self.service()
101
+
102
+ def service(self) -> None:
103
+ """Answer every verb whose request marker is newer than its done marker."""
104
+ for verb in VERBS:
105
+ if self._stop.is_set():
106
+ return
107
+ try:
108
+ requested = marker_requested(self.ctx.workspace, f"{verb}-request", f"{verb}-done")
109
+ if requested is None:
110
+ continue
111
+ payload = self.queue_view() if verb == "queue" else self.history_view()
112
+ if self._stop.is_set():
113
+ return # the session ended meanwhile: nothing is written after it
114
+ write_channel_json(self.ctx.workspace, f"{verb}.json", payload)
115
+ mark_done(self.ctx.workspace, f"{verb}-done", requested)
116
+ except Exception as exc: # the request stands; the tool times out
117
+ log.warning("session watcher: %s failed: %s", verb, exc)
118
+
119
+ def history_view(self) -> dict[str, Any]:
120
+ ctx = self.ctx
121
+ return {
122
+ "at": ctx.clock(),
123
+ "run_id": ctx.run_id,
124
+ "history": history(run_dir(ctx.run_root, ctx.run_id)),
125
+ }
126
+
127
+ def queue_view(self) -> dict[str, Any]:
128
+ """The kernel's jobs as the board projects them (attributed to agents),
129
+ each launch labelled with its author's `why`, plus the GPU lane's load.
130
+ Cached for `query_gap_s` — the rate limit on scheduler queries."""
131
+ ctx = self.ctx
132
+ now = ctx.clock()
133
+ if self._queue_cache is not None and now - self._queue_at < ctx.query_gap_s:
134
+ return {**self._queue_cache, "at": now, "cached": True}
135
+ error = ""
136
+ rows: list[dict[str, Any]] = []
137
+ try:
138
+ snapshot = ctx.compute.queue_snapshot() if ctx.compute is not None else []
139
+ rows = [
140
+ dict(r) for r in queue_rows(ctx.run_root, ctx.target, snapshot)[:MAX_QUEUE_ROWS]
141
+ ]
142
+ except Exception as exc:
143
+ error = str(exc)[:200]
144
+ labels: dict[str, dict[str, Any]] = {}
145
+ for rid in {str(r.get("run_id") or "") for r in rows} - {""}:
146
+ labels.update(why_by_job(run_dir(ctx.run_root, rid)))
147
+ for r in rows:
148
+ rid = str(r.get("run_id") or "")
149
+ name = str(r.get("name") or "")
150
+ prefix = f"{rid}-launch-" if rid else ""
151
+ r["mine"] = bool(rid) and rid == ctx.run_id
152
+ job_id = str(r.get("id") or "")
153
+ throttle = _ARRAY_THROTTLE.search(job_id)
154
+ if throttle:
155
+ r["concurrency"] = int(throttle.group(1))
156
+ if prefix and name.startswith(prefix):
157
+ r["experiment"] = name[len(prefix) :]
158
+ # the ledger keys the array's id; squeue shows `<id>_<k>` or `<id>_[...]`
159
+ base = job_id.split("_", 1)[0]
160
+ label = labels.get(base, {})
161
+ r["why"] = str(label.get("why", ""))[:MAX_WHY_CHARS]
162
+ r["kind"] = "launch"
163
+ # the pace: the pending range says it; once only running tasks
164
+ # are left, the ledger does (the whole array when unset)
165
+ array = int(label.get("array") or 1)
166
+ if "concurrency" not in r and array > 1:
167
+ r["concurrency"] = int(label.get("concurrency") or array)
168
+ else:
169
+ r["experiment"] = ""
170
+ r["why"] = ""
171
+ r["kind"] = _kind(name)
172
+ view = {
173
+ "at": now,
174
+ "run_id": ctx.run_id,
175
+ "agent": ctx.agent_id,
176
+ "jobs": rows,
177
+ "lane": self._lane_load(),
178
+ "error": error,
179
+ }
180
+ self._queue_cache, self._queue_at = view, now
181
+ return view
182
+
183
+ def _lane_load(self) -> dict[str, Any]:
184
+ """The GPU lane's node states, refreshed once per `lane_gap_s` (a
185
+ second scheduler query per request would defeat the rate limit). A
186
+ failed sinfo is reported in the answer, never passed off as a lane
187
+ with no state."""
188
+ ctx = self.ctx
189
+ load = getattr(ctx.compute, "lane_load", None)
190
+ if load is None or not ctx.gpu_partition:
191
+ return {}
192
+ now = ctx.clock()
193
+ if self._lane_cache is not None and now - self._lane_at < ctx.lane_gap_s:
194
+ return self._lane_cache
195
+ try:
196
+ lane: dict[str, Any] = {
197
+ "partition": ctx.gpu_partition,
198
+ "nodes": load(ctx.gpu_partition),
199
+ }
200
+ except Exception as exc:
201
+ lane = {"partition": ctx.gpu_partition, "nodes": {}, "error": str(exc)[:200]}
202
+ self._lane_cache, self._lane_at = lane, now
203
+ return lane