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/disk.py ADDED
@@ -0,0 +1,164 @@
1
+ """Disk preflight: refuse to launch work onto storage that cannot hold it.
2
+
3
+ Quota exhaustion is INVISIBLE on some clusters until a write fails: user
4
+ quotas on VAST/NFS homes are not exposed through statvfs (df reports the
5
+ whole filesystem), and rquota RPCs may be administratively blocked — the
6
+ first symptom is EDQUOT from a write that already lost data. (Verified on
7
+ Torch 2026-08-07, the day a full home quota crashed a live climb and its
8
+ error handling with it.)
9
+
10
+ So the preflight is built on two honest signals:
11
+
12
+ - a WRITE PROBE — create, fsync, and remove a small file; this surfaces
13
+ EDQUOT/ENOSPC/EROFS exactly the way real work would hit them, and is the
14
+ only quota check that works everywhere;
15
+ - statvfs free space — meaningful for cluster-level exhaustion (a shared
16
+ scratch filesystem filling up), used as an early-warning threshold where
17
+ the numbers are real.
18
+
19
+ The tick probes before launching sessions; the climb probes before touching
20
+ its run directory. A failed probe skips NEW work and surfaces in the
21
+ heartbeat — it never kills the tick chain.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import errno
28
+ import logging
29
+ import os
30
+ import tempfile
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ PROBE_NAME = ".disk-probe"
37
+ # Enough for a clone + venv + eval scratch with margin; overridable per call.
38
+ DEFAULT_MIN_FREE_BYTES = 10 * 1024**3
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class MountHealth:
43
+ path: str
44
+ writable: bool
45
+ error: str = "" # why the probe failed, when it did
46
+ free_bytes: int = -1 # statvfs; -1 when the query itself failed
47
+ min_free_bytes: int = 0
48
+
49
+ def ok(self) -> bool:
50
+ if not self.writable:
51
+ return False
52
+ # Unknown free space is not a failure: the write probe is the
53
+ # authoritative check, the threshold is early warning on top.
54
+ if self.free_bytes >= 0 and self.min_free_bytes > 0:
55
+ return self.free_bytes >= self.min_free_bytes
56
+ return True
57
+
58
+ def describe(self) -> str:
59
+ state = "ok" if self.ok() else "BLOCKED"
60
+ free = f"{self.free_bytes / 1024**3:.1f}G free" if self.free_bytes >= 0 else "free=?"
61
+ detail = f" ({self.error})" if self.error else ""
62
+ return f"{self.path}: {state}, {free}{detail}"
63
+
64
+
65
+ def probe_writable(path: Path) -> tuple[bool, str]:
66
+ """Try to actually write in `path` the way real work would.
67
+
68
+ fsync is deliberate: quota errors on networked filesystems can be
69
+ deferred until the data is forced out, and a probe that skips the flush
70
+ reports healthy right up to the crash. mkstemp (random name) rather than
71
+ a PID-derived one: a stale probe left by a SIGKILLed process must never
72
+ alias a later PID and read as BLOCKED on healthy storage. Short writes
73
+ are treated as full: ENOSPC can surface as a short count before the
74
+ error, and a probe that ignores the count reports healthy on a full
75
+ mount.
76
+ """
77
+ probe = ""
78
+ try:
79
+ path.mkdir(parents=True, exist_ok=True)
80
+ fd, probe = tempfile.mkstemp(dir=path, prefix=f"{PROBE_NAME}.")
81
+ try:
82
+ payload = b"autoresearch disk probe\n" * 32
83
+ written = 0
84
+ while written < len(payload):
85
+ count = os.write(fd, payload[written:])
86
+ if count <= 0:
87
+ return False, "short write: filesystem refused data without an error"
88
+ written += count
89
+ os.fsync(fd)
90
+ finally:
91
+ os.close(fd)
92
+ return True, ""
93
+ except OSError as exc:
94
+ name = errno.errorcode.get(exc.errno, "") if exc.errno else ""
95
+ return False, f"{name or type(exc).__name__}: {exc}"
96
+ finally:
97
+ if probe:
98
+ with contextlib.suppress(OSError):
99
+ os.unlink(probe)
100
+
101
+
102
+ def free_bytes(path: Path) -> int:
103
+ """Free bytes from statvfs, or -1 when the query fails. Honest for
104
+ cluster-level fullness; blind to per-user quotas on some filesystems —
105
+ that is what the write probe is for."""
106
+ try:
107
+ st = os.statvfs(path)
108
+ return st.f_bavail * st.f_frsize
109
+ except OSError:
110
+ return -1
111
+
112
+
113
+ def check_mount(path: Path, min_free_bytes: int = 0) -> MountHealth:
114
+ writable, error = probe_writable(path)
115
+ return MountHealth(
116
+ path=str(path),
117
+ writable=writable,
118
+ error=error,
119
+ free_bytes=free_bytes(path),
120
+ min_free_bytes=min_free_bytes,
121
+ )
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class DiskHealth:
126
+ """What the tick needs: may new work launch, and what should humans see."""
127
+
128
+ state_root: MountHealth
129
+ home: MountHealth | None = None # warn-only: the agent barely writes there
130
+
131
+ def launch_ok(self) -> bool:
132
+ return self.state_root.ok()
133
+
134
+ def warnings(self) -> list[str]:
135
+ out = []
136
+ if not self.state_root.ok():
137
+ out.append(self.state_root.describe())
138
+ if self.home is not None and not self.home.ok():
139
+ out.append(f"home {self.home.describe()} (warn-only)")
140
+ return out
141
+
142
+ def as_dict(self) -> dict[str, object]:
143
+ d: dict[str, object] = {
144
+ "launch_ok": self.launch_ok(),
145
+ "state_root": self.state_root.__dict__,
146
+ }
147
+ if self.home is not None:
148
+ d["home"] = self.home.__dict__
149
+ return d
150
+
151
+
152
+ def check_disk(
153
+ root: Path,
154
+ min_free_bytes: int = DEFAULT_MIN_FREE_BYTES,
155
+ home: Path | None = None,
156
+ ) -> DiskHealth:
157
+ """Preflight for one tick: the state root gates launches; the home probe
158
+ (default: the real home) is a warn-only signal for humans, because a
159
+ full home breaks logins and tooling long before it breaks the agent."""
160
+ home_path = Path.home() if home is None else home
161
+ return DiskHealth(
162
+ state_root=check_mount(root, min_free_bytes),
163
+ home=check_mount(home_path, 0),
164
+ )