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.
Files changed (45) hide show
  1. outerloop/__init__.py +2 -2
  2. outerloop/appauth.py +17 -0
  3. outerloop/attempt.py +376 -101
  4. outerloop/brief.py +38 -25
  5. outerloop/cli.py +104 -6
  6. outerloop/climbboard.py +67 -22
  7. outerloop/compute.py +148 -53
  8. outerloop/contract.py +8 -0
  9. outerloop/dispatch.py +63 -18
  10. outerloop/evalcache.py +147 -0
  11. outerloop/followup.py +40 -25
  12. outerloop/github.py +67 -22
  13. outerloop/harness.py +22 -47
  14. outerloop/housekeeping.py +1 -17
  15. outerloop/image.py +0 -4
  16. outerloop/init.py +45 -2
  17. outerloop/intake.py +4 -7
  18. outerloop/launchlog.py +239 -0
  19. outerloop/maintain.py +353 -0
  20. outerloop/maintain_agent_cli.py +81 -0
  21. outerloop/maintain_post_cli.py +140 -0
  22. outerloop/measure.py +6 -0
  23. outerloop/orchestrator.py +141 -31
  24. outerloop/panel.py +3 -3
  25. outerloop/review.py +4 -0
  26. outerloop/review_agent.py +7 -7
  27. outerloop/review_agent_cli.py +2 -2
  28. outerloop/review_post_cli.py +2 -2
  29. outerloop/review_summarize_cli.py +8 -6
  30. outerloop/roles.py +27 -0
  31. outerloop/rolespec.py +3 -1
  32. outerloop/steward.py +7 -14
  33. outerloop/syscall.py +261 -47
  34. outerloop/syscall_cli.py +243 -12
  35. outerloop/tick.py +274 -313
  36. outerloop/verify_agent.py +8 -6
  37. outerloop/verify_post_cli.py +2 -2
  38. outerloop/watcher.py +203 -0
  39. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/METADATA +4 -1
  40. outerloop_science-0.1.0.dev4.dist-info/RECORD +59 -0
  41. outerloop_science-0.1.0.dev2.dist-info/RECORD +0 -53
  42. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/WHEEL +0 -0
  43. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/entry_points.txt +0 -0
  44. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
  45. {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 _fence
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(name=name, command=command, minutes=minutes, artifacts=tuple(arts), array=array)
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), note=note, submit=submit, eval_minutes=eval_minutes
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: # verifier-only; a non-empty string
453
- # (str here, so `in CATEGORIES` cannot raise on unhashables) — CLAMP an
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
- out["category"] = category if category in CATEGORIES else "other"
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
- try:
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
- lines = [f"launch `{r.name}` — exit code: {_exit_code_line(r)}"]
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 = _fence(body + err)
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 = _fence(note)
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
- fd = os.open(SYNC_DONE, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dirfd)
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 sync_requested(workspace: Path) -> float | None:
938
- """The pending request's mtime, or None. Passed back to mark_synced so
939
- the done marker acknowledges exactly the serviced request — one arriving
940
- mid-fetch stays newer and re-fires. A symlinked channel or request is
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(SYNC_REQUEST, dir_fd=dirfd, follow_symlinks=False)
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 mark_synced(workspace: Path, at: float) -> None:
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
- # O_EXCL + an unguessable name: never open (and O_TRUNC) an existing
967
- # inode. A session that hard-links a victim file to the temp name
968
- # would otherwise have it truncated — O_EXCL fails on any pre-existing
969
- # name instead, and O_NOFOLLOW refuses a symlink.
970
- tmp = f".{SYNC_DONE}.{os.urandom(8).hex()}"
971
- fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o644, dir_fd=dirfd)
972
- try:
973
- os.write(fd, f"{at!r}".encode())
974
- finally:
975
- os.close(fd)
976
- os.replace(tmp, SYNC_DONE, src_dir_fd=dirfd, dst_dir_fd=dirfd)
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)