techtree 0.1.0__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 (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,633 @@
1
+ """One live evaluation child process. Spec section 6.10.
2
+
3
+ Three rules shape this module, and each of them is a finding rather than a
4
+ preference.
5
+
6
+ *The engine's ``eval`` is addressed by absolute path.* The pinned build installs
7
+ its console script under the bare, generic name ``eval``
8
+ (``docs/verifiers-pin.md``, finding C3). Resolving that through ``PATH`` would
9
+ let the shell's builtin, or anything else on the machine called ``eval``, answer
10
+ for the managed engine. :class:`~techtree.engines.registry.EngineRegistry`
11
+ resolves it inside the engine's own virtual environment and the resolved path is
12
+ what is executed.
13
+
14
+ *Standard output is redirected to a file and never streamed.* With ``rich``
15
+ disabled the pinned CLI prints every trace to stdout as indented JSON once the
16
+ run finishes (``docs/verifiers-eval.md``). Those are the subject's full
17
+ transcripts. A host agent that saw them would be reading the participant's
18
+ private evidence out of its own terminal, so the child's descriptors point at
19
+ run-owned files from the moment it starts and nothing here ever returns their
20
+ contents.
21
+
22
+ *Cancellation goes to the process group, gently first.* The pinned CLI installs
23
+ an interrupt handler so the first signal raises ``KeyboardInterrupt`` inside
24
+ each rollout, letting its ``finally`` tear down the Docker container before the
25
+ process exits ``130``. Killing outright would leave containers running. So
26
+ :meth:`VerifiersChild.terminate` sends ``SIGTERM`` to the whole group, waits out
27
+ a grace period, and only then escalates.
28
+
29
+ *Nothing here can run once this process is gone.* Every rule above describes a
30
+ worker that is alive to apply it, and a worker that is hard-killed applies
31
+ none of them. So the process this class starts is not the evaluation: it is
32
+ :mod:`techtree.verifiers.supervisor`, holding the read end of a pipe this
33
+ worker keeps open, and the evaluation is that supervisor's own child
34
+ (decisions document 0029, layer B). The worker's death closes the pipe, the
35
+ supervisor reads end-of-file, and the evaluation is stopped by something that
36
+ is still running. The invocation this class reports — its ``argv_digest``, its
37
+ capture headers — remains the underlying evaluation's, because the digest
38
+ identifies the experiment that was run and not the machinery that watched it.
39
+
40
+ The capture files open with a single provenance line naming the variant, the
41
+ start time and the digest of the invocation. It costs one line and buys two
42
+ things: an ``ArtifactRef`` is well formed even when a stream stayed silent, and
43
+ a log copied out of a run directory for diagnosis still says which variant of
44
+ which invocation produced it.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import contextlib
50
+ import os
51
+ import signal
52
+ import subprocess
53
+ import sys
54
+ import time
55
+ from collections.abc import Mapping, Sequence
56
+ from datetime import UTC, datetime
57
+ from pathlib import Path
58
+ from typing import IO, Final, Self
59
+
60
+ from techtree.canonical import sha256_digest_bytes
61
+ from techtree.errors import RunError
62
+ from techtree.fs import ensure_private_directory, fsync_directory
63
+ from techtree.models.base import ArtifactRef, Digest
64
+ from techtree.verifiers.models import EVAL_RUN_NAME, ChildProcessOutcome, VariantName
65
+
66
+ __all__ = [
67
+ "CANCELLATION_EXIT_CODE",
68
+ "CAPTURE_MEDIA_TYPE",
69
+ "CHILD_NOT_STARTED",
70
+ "CHILD_START_FAILED",
71
+ "CHILD_STILL_RUNNING",
72
+ "CONFIG_ARGUMENT_MARKER",
73
+ "DEFAULT_GRACE_SECONDS",
74
+ "DRY_RUN_FLAG",
75
+ "DRY_RUN_NAME",
76
+ "EVAL_EXECUTABLE",
77
+ "OUTPUT_DIR_FLAG",
78
+ "PUSH_DISABLED_FLAG",
79
+ "RUN_NAME_FLAG",
80
+ "SERVE_DISABLED_FLAG",
81
+ "SUPERVISOR_GRACE_SECONDS",
82
+ "SUPERVISOR_MODULE",
83
+ "VARIANT_HARD_DEADLINE_SECONDS",
84
+ "VerifiersChild",
85
+ "argv_digest",
86
+ "capture_artifact",
87
+ "dry_run_argv",
88
+ "eval_argv",
89
+ "supervisor_argv",
90
+ "write_command_log",
91
+ ]
92
+
93
+ #: The pinned engine's evaluation console script. A bare, generic name
94
+ #: (``docs/verifiers-pin.md``, finding C3), so it is only ever handed to
95
+ #: :class:`~techtree.engines.registry.EngineRegistry` or resolved to an absolute
96
+ #: path before being executed — never looked up on ``PATH``.
97
+ EVAL_EXECUTABLE: Final = "eval"
98
+
99
+ #: ``eval`` takes its configuration as ``@`` followed by the path, as two
100
+ #: separate argv entries (``docs/verifiers-eval.md``).
101
+ CONFIG_ARGUMENT_MARKER: Final = "@"
102
+ DRY_RUN_FLAG: Final = "--dry-run"
103
+ OUTPUT_DIR_FLAG: Final = "--output-dir"
104
+
105
+ #: ``--output-dir`` names the directory runs are *grouped* under; the run
106
+ #: itself lands in ``<output-dir>/<run.dir>``, and an unnamed run takes a
107
+ #: random suffix (``docs/verifiers-pin-0.3.1.md``, deviation D2). Evidence
108
+ #: nobody can find the second time is not evidence, so every invocation here
109
+ #: names its own run directory — the validation as ``dry-run`` and the
110
+ #: evaluation as :data:`~techtree.verifiers.models.EVAL_RUN_NAME`.
111
+ RUN_NAME_FLAG: Final = "--run.name"
112
+ DRY_RUN_NAME: Final = "dry-run"
113
+
114
+ #: Belt and braces alongside ``push = false`` in the compiled document. Upstream
115
+ #: defaults to uploading the participant's episodes (``docs/verifiers-eval.md``,
116
+ #: finding E1), and a flag on argv overrides whatever the file says.
117
+ PUSH_DISABLED_FLAG: Final = "--no-push"
118
+
119
+ #: Runs the evaluation in this process rather than through the engine's elastic
120
+ #: env-server worker pool, which is what ``eval`` does by default
121
+ #: (``docs/verifiers-pin-0.3.1.md``, deviation D5). Techtree has always run
122
+ #: in-process and everything downstream assumes it: one child, one process
123
+ #: group, one cancellation path that tears the subject's containers down
124
+ #: (decisions document 0029). Rollouts farmed out to a pool of spawned workers
125
+ #: are none of those things. Carried by the validation as well as by the run,
126
+ #: for the same reason ``--no-push`` is: a flag the engine stopped
127
+ #: understanding should be found before a run starts spending, not after.
128
+ SERVE_DISABLED_FLAG: Final = "--no-serve"
129
+
130
+ #: The conventional Ctrl-C code the pinned CLI exits on after it has torn its
131
+ #: containers down (``docs/verifiers-eval.md``). A stopped run is cancelled,
132
+ #: not invalid.
133
+ CANCELLATION_EXIT_CODE: Final = 130
134
+
135
+ #: How long a terminated child is given to tear its containers down before the
136
+ #: signal is escalated.
137
+ DEFAULT_GRACE_SECONDS: Final = 30.0
138
+
139
+ #: The supervisor's own grace period, and the reason it is shorter than the
140
+ #: worker's. Both are running during an ordinary cancellation, and the inner
141
+ #: escalation has to complete first: a worker that escalated first would
142
+ #: ``SIGKILL`` a supervisor in the middle of a clean teardown and leave the
143
+ #: containers it was tearing down. Decisions document 0029 makes the ordering
144
+ #: an invariant, and a test holds it.
145
+ SUPERVISOR_GRACE_SECONDS: Final = 20.0
146
+
147
+ #: The longest one variant may run, whatever anything else believes. A named
148
+ #: release constant rather than a Campaign field: the Campaign's
149
+ #: ``timeout_seconds`` bounds one subject rollout, and this bounds the whole
150
+ #: supervised evaluation (decisions document 0029, chief resolution 2). This
151
+ #: wall exists for orphan containment, not performance judgment — the
152
+ #: per-episode limits bound the work; this value only has to be unreachable
153
+ #: by a legitimate run while staying finite. 1800 fired on a live canonical
154
+ #: arm whose only sin was provider latency (founder-predeclared raise,
155
+ #: 2026-08-20: 2.9x the worst observed variant, 2x the observed violation).
156
+ VARIANT_HARD_DEADLINE_SECONDS: Final = 3600.0
157
+
158
+ #: The module the worker runs to supervise one evaluation. Executed with this
159
+ #: interpreter, by module name, so it is the Techtree in this environment
160
+ #: rather than whatever a ``PATH`` lookup would have found.
161
+ SUPERVISOR_MODULE: Final = "techtree.verifiers.supervisor"
162
+
163
+ CAPTURE_MEDIA_TYPE: Final = "text/plain"
164
+
165
+ CHILD_NOT_STARTED: Final = "eval_child_not_started"
166
+ CHILD_START_FAILED: Final = "eval_child_start_failed"
167
+ CHILD_STILL_RUNNING: Final = "eval_child_still_running"
168
+
169
+ #: How often :meth:`VerifiersChild.terminate` re-checks a dying child.
170
+ _REAP_INTERVAL_SECONDS: Final = 0.05
171
+
172
+
173
+ def eval_argv(*, eval_executable: Path, input_config_path: Path) -> list[str]:
174
+ """Return the full-path invocation for one variant's real evaluation.
175
+
176
+ The output directory is deliberately absent: the compiled configuration
177
+ already names an absolute one, and repeating it on argv would create a
178
+ second place the two documents could disagree. What the configuration
179
+ cannot name is the run *inside* that directory — ``--output-dir`` only
180
+ groups runs (deviation D2) — so ``--run.name`` pins it here, and the
181
+ evidence lands at
182
+ :meth:`~techtree.verifiers.models.RunPaths.variant_output_dir` rather than
183
+ under a random suffix nobody can find twice.
184
+
185
+ ``--no-serve`` is the other half of the same sentence: without it the
186
+ rollouts are hosted through an elastic worker pool instead of running in
187
+ this child (deviation D5).
188
+
189
+ No credential appears here and none can — the configuration names an
190
+ environment variable, and the engine reads it from the child's environment
191
+ (spec section 6.9).
192
+ """
193
+ return [
194
+ str(eval_executable),
195
+ CONFIG_ARGUMENT_MARKER,
196
+ str(input_config_path),
197
+ PUSH_DISABLED_FLAG,
198
+ SERVE_DISABLED_FLAG,
199
+ RUN_NAME_FLAG,
200
+ EVAL_RUN_NAME,
201
+ ]
202
+
203
+
204
+ def dry_run_argv(*, input_config_path: Path, dry_run_dir: Path) -> list[str]:
205
+ """Return the arguments the engine's ``eval`` script is given for a dry run.
206
+
207
+ ``--output-dir`` redirects the resolved document away from the real run
208
+ directory, so a validation cannot be mistaken later for a truncated run
209
+ (``docs/verifiers-eval.md``, finding E2), and ``--run.name`` pins the
210
+ directory underneath it so the resolved document has one findable path.
211
+ Neither is a setting the experiment turns on — every one of those stays in
212
+ the file.
213
+
214
+ ``--no-push`` and ``--no-serve`` are carried because the run carries them:
215
+ the point of resolving the configuration first is to learn what the run
216
+ would do, and a validation that resolved a hosting mode the run will not
217
+ use would be answering a question nobody asked. The executable itself is
218
+ prepended by :class:`~techtree.engines.runner.EngineRunner`.
219
+ """
220
+ return [
221
+ CONFIG_ARGUMENT_MARKER,
222
+ str(input_config_path),
223
+ DRY_RUN_FLAG,
224
+ PUSH_DISABLED_FLAG,
225
+ SERVE_DISABLED_FLAG,
226
+ RUN_NAME_FLAG,
227
+ DRY_RUN_NAME,
228
+ OUTPUT_DIR_FLAG,
229
+ str(dry_run_dir),
230
+ ]
231
+
232
+
233
+ def supervisor_argv(
234
+ *,
235
+ variant: VariantName,
236
+ parent_fd: int,
237
+ record_path: Path,
238
+ deadline_seconds: float,
239
+ grace_seconds: float,
240
+ eval_argv: Sequence[str],
241
+ ) -> list[str]:
242
+ """Return the invocation that wraps one evaluation in its supervisor.
243
+
244
+ Everything here is a number, a path Techtree owns, or the evaluation's own
245
+ invocation. No credential appears and none can, for exactly the reason it
246
+ cannot appear on the evaluation's own argv: the configuration names an
247
+ environment variable and the value stays in the environment.
248
+ """
249
+ return [
250
+ sys.executable,
251
+ "-m",
252
+ SUPERVISOR_MODULE,
253
+ "--variant",
254
+ variant.value,
255
+ "--parent-fd",
256
+ str(parent_fd),
257
+ "--record",
258
+ str(record_path),
259
+ "--deadline-seconds",
260
+ f"{deadline_seconds:g}",
261
+ "--grace-seconds",
262
+ f"{grace_seconds:g}",
263
+ "--",
264
+ *eval_argv,
265
+ ]
266
+
267
+
268
+ def argv_digest(argv: Sequence[str]) -> Digest:
269
+ """Digest one invocation so it can be cited without being reprinted."""
270
+ return sha256_digest_bytes("\0".join(argv).encode("utf-8"))
271
+
272
+
273
+ def capture_artifact(path: Path) -> ArtifactRef:
274
+ """Hash one capture file and describe it."""
275
+ data = path.read_bytes()
276
+ return ArtifactRef(
277
+ digest=sha256_digest_bytes(data),
278
+ media_type=CAPTURE_MEDIA_TYPE,
279
+ size=len(data),
280
+ relative_path=None,
281
+ )
282
+
283
+
284
+ def write_command_log(
285
+ path: Path,
286
+ *,
287
+ variant: VariantName,
288
+ argv: Sequence[str],
289
+ exit_code: int,
290
+ stdout: str,
291
+ stderr: str,
292
+ ) -> Path:
293
+ """Record what a short captured engine command did. Spec section 6.19.
294
+
295
+ This is the dry run's counterpart to a live child's capture files. A dry run
296
+ is short and its output is model-free, so it is captured in memory and
297
+ written here in one piece rather than streamed.
298
+ """
299
+ ensure_private_directory(path.parent)
300
+ document = "\n".join(
301
+ [
302
+ f"variant: {variant.value}",
303
+ f"argv-digest: {argv_digest(argv)}",
304
+ f"argv: {' '.join(argv)}",
305
+ f"exit-code: {exit_code}",
306
+ "--- stdout ---",
307
+ stdout.rstrip("\n"),
308
+ "--- stderr ---",
309
+ stderr.rstrip("\n"),
310
+ "",
311
+ ]
312
+ )
313
+ _write_private(path, document.encode("utf-8"))
314
+ return path
315
+
316
+
317
+ class VerifiersChild:
318
+ """One ``eval`` process, its capture files, and its outcome.
319
+
320
+ The object is single-use and strictly ordered: construct, :meth:`start`,
321
+ observe through :meth:`poll` or :meth:`wait`, then :meth:`outcome`. Asking
322
+ for an outcome before the process has exited raises rather than guessing,
323
+ because a half-finished child has no finishing time and no final bytes to
324
+ hash.
325
+ """
326
+
327
+ def __init__(
328
+ self,
329
+ *,
330
+ variant: VariantName,
331
+ argv: Sequence[str],
332
+ cwd: Path,
333
+ env: Mapping[str, str],
334
+ stdout_path: Path,
335
+ stderr_path: Path,
336
+ supervision_record_path: Path,
337
+ hard_deadline_seconds: float = VARIANT_HARD_DEADLINE_SECONDS,
338
+ supervisor_grace_seconds: float = SUPERVISOR_GRACE_SECONDS,
339
+ ) -> None:
340
+ self._variant = variant
341
+ self._argv = tuple(argv)
342
+ self._cwd = cwd
343
+ self._env = dict(env)
344
+ self._stdout_path = stdout_path
345
+ self._stderr_path = stderr_path
346
+ self._supervision_record_path = supervision_record_path
347
+ self._hard_deadline_seconds = hard_deadline_seconds
348
+ self._supervisor_grace_seconds = supervisor_grace_seconds
349
+
350
+ self._process: subprocess.Popen[bytes] | None = None
351
+ self._parent_liveness_fd: int | None = None
352
+ self._streams: list[IO[bytes]] = []
353
+ self._started_at: datetime | None = None
354
+ self._started_monotonic: float | None = None
355
+ self._finished_at: datetime | None = None
356
+ self._elapsed_seconds: float | None = None
357
+ self._cancelled = False
358
+
359
+ # -- identity ---------------------------------------------------------
360
+
361
+ @property
362
+ def variant(self) -> VariantName:
363
+ """Which side of the comparison this child is running."""
364
+ return self._variant
365
+
366
+ @property
367
+ def argv_digest(self) -> Digest:
368
+ """The digest of this child's invocation."""
369
+ return argv_digest(self._argv)
370
+
371
+ @property
372
+ def pid(self) -> int | None:
373
+ """The child's process id once it has started."""
374
+ return None if self._process is None else self._process.pid
375
+
376
+ @property
377
+ def cancelled(self) -> bool:
378
+ """Whether this child was asked to stop rather than allowed to finish."""
379
+ return self._cancelled
380
+
381
+ @property
382
+ def elapsed_seconds(self) -> float | None:
383
+ """How long the child ran, measured on the monotonic clock."""
384
+ return self._elapsed_seconds
385
+
386
+ # -- lifecycle --------------------------------------------------------
387
+
388
+ def start(self) -> int:
389
+ """Start the supervised evaluation and return the supervisor's pid.
390
+
391
+ A new session means the whole subprocess tree — the supervisor, the
392
+ engine, its Docker client, anything any of them spawns — can be
393
+ signalled as one group, and that the operator's own Ctrl-C in the
394
+ foreground terminal does not reach an evaluation mid-rollout and orphan
395
+ its containers.
396
+
397
+ The pipe is the part that survives this process. Its read end is the
398
+ only descriptor handed to the supervisor and its write end is held here
399
+ and never written to, so the supervisor's read returns end-of-file at
400
+ the moment this worker stops existing — including the moment it is
401
+ killed outright, when no code here gets to run at all.
402
+ """
403
+ if self._process is not None:
404
+ raise RunError(
405
+ f"the {self._variant.value} evaluation child has already started",
406
+ code=CHILD_START_FAILED,
407
+ details={"variant": self._variant.value},
408
+ )
409
+
410
+ ensure_private_directory(self._cwd)
411
+ stdout = self._open_capture(self._stdout_path, stream="stdout")
412
+ stderr = self._open_capture(self._stderr_path, stream="stderr")
413
+
414
+ read_fd, write_fd = os.pipe()
415
+ self._parent_liveness_fd = write_fd
416
+ launch = supervisor_argv(
417
+ variant=self._variant,
418
+ parent_fd=read_fd,
419
+ record_path=self._supervision_record_path,
420
+ deadline_seconds=self._hard_deadline_seconds,
421
+ grace_seconds=self._supervisor_grace_seconds,
422
+ eval_argv=self._argv,
423
+ )
424
+
425
+ self._started_at = datetime.now(UTC)
426
+ self._started_monotonic = time.monotonic()
427
+ try:
428
+ self._process = subprocess.Popen(
429
+ launch,
430
+ cwd=str(self._cwd),
431
+ env=self._env,
432
+ stdin=subprocess.DEVNULL,
433
+ stdout=stdout,
434
+ stderr=stderr,
435
+ start_new_session=True,
436
+ close_fds=True,
437
+ pass_fds=(read_fd,),
438
+ )
439
+ except OSError as error:
440
+ self._close_streams()
441
+ self._close_parent_liveness()
442
+ raise RunError(
443
+ f"the evaluation child could not be started: {error.strerror or error}",
444
+ code=CHILD_START_FAILED,
445
+ details={"variant": self._variant.value, "program": self._argv[0]},
446
+ ) from error
447
+ finally:
448
+ # The worker must not be a second writer to its own liveness pipe,
449
+ # and it must not hold the reader open either: only the supervisor
450
+ # reads, and only this process writes.
451
+ os.close(read_fd)
452
+ return self._process.pid
453
+
454
+ def poll(self) -> int | None:
455
+ """Return the exit code, or ``None`` while the child is still running."""
456
+ process = self._require_started()
457
+ code = process.poll()
458
+ if code is not None:
459
+ self._record_exit()
460
+ return code
461
+
462
+ def wait(self, timeout: float | None = None) -> int:
463
+ """Wait for the child and return its exit code."""
464
+ process = self._require_started()
465
+ try:
466
+ code = process.wait(timeout=timeout)
467
+ except subprocess.TimeoutExpired as error:
468
+ raise RunError(
469
+ f"the {self._variant.value} evaluation did not finish within "
470
+ f"{timeout:.0f}s",
471
+ code=CHILD_STILL_RUNNING,
472
+ details={"variant": self._variant.value, "timeout_seconds": timeout},
473
+ retryable=True,
474
+ ) from error
475
+ self._record_exit()
476
+ return code
477
+
478
+ def terminate(self, grace_seconds: float = DEFAULT_GRACE_SECONDS) -> None:
479
+ """Stop the child's process group, allowing Docker teardown first.
480
+
481
+ ``SIGTERM`` reaches every process in the group, which is what lets the
482
+ pinned CLI's interrupt handler run each rollout's cleanup. Only after
483
+ the grace period does the group get ``SIGKILL``; escalating sooner would
484
+ buy a faster exit at the price of containers nobody owns any more.
485
+ """
486
+ process = self._require_started()
487
+ self._cancelled = True
488
+ if process.poll() is not None:
489
+ self._record_exit()
490
+ return
491
+
492
+ self._signal_group(signal.SIGTERM)
493
+ deadline = time.monotonic() + max(grace_seconds, 0.0)
494
+ while time.monotonic() < deadline:
495
+ if process.poll() is not None:
496
+ self._record_exit()
497
+ return
498
+ time.sleep(_REAP_INTERVAL_SECONDS)
499
+
500
+ self._signal_group(signal.SIGKILL)
501
+ process.wait()
502
+ self._record_exit()
503
+
504
+ def outcome(self) -> ChildProcessOutcome:
505
+ """Describe the finished child, with both capture files hashed."""
506
+ process = self._require_started()
507
+ code = process.poll()
508
+ if code is None:
509
+ raise RunError(
510
+ f"the {self._variant.value} evaluation child is still running",
511
+ code=CHILD_STILL_RUNNING,
512
+ details={"variant": self._variant.value},
513
+ )
514
+ self._record_exit()
515
+ assert self._started_at is not None
516
+ assert self._finished_at is not None
517
+
518
+ return ChildProcessOutcome(
519
+ variant=self._variant,
520
+ argv_digest=self.argv_digest,
521
+ exit_code=code,
522
+ started_at=self._started_at,
523
+ finished_at=self._finished_at,
524
+ stdout_artifact=capture_artifact(self._stdout_path),
525
+ stderr_artifact=capture_artifact(self._stderr_path),
526
+ cancelled=self._cancelled,
527
+ )
528
+
529
+ # -- context management ----------------------------------------------
530
+
531
+ def __enter__(self) -> Self:
532
+ """Start the child and return it."""
533
+ self.start()
534
+ return self
535
+
536
+ def __exit__(self, *_: object) -> None:
537
+ """Stop a child that is still running, then release its descriptors."""
538
+ if self._process is not None and self._process.poll() is None:
539
+ self.terminate()
540
+ self._close_streams()
541
+ self._close_parent_liveness()
542
+
543
+ # -- internals ---------------------------------------------------------
544
+
545
+ def _require_started(self) -> subprocess.Popen[bytes]:
546
+ """Return the running process, or refuse."""
547
+ if self._process is None:
548
+ raise RunError(
549
+ f"the {self._variant.value} evaluation child has not been started",
550
+ code=CHILD_NOT_STARTED,
551
+ details={"variant": self._variant.value},
552
+ )
553
+ return self._process
554
+
555
+ def _open_capture(self, path: Path, *, stream: str) -> IO[bytes]:
556
+ """Create one capture file, seeded with its provenance line."""
557
+ ensure_private_directory(path.parent)
558
+ header = (
559
+ f"# techtree {self._variant.value} {stream}; "
560
+ f"argv-digest {self.argv_digest}; "
561
+ f"started {datetime.now(UTC).isoformat()}\n"
562
+ )
563
+ handle = _open_private(path)
564
+ handle.write(header.encode("utf-8"))
565
+ handle.flush()
566
+ self._streams.append(handle)
567
+ return handle
568
+
569
+ def _close_streams(self) -> None:
570
+ """Flush and release the capture descriptors this child opened."""
571
+ while self._streams:
572
+ handle = self._streams.pop()
573
+ try:
574
+ handle.flush()
575
+ os.fsync(handle.fileno())
576
+ except (OSError, ValueError):
577
+ pass
578
+ handle.close()
579
+
580
+ def _record_exit(self) -> None:
581
+ """Fix the finishing time once, and let go of the capture files."""
582
+ if self._finished_at is not None:
583
+ return
584
+ self._finished_at = datetime.now(UTC)
585
+ if self._started_monotonic is not None:
586
+ self._elapsed_seconds = time.monotonic() - self._started_monotonic
587
+ self._close_streams()
588
+ self._close_parent_liveness()
589
+
590
+ def _close_parent_liveness(self) -> None:
591
+ """Close this worker's end of the supervisor's liveness pipe.
592
+
593
+ Idempotent, and safe to call on a child that never started. The
594
+ supervisor treats the close as this worker's death, so it is only ever
595
+ done once the evaluation has already finished or been stopped.
596
+ """
597
+ descriptor = self._parent_liveness_fd
598
+ if descriptor is None:
599
+ return
600
+ self._parent_liveness_fd = None
601
+ with contextlib.suppress(OSError):
602
+ os.close(descriptor)
603
+
604
+ def _signal_group(self, number: int) -> None:
605
+ """Signal the child's whole process group, tolerating a race with exit."""
606
+ process = self._require_started()
607
+ try:
608
+ os.killpg(os.getpgid(process.pid), number)
609
+ except ProcessLookupError:
610
+ # The child exited between the poll and the signal. Nothing to stop.
611
+ return
612
+ except PermissionError:
613
+ # Not ours to signal as a group; fall back to the child itself.
614
+ with contextlib.suppress(ProcessLookupError):
615
+ process.send_signal(number)
616
+
617
+
618
+ def _open_private(path: Path) -> IO[bytes]:
619
+ """Open a capture file for writing with private permissions."""
620
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
621
+ if hasattr(os, "O_NOFOLLOW"):
622
+ flags |= os.O_NOFOLLOW
623
+ descriptor = os.open(path, flags, 0o600)
624
+ return os.fdopen(descriptor, "wb")
625
+
626
+
627
+ def _write_private(path: Path, data: bytes) -> None:
628
+ """Write one whole private file and flush it to disk."""
629
+ with _open_private(path) as handle:
630
+ handle.write(data)
631
+ handle.flush()
632
+ os.fsync(handle.fileno())
633
+ fsync_directory(path.parent)