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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|