outerloop-science 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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 +2 -2
- outerloop/appauth.py +17 -0
- outerloop/attempt.py +376 -101
- outerloop/brief.py +38 -25
- outerloop/cli.py +104 -6
- outerloop/climbboard.py +67 -22
- outerloop/compute.py +148 -53
- outerloop/contract.py +8 -0
- outerloop/dispatch.py +63 -18
- outerloop/evalcache.py +147 -0
- outerloop/followup.py +40 -25
- outerloop/github.py +67 -22
- outerloop/harness.py +22 -47
- outerloop/housekeeping.py +1 -17
- outerloop/image.py +0 -4
- outerloop/init.py +45 -2
- outerloop/intake.py +4 -7
- outerloop/launchlog.py +239 -0
- outerloop/maintain.py +353 -0
- outerloop/maintain_agent_cli.py +81 -0
- outerloop/maintain_post_cli.py +140 -0
- outerloop/measure.py +6 -0
- outerloop/orchestrator.py +141 -31
- outerloop/panel.py +3 -3
- outerloop/review.py +4 -0
- outerloop/review_agent.py +7 -7
- outerloop/review_agent_cli.py +2 -2
- outerloop/review_post_cli.py +2 -2
- outerloop/review_summarize_cli.py +8 -6
- outerloop/roles.py +27 -0
- outerloop/rolespec.py +3 -1
- outerloop/steward.py +7 -14
- outerloop/syscall.py +261 -47
- outerloop/syscall_cli.py +243 -12
- outerloop/tick.py +274 -313
- outerloop/verify_agent.py +8 -6
- outerloop/verify_post_cli.py +2 -2
- outerloop/watcher.py +203 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/METADATA +4 -1
- outerloop_science-0.1.0.dev4.dist-info/RECORD +59 -0
- outerloop_science-0.1.0.dev2.dist-info/RECORD +0 -53
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/licenses/NOTICE +0 -0
outerloop/syscall.py
CHANGED
|
@@ -36,13 +36,14 @@ import contextlib
|
|
|
36
36
|
import json
|
|
37
37
|
import os
|
|
38
38
|
import re
|
|
39
|
+
import stat
|
|
39
40
|
from collections.abc import Callable, Iterable
|
|
40
41
|
from dataclasses import dataclass, replace
|
|
41
42
|
from pathlib import Path
|
|
42
43
|
from time import monotonic
|
|
43
44
|
from typing import Any
|
|
44
45
|
|
|
45
|
-
from outerloop.brief import
|
|
46
|
+
from outerloop.brief import code_fence
|
|
46
47
|
from outerloop.compute import GONE
|
|
47
48
|
|
|
48
49
|
# The syscall channel dir in the workspace. New runs install `.outerloop/`;
|
|
@@ -87,6 +88,10 @@ MAX_LAUNCH_ARRAY = 16
|
|
|
87
88
|
MAX_COMMAND_CHARS = 2_000
|
|
88
89
|
MAX_ARTIFACTS_PER_LAUNCH = 8
|
|
89
90
|
MAX_NOTE_CHARS = 2_000
|
|
91
|
+
# a launch's one-line reason, shown to every agent in the queue view
|
|
92
|
+
MAX_WHY_CHARS = 200
|
|
93
|
+
# the author's write-up at submit: hypothesis, what ran, what was measured, why merge
|
|
94
|
+
MAX_REPORT_CHARS = 8_000
|
|
90
95
|
# Per-job walltime ask, clamped to the same ceiling as dispatched evals.
|
|
91
96
|
MAX_LAUNCH_MINUTES = 240
|
|
92
97
|
# a submit's declared eval walltime: bounded only by the GPU-hour budget the
|
|
@@ -127,6 +132,11 @@ class Launch:
|
|
|
127
132
|
# a sweep: N jobs of this command, each told its index through SWEEP_INDEX;
|
|
128
133
|
# one launch against depth_k, N times the walltime against GPU-hours
|
|
129
134
|
array: int = 1
|
|
135
|
+
# the author's one-line reason; the queue view shows it to every agent
|
|
136
|
+
why: str = ""
|
|
137
|
+
# a sweep's pace: at most this many tasks at once (0 = the whole array);
|
|
138
|
+
# the kernel clamps it to the contract's GPU ceiling (`clamp_concurrency`)
|
|
139
|
+
concurrency: int = 0
|
|
130
140
|
|
|
131
141
|
|
|
132
142
|
@dataclass(frozen=True)
|
|
@@ -140,6 +150,9 @@ class SyscallRequest:
|
|
|
140
150
|
# wake returns verdict + gate result to the author (published directly when
|
|
141
151
|
# it clears cleanly). Costs the sleep it rides on, nothing else.
|
|
142
152
|
submit: bool = False
|
|
153
|
+
# the author's report at submit, required with one: it becomes the pull
|
|
154
|
+
# request's research report and the panel reads it against the diff
|
|
155
|
+
report: str = ""
|
|
143
156
|
# The author's declared walltime for the submit's paired gate evals
|
|
144
157
|
# (None = the contract's eval_minutes). Walltime is a budget, never the
|
|
145
158
|
# metric: compute is priced in GPU-hours against the run's budget, so a
|
|
@@ -162,6 +175,7 @@ class LaunchResult:
|
|
|
162
175
|
# code (an untrappable SIGKILL — OOM, walltime kill, node failure — writes
|
|
163
176
|
# none). "" when known from the exit code, unavailable, or unqueried.
|
|
164
177
|
slurm_state: str = ""
|
|
178
|
+
why: str = "" # the launch's reason, echoed with its result
|
|
165
179
|
|
|
166
180
|
|
|
167
181
|
def launch_jobs(launch: Launch) -> tuple[tuple[str, dict[str, str]], ...]:
|
|
@@ -176,6 +190,50 @@ def launch_jobs(launch: Launch) -> tuple[tuple[str, dict[str, str]], ...]:
|
|
|
176
190
|
return tuple((f"{launch.name}.{i}", {"SWEEP_INDEX": str(i)}) for i in range(launch.array))
|
|
177
191
|
|
|
178
192
|
|
|
193
|
+
def array_spec(launch: Launch) -> str:
|
|
194
|
+
"""The Slurm array spec for a sweep: tasks 0..N-1, at most `concurrency`
|
|
195
|
+
at a time (the whole array when unset). "" for a plain launch."""
|
|
196
|
+
if launch.array <= 1:
|
|
197
|
+
return ""
|
|
198
|
+
return f"0-{launch.array - 1}%{launch.concurrency or launch.array}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def clamp_concurrency(
|
|
202
|
+
request: SyscallRequest, *, gpus: int, max_concurrent_gpus: int | None
|
|
203
|
+
) -> SyscallRequest:
|
|
204
|
+
"""Apply the contract's ceiling to every sweep: the tasks one launch may
|
|
205
|
+
run at once is `max_concurrent_gpus // gpus` (a two-GPU task gets half the
|
|
206
|
+
tasks of a one-GPU task and the same share of the machine), never below
|
|
207
|
+
one; a request above it is clamped, never refused. No ceiling: the
|
|
208
|
+
author's pace stands, the whole array by default."""
|
|
209
|
+
if not max_concurrent_gpus:
|
|
210
|
+
return request
|
|
211
|
+
cap = max(1, max_concurrent_gpus // max(gpus, 1))
|
|
212
|
+
launches = tuple(
|
|
213
|
+
replace(la, concurrency=min(la.concurrency or la.array, cap)) if la.array > 1 else la
|
|
214
|
+
for la in request.launches
|
|
215
|
+
)
|
|
216
|
+
return replace(request, launches=launches)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def launch_task_ids(launches: Iterable[Launch], job_ids: list[str]) -> list[str]:
|
|
220
|
+
"""The per-task job ids of a park's launches, aligned with `launch_jobs`
|
|
221
|
+
order (what results, states and refunds are keyed by). A sweep is one
|
|
222
|
+
Slurm job whose tasks are `<id>_<k>`, so one id per launch expands; a park
|
|
223
|
+
recorded before arrays were single jobs already carries one id per task
|
|
224
|
+
and passes through. Anything else is an unknown mapping: no ids, so no
|
|
225
|
+
caller guesses."""
|
|
226
|
+
launches = list(launches)
|
|
227
|
+
if len(job_ids) == len(launches):
|
|
228
|
+
out: list[str] = []
|
|
229
|
+
for la, jid in zip(launches, job_ids, strict=True):
|
|
230
|
+
out.extend([f"{jid}_{k}" for k in range(la.array)] if la.array > 1 else [jid])
|
|
231
|
+
return out
|
|
232
|
+
if len(job_ids) == sum(max(la.array, 1) for la in launches):
|
|
233
|
+
return list(job_ids)
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
|
|
179
237
|
def _rel_path_ok(path: str) -> bool:
|
|
180
238
|
"""A declared artifact must stay inside the job's tree: repo-relative,
|
|
181
239
|
no traversal, no absolute paths. (Same stance as scope normalization.)"""
|
|
@@ -218,7 +276,7 @@ def read_request(workspace: Path) -> SyscallRequest | None:
|
|
|
218
276
|
# so anything else here (e.g. a verdict) is a wrong-type request, not a sleep.
|
|
219
277
|
if data.get("type") != "sleep":
|
|
220
278
|
raise SyscallError(f"expected a sleep syscall, got type {data.get('type')!r}")
|
|
221
|
-
unknown = set(data) - {"type", "launches", "note", "submit", "eval_minutes"}
|
|
279
|
+
unknown = set(data) - {"type", "launches", "note", "submit", "eval_minutes", "report"}
|
|
222
280
|
if unknown:
|
|
223
281
|
raise SyscallError(f"unknown syscall keys: {sorted(unknown)}")
|
|
224
282
|
note = data.get("note", "")
|
|
@@ -234,6 +292,9 @@ def read_request(workspace: Path) -> SyscallRequest | None:
|
|
|
234
292
|
if not submit:
|
|
235
293
|
raise SyscallError("eval_minutes only applies to a submit")
|
|
236
294
|
eval_minutes = min(eval_minutes, MAX_EVAL_MINUTES)
|
|
295
|
+
report = data.get("report", "")
|
|
296
|
+
if not isinstance(report, str) or len(report) > MAX_REPORT_CHARS:
|
|
297
|
+
raise SyscallError(f"report must be a string of at most {MAX_REPORT_CHARS} chars")
|
|
237
298
|
raw_launches = data.get("launches", [])
|
|
238
299
|
if not isinstance(raw_launches, list):
|
|
239
300
|
raise SyscallError("launches must be a list")
|
|
@@ -244,7 +305,7 @@ def read_request(workspace: Path) -> SyscallRequest | None:
|
|
|
244
305
|
for i, item in enumerate(raw_launches):
|
|
245
306
|
if not isinstance(item, dict):
|
|
246
307
|
raise SyscallError(f"launch #{i} must be an object")
|
|
247
|
-
bad = set(item) - {"name", "command", "minutes", "artifacts", "array"}
|
|
308
|
+
bad = set(item) - {"name", "command", "minutes", "artifacts", "array", "why", "concurrency"}
|
|
248
309
|
if bad:
|
|
249
310
|
raise SyscallError(f"launch #{i}: unknown keys {sorted(bad)}")
|
|
250
311
|
name = item.get("name")
|
|
@@ -266,6 +327,16 @@ def read_request(workspace: Path) -> SyscallRequest | None:
|
|
|
266
327
|
if not isinstance(array, int) or isinstance(array, bool) or array < 1:
|
|
267
328
|
raise SyscallError(f"launch {name}: array must be a positive integer")
|
|
268
329
|
array = min(array, MAX_LAUNCH_ARRAY)
|
|
330
|
+
concurrency = item.get("concurrency", 0)
|
|
331
|
+
if not isinstance(concurrency, int) or isinstance(concurrency, bool) or concurrency < 0:
|
|
332
|
+
raise SyscallError(f"launch {name}: concurrency must be a non-negative integer")
|
|
333
|
+
concurrency = min(concurrency, array) if array > 1 else 0
|
|
334
|
+
why = item.get("why", "")
|
|
335
|
+
if not isinstance(why, str) or len(why) > MAX_WHY_CHARS:
|
|
336
|
+
raise SyscallError(
|
|
337
|
+
f"launch {name}: why must be a string of at most {MAX_WHY_CHARS} chars"
|
|
338
|
+
)
|
|
339
|
+
why = " ".join(why.split()) # one line: it is rendered inline where other agents read
|
|
269
340
|
arts = item.get("artifacts", [])
|
|
270
341
|
if not isinstance(arts, list) or len(arts) > MAX_ARTIFACTS_PER_LAUNCH:
|
|
271
342
|
raise SyscallError(
|
|
@@ -278,13 +349,25 @@ def read_request(workspace: Path) -> SyscallRequest | None:
|
|
|
278
349
|
f"launch {name}: artifact {a!r} must be a repo-relative file path"
|
|
279
350
|
)
|
|
280
351
|
launches.append(
|
|
281
|
-
Launch(
|
|
352
|
+
Launch(
|
|
353
|
+
name=name,
|
|
354
|
+
command=command,
|
|
355
|
+
minutes=minutes,
|
|
356
|
+
artifacts=tuple(arts),
|
|
357
|
+
array=array,
|
|
358
|
+
why=why,
|
|
359
|
+
concurrency=concurrency,
|
|
360
|
+
)
|
|
282
361
|
)
|
|
283
362
|
# a sleep with no launches is legitimate: checkpoint-and-reschedule
|
|
284
363
|
# (research-loop.md, "the session clock is visible") — it still burns a
|
|
285
364
|
# sleep count, which is what bounds living forever.
|
|
286
365
|
return SyscallRequest(
|
|
287
|
-
launches=tuple(launches),
|
|
366
|
+
launches=tuple(launches),
|
|
367
|
+
note=note,
|
|
368
|
+
submit=submit,
|
|
369
|
+
eval_minutes=eval_minutes,
|
|
370
|
+
report=report.strip(),
|
|
288
371
|
)
|
|
289
372
|
|
|
290
373
|
|
|
@@ -449,14 +532,16 @@ def _validate_finding(i: int, item: Any) -> dict[str, Any]:
|
|
|
449
532
|
# Absent or "" is legitimately "no category".
|
|
450
533
|
if not isinstance(category, str):
|
|
451
534
|
raise VerdictError(f"finding {file}: category must be a string")
|
|
452
|
-
if category: #
|
|
453
|
-
# (str here, so
|
|
535
|
+
if category: # a non-empty string: the verifier's taxonomy or a digest section
|
|
536
|
+
# (str here, so membership cannot raise on unhashables) — CLAMP an
|
|
454
537
|
# unknown category to "other" rather than reject, the existing verifier
|
|
455
538
|
# stance (verifier.py: "a free-string category must not leak through"),
|
|
456
539
|
# so a taxonomy typo normalizes instead of nuking a verdict.
|
|
540
|
+
from outerloop.maintain import MAINTENANCE_LENSES
|
|
457
541
|
from outerloop.verifier import CATEGORIES
|
|
458
542
|
|
|
459
|
-
|
|
543
|
+
known = category in CATEGORIES or category in MAINTENANCE_LENSES
|
|
544
|
+
out["category"] = category if known else "other"
|
|
460
545
|
return out
|
|
461
546
|
|
|
462
547
|
|
|
@@ -512,6 +597,77 @@ def install_tool(workspace: Path) -> None:
|
|
|
512
597
|
tool.chmod(0o755)
|
|
513
598
|
|
|
514
599
|
|
|
600
|
+
MISSING_REPORT = (
|
|
601
|
+
"a submit needs a report. Write your hypothesis, what you ran and what it measured, "
|
|
602
|
+
"why this should merge and what did not work to a markdown file, stage "
|
|
603
|
+
"`submit --report <file>`, and sleep again."
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def tool_update_note(channel: str) -> str:
|
|
608
|
+
"""What a session that started under an older kernel is told at a wake
|
|
609
|
+
whose tool refresh replaced its tool; `channel` is this workspace's channel
|
|
610
|
+
dir name (a resumed legacy session still has `.autoresearch`)."""
|
|
611
|
+
return (
|
|
612
|
+
"Your syscall tool was updated. `submit` now requires `--report <file>`. The "
|
|
613
|
+
"report explains your hypothesis, what you ran and measured, why this should "
|
|
614
|
+
"merge, and what did not work; it becomes the pull request's research report "
|
|
615
|
+
"and the panel reads it. `launch` accepts `--why` and, with `--array`, "
|
|
616
|
+
"`--concurrency K`. `queue` shows every agent's jobs; `history` shows your "
|
|
617
|
+
f"launches this run. `python {channel}/syscall <verb> --help` has the details."
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def refresh_tool(workspace: Path) -> bool:
|
|
622
|
+
"""Rewrite the installed tool from this kernel's source at a wake, so a
|
|
623
|
+
session that started under an older kernel gets the current verbs and
|
|
624
|
+
flags. Only the tool file changes — the channel and everything in it
|
|
625
|
+
stay — and it is written with a marker's care (a fresh O_EXCL inode,
|
|
626
|
+
renamed into place), so a `syscall` the session replaced with a symlink
|
|
627
|
+
is never written through. Returns whether the tool changed, so the wake
|
|
628
|
+
can tell the author what is new."""
|
|
629
|
+
import shutil
|
|
630
|
+
|
|
631
|
+
from outerloop import syscall_cli
|
|
632
|
+
|
|
633
|
+
source = Path(syscall_cli.__file__).read_bytes()
|
|
634
|
+
dirfd = _channel_fd(workspace)
|
|
635
|
+
try:
|
|
636
|
+
try:
|
|
637
|
+
st = os.stat("syscall", dir_fd=dirfd, follow_symlinks=False)
|
|
638
|
+
except FileNotFoundError:
|
|
639
|
+
st = None
|
|
640
|
+
current: bytes | None = None
|
|
641
|
+
if st is not None and stat.S_ISREG(st.st_mode) and st.st_size == len(source):
|
|
642
|
+
# compare through a non-blocking open checked after the fact: a FIFO
|
|
643
|
+
# swapped in since the stat returns at once instead of waiting for a
|
|
644
|
+
# writer, and anything unreadable (mode 000) simply gets rewritten
|
|
645
|
+
try:
|
|
646
|
+
fd = os.open("syscall", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=dirfd)
|
|
647
|
+
except OSError:
|
|
648
|
+
fd = -1
|
|
649
|
+
if fd >= 0:
|
|
650
|
+
try:
|
|
651
|
+
if stat.S_ISREG(os.fstat(fd).st_mode):
|
|
652
|
+
current = os.read(fd, len(source) + 1)
|
|
653
|
+
except OSError:
|
|
654
|
+
current = None
|
|
655
|
+
finally:
|
|
656
|
+
os.close(fd)
|
|
657
|
+
if current == source:
|
|
658
|
+
return False # already this kernel's tool, readable as it should be
|
|
659
|
+
if st is not None and stat.S_ISDIR(st.st_mode):
|
|
660
|
+
# a directory planted in the tool's place: rename cannot replace
|
|
661
|
+
# it. Removed RELATIVE TO THE CHANNEL FD, never by path — a path
|
|
662
|
+
# would be re-resolved, and a channel swapped for a symlink in
|
|
663
|
+
# between would send the removal outside the workspace
|
|
664
|
+
shutil.rmtree("syscall", dir_fd=dirfd)
|
|
665
|
+
_write_channel(dirfd, "syscall", source, mode=0o755)
|
|
666
|
+
return True
|
|
667
|
+
finally:
|
|
668
|
+
os.close(dirfd)
|
|
669
|
+
|
|
670
|
+
|
|
515
671
|
def write_budget(
|
|
516
672
|
workspace: Path,
|
|
517
673
|
*,
|
|
@@ -604,6 +760,41 @@ def budget_error(
|
|
|
604
760
|
return ""
|
|
605
761
|
|
|
606
762
|
|
|
763
|
+
def _job_outcome(ev: Path) -> tuple[int | None, str, str, tuple[str, ...]]:
|
|
764
|
+
"""What one launch job left in its dir: exit code (None = it died before
|
|
765
|
+
its wrapper ran), stdout/stderr tails, and the copy-out's skip lines."""
|
|
766
|
+
try:
|
|
767
|
+
exit_code: int | None = int((ev / "exit-code").read_text().strip())
|
|
768
|
+
except (OSError, ValueError):
|
|
769
|
+
exit_code = None
|
|
770
|
+
stdout = _read_tail(ev / "stdout", MAX_OUTPUT_CHARS)
|
|
771
|
+
stderr = _read_tail(ev / "stderr", MAX_OUTPUT_CHARS)
|
|
772
|
+
skipped = tuple(ln for ln in _read_text(ev / "artifacts.log").splitlines() if ln.strip())
|
|
773
|
+
return exit_code, stdout, stderr, skipped
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def read_results(run_dir: Path, launches: tuple[Launch, ...]) -> tuple[LaunchResult, ...]:
|
|
777
|
+
"""Each launch job's outcome as it sits in the run dir — no delivery into
|
|
778
|
+
any workspace. For the ledger, and for a wake that publishes without
|
|
779
|
+
resuming the author (`gather_results` is the delivering form)."""
|
|
780
|
+
results: list[LaunchResult] = []
|
|
781
|
+
for launch in launches:
|
|
782
|
+
for job_name, _env in launch_jobs(launch):
|
|
783
|
+
exit_code, stdout, stderr, skipped = _job_outcome(run_dir / f"eval-launch-{job_name}")
|
|
784
|
+
results.append(
|
|
785
|
+
LaunchResult(
|
|
786
|
+
name=job_name,
|
|
787
|
+
exit_code=exit_code,
|
|
788
|
+
stdout_tail=stdout,
|
|
789
|
+
stderr_tail=stderr,
|
|
790
|
+
delivered=(),
|
|
791
|
+
skipped=skipped,
|
|
792
|
+
why=launch.why,
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
return tuple(results)
|
|
796
|
+
|
|
797
|
+
|
|
607
798
|
def gather_results(
|
|
608
799
|
run_dir: Path, workspace: Path, launches: tuple[Launch, ...]
|
|
609
800
|
) -> tuple[LaunchResult, ...]:
|
|
@@ -626,16 +817,7 @@ def gather_results(
|
|
|
626
817
|
# with artifacts under results/<launch>/<i>/
|
|
627
818
|
for i, (job_name, _env) in enumerate(launch_jobs(launch)):
|
|
628
819
|
ev = run_dir / f"eval-launch-{job_name}"
|
|
629
|
-
|
|
630
|
-
exit_code: int | None = int((ev / "exit-code").read_text().strip())
|
|
631
|
-
except (OSError, ValueError):
|
|
632
|
-
exit_code = None
|
|
633
|
-
stdout = _read_tail(ev / "stdout", MAX_OUTPUT_CHARS)
|
|
634
|
-
stderr = _read_tail(ev / "stderr", MAX_OUTPUT_CHARS)
|
|
635
|
-
skipped = tuple(
|
|
636
|
-
ln for ln in _read_text(ev / "artifacts.log").splitlines() if ln.strip()
|
|
637
|
-
)
|
|
638
|
-
|
|
820
|
+
exit_code, stdout, stderr, skipped = _job_outcome(ev)
|
|
639
821
|
delivered, skips = _deliver_artifacts(
|
|
640
822
|
ev / "artifacts", workspace, launch.name, index=i if launch.array > 1 else None
|
|
641
823
|
)
|
|
@@ -647,6 +829,7 @@ def gather_results(
|
|
|
647
829
|
stderr_tail=stderr,
|
|
648
830
|
delivered=delivered,
|
|
649
831
|
skipped=skipped + skips,
|
|
832
|
+
why=launch.why,
|
|
650
833
|
)
|
|
651
834
|
)
|
|
652
835
|
return tuple(results)
|
|
@@ -832,14 +1015,15 @@ def render_wake(
|
|
|
832
1015
|
data-fenced exactly like panel findings."""
|
|
833
1016
|
blocks: list[str] = []
|
|
834
1017
|
for r in results:
|
|
835
|
-
|
|
1018
|
+
why = f" ({r.why})" if r.why else ""
|
|
1019
|
+
lines = [f"launch `{r.name}`{why} — exit code: {_exit_code_line(r)}"]
|
|
836
1020
|
if r.delivered:
|
|
837
1021
|
lines.append("artifacts delivered: " + ", ".join(f"`{p}`" for p in r.delivered))
|
|
838
1022
|
if r.skipped:
|
|
839
1023
|
lines.append("artifacts NOT delivered: " + "; ".join(r.skipped))
|
|
840
1024
|
body = _tail(r.stdout_tail) or "(empty)"
|
|
841
1025
|
err = _tail(r.stderr_tail)
|
|
842
|
-
fence =
|
|
1026
|
+
fence = code_fence(body + err)
|
|
843
1027
|
lines.append(f"stdout (tail):\n{fence}\n{body}\n{fence}")
|
|
844
1028
|
if err:
|
|
845
1029
|
lines.append(f"stderr (tail):\n{fence}\n{err}\n{fence}")
|
|
@@ -852,7 +1036,7 @@ def render_wake(
|
|
|
852
1036
|
joined,
|
|
853
1037
|
]
|
|
854
1038
|
if note:
|
|
855
|
-
fence =
|
|
1039
|
+
fence = code_fence(note)
|
|
856
1040
|
parts.append(f"Your note to yourself:\n{fence}\n{note}\n{fence}")
|
|
857
1041
|
gpu = f", {gpu_hours_remaining:.1f} GPU-hours" if gpu_hours_remaining is not None else ""
|
|
858
1042
|
# Push to keep going ONLY when another launch is actually possible: a launch
|
|
@@ -918,15 +1102,20 @@ def _channel_fd(workspace: Path) -> int:
|
|
|
918
1102
|
return os.open(workspace / channel_dir(workspace), os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
919
1103
|
|
|
920
1104
|
|
|
921
|
-
def _read_done(dirfd: int) -> float:
|
|
1105
|
+
def _read_done(dirfd: int, name: str = SYNC_DONE) -> float:
|
|
922
1106
|
"""The mtime the kernel last acknowledged (stored as marker CONTENT, so
|
|
923
1107
|
no mtime games: hard-linking the marker cannot change another file's
|
|
924
1108
|
times, because the kernel never calls utime)."""
|
|
925
1109
|
try:
|
|
926
|
-
|
|
1110
|
+
# O_NONBLOCK: opening a FIFO planted in the marker's place returns at
|
|
1111
|
+
# once instead of waiting for a writer that never comes (the watcher
|
|
1112
|
+
# thread and the tick would otherwise hang on it forever)
|
|
1113
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=dirfd)
|
|
927
1114
|
except OSError:
|
|
928
1115
|
return 0.0
|
|
929
1116
|
try:
|
|
1117
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
1118
|
+
return 0.0 # a FIFO, socket or device is not a marker the kernel wrote
|
|
930
1119
|
return float(os.read(fd, 64).decode() or 0)
|
|
931
1120
|
except (OSError, ValueError):
|
|
932
1121
|
return 0.0
|
|
@@ -934,10 +1123,30 @@ def _read_done(dirfd: int) -> float:
|
|
|
934
1123
|
os.close(fd)
|
|
935
1124
|
|
|
936
1125
|
|
|
937
|
-
def
|
|
938
|
-
"""
|
|
939
|
-
|
|
940
|
-
|
|
1126
|
+
def _write_channel(dirfd: int, name: str, data: bytes, mode: int = 0o644) -> None:
|
|
1127
|
+
"""Write `data` to `name` in the channel: a fresh O_EXCL temp inode with an
|
|
1128
|
+
unguessable name, then an atomic rename — all relative to the O_NOFOLLOW
|
|
1129
|
+
channel fd. Never opens (and O_TRUNCs) an existing inode, so a session
|
|
1130
|
+
that hard-links a victim file to the temp name gets a failure instead of a
|
|
1131
|
+
truncation; never writes through a planted symlink; never utime()s."""
|
|
1132
|
+
tmp = f".{name}.{os.urandom(8).hex()}"
|
|
1133
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, mode, dir_fd=dirfd)
|
|
1134
|
+
try:
|
|
1135
|
+
# the open's mode is filtered by the umask; the tool must stay executable
|
|
1136
|
+
os.fchmod(fd, mode)
|
|
1137
|
+
view = memoryview(data)
|
|
1138
|
+
while view:
|
|
1139
|
+
written = os.write(fd, view)
|
|
1140
|
+
view = view[written:]
|
|
1141
|
+
finally:
|
|
1142
|
+
os.close(fd)
|
|
1143
|
+
os.replace(tmp, name, src_dir_fd=dirfd, dst_dir_fd=dirfd)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def marker_requested(workspace: Path, request: str, done: str) -> float | None:
|
|
1147
|
+
"""The pending request's mtime, or None. Passed back to `mark_done` so the
|
|
1148
|
+
done marker acknowledges exactly the serviced request — one arriving
|
|
1149
|
+
mid-service stays newer and re-fires. A symlinked channel or request is
|
|
941
1150
|
refused (returns None), never followed."""
|
|
942
1151
|
try:
|
|
943
1152
|
dirfd = _channel_fd(workspace)
|
|
@@ -945,34 +1154,39 @@ def sync_requested(workspace: Path) -> float | None:
|
|
|
945
1154
|
return None
|
|
946
1155
|
try:
|
|
947
1156
|
try:
|
|
948
|
-
st = os.stat(
|
|
1157
|
+
st = os.stat(request, dir_fd=dirfd, follow_symlinks=False)
|
|
949
1158
|
except OSError:
|
|
950
1159
|
return None
|
|
951
1160
|
req_m = st.st_mtime
|
|
952
|
-
return req_m if req_m > _read_done(dirfd) else None
|
|
1161
|
+
return req_m if req_m > _read_done(dirfd, done) else None
|
|
953
1162
|
finally:
|
|
954
1163
|
os.close(dirfd)
|
|
955
1164
|
|
|
956
1165
|
|
|
957
|
-
def
|
|
958
|
-
"""Record the serviced request's mtime as the done marker's CONTENT
|
|
959
|
-
written to a fresh temp inode and renamed into place — all relative to a
|
|
960
|
-
O_NOFOLLOW channel fd. No utime (so a hard-linked marker cannot touch
|
|
961
|
-
another file), no write through a planted symlink (O_NOFOLLOW create),
|
|
962
|
-
no parent-symlink escape (the channel fd was opened O_NOFOLLOW), and the
|
|
963
|
-
rename is atomic."""
|
|
1166
|
+
def mark_done(workspace: Path, done: str, at: float) -> None:
|
|
1167
|
+
"""Record the serviced request's mtime as the done marker's CONTENT."""
|
|
964
1168
|
dirfd = _channel_fd(workspace)
|
|
965
1169
|
try:
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1170
|
+
_write_channel(dirfd, done, f"{at!r}".encode())
|
|
1171
|
+
finally:
|
|
1172
|
+
os.close(dirfd)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def write_channel_json(workspace: Path, name: str, payload: object) -> None:
|
|
1176
|
+
"""A kernel answer the session reads (`queue.json`, `history.json`),
|
|
1177
|
+
written with a marker's care: the session may have replaced anything in
|
|
1178
|
+
the channel while the kernel was not looking."""
|
|
1179
|
+
dirfd = _channel_fd(workspace)
|
|
1180
|
+
try:
|
|
1181
|
+
_write_channel(dirfd, name, json.dumps(payload).encode())
|
|
977
1182
|
finally:
|
|
978
1183
|
os.close(dirfd)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def sync_requested(workspace: Path) -> float | None:
|
|
1187
|
+
"""The pending sync request's mtime, or None (see `marker_requested`)."""
|
|
1188
|
+
return marker_requested(workspace, SYNC_REQUEST, SYNC_DONE)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def mark_synced(workspace: Path, at: float) -> None:
|
|
1192
|
+
mark_done(workspace, SYNC_DONE, at)
|