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,341 @@
|
|
|
1
|
+
"""One evaluation, supervised by a process that outlives its worker.
|
|
2
|
+
|
|
3
|
+
Decisions document 0029, layer B. The problem this solves has one sentence: a
|
|
4
|
+
Techtree worker that is hard-killed cannot stop anything, so without something
|
|
5
|
+
else watching, the evaluation it started keeps running containers and keeps
|
|
6
|
+
spending until it finishes on its own. Nothing in the pinned engine bounds
|
|
7
|
+
that, and a signal handler in the worker cannot run when the worker is gone.
|
|
8
|
+
|
|
9
|
+
So each variant is launched under a supervisor of its own — one small process,
|
|
10
|
+
in its own session, holding three things and nothing else:
|
|
11
|
+
|
|
12
|
+
*The read end of a pipe the worker holds open.* Nobody ever writes to it. Its
|
|
13
|
+
only message is end-of-file, and the kernel sends that when the last writer
|
|
14
|
+
closes, which includes the worker being killed outright. That is the fast path:
|
|
15
|
+
the eval is stopped within a poll interval of the worker's death rather than
|
|
16
|
+
within the deadline.
|
|
17
|
+
|
|
18
|
+
*A monotonic deadline.* The backstop for everything the pipe cannot see — a
|
|
19
|
+
worker that is alive but wedged, an evaluation that hangs with the pipe still
|
|
20
|
+
open. Measured on the monotonic clock so a system clock change cannot extend
|
|
21
|
+
it.
|
|
22
|
+
|
|
23
|
+
*The eval's process group.* The engine tears its containers down inside the
|
|
24
|
+
rollout it was interrupted in, so it is signalled gently first and the whole
|
|
25
|
+
group is signalled, because the rollouts are grandchildren. Only after the
|
|
26
|
+
grace period does the group get ``SIGKILL``. The grace here is deliberately
|
|
27
|
+
shorter than the worker's own, so that when both are running the inner
|
|
28
|
+
escalation happens first and the worker never kills a supervisor that was
|
|
29
|
+
halfway through a clean stop.
|
|
30
|
+
|
|
31
|
+
This is not a daemon. It has no registry, no socket, no state directory, and no
|
|
32
|
+
life beyond the one evaluation it was started for. What it leaves behind is one
|
|
33
|
+
private record saying what happened, which is the only way anybody can tell an
|
|
34
|
+
orphan-stop from an ordinary finish after the fact. The record carries no argv,
|
|
35
|
+
no environment and no credentials: it is written by a process that had a
|
|
36
|
+
credential in its environment, and a record of a run is not a place for one.
|
|
37
|
+
|
|
38
|
+
Run as ``python -m techtree.verifiers.supervisor``; the worker builds the
|
|
39
|
+
invocation in :mod:`techtree.verifiers.child`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import contextlib
|
|
46
|
+
import errno
|
|
47
|
+
import os
|
|
48
|
+
import selectors
|
|
49
|
+
import signal
|
|
50
|
+
import subprocess
|
|
51
|
+
import sys
|
|
52
|
+
import time
|
|
53
|
+
from datetime import UTC, datetime
|
|
54
|
+
from enum import StrEnum
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
from typing import Final
|
|
57
|
+
|
|
58
|
+
from techtree.fs import atomic_write_json, ensure_private_directory
|
|
59
|
+
from techtree.models.base import JsonValue
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"DEADLINE_EXIT_CODE",
|
|
63
|
+
"RECORD_SCHEMA_VERSION",
|
|
64
|
+
"STOPPED_EXIT_CODE",
|
|
65
|
+
"SUPERVISION_RECORD_MODE",
|
|
66
|
+
"SUPERVISOR_FAILURE_EXIT_CODE",
|
|
67
|
+
"SupervisionReason",
|
|
68
|
+
"main",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
#: The schema of the record this process leaves behind.
|
|
72
|
+
RECORD_SCHEMA_VERSION: Final = "techtree.eval-supervision.v1"
|
|
73
|
+
|
|
74
|
+
#: The deadline was reached and the evaluation was stopped.
|
|
75
|
+
DEADLINE_EXIT_CODE: Final = 124
|
|
76
|
+
|
|
77
|
+
#: The supervisor itself could not do its job — most often, the evaluation
|
|
78
|
+
#: could not be launched at all.
|
|
79
|
+
SUPERVISOR_FAILURE_EXIT_CODE: Final = 125
|
|
80
|
+
|
|
81
|
+
#: The evaluation was stopped rather than allowed to finish, either because the
|
|
82
|
+
#: supervisor was signalled or because its worker died. The conventional Ctrl-C
|
|
83
|
+
#: code, which is what the pinned engine exits with after its own teardown.
|
|
84
|
+
STOPPED_EXIT_CODE: Final = 130
|
|
85
|
+
|
|
86
|
+
#: Owner read and write only, matching every other file Techtree writes.
|
|
87
|
+
SUPERVISION_RECORD_MODE: Final = 0o600
|
|
88
|
+
|
|
89
|
+
#: How often the loop re-checks the eval, the clock, and a pending signal.
|
|
90
|
+
_POLL_INTERVAL_SECONDS: Final = 0.1
|
|
91
|
+
|
|
92
|
+
#: How often a dying process group is re-checked during the grace period.
|
|
93
|
+
_REAP_INTERVAL_SECONDS: Final = 0.05
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SupervisionReason(StrEnum):
|
|
97
|
+
"""Why one supervised evaluation ended. The record's ``reason`` field."""
|
|
98
|
+
|
|
99
|
+
COMPLETED = "completed"
|
|
100
|
+
CANCELLED = "cancelled"
|
|
101
|
+
DEADLINE_EXCEEDED = "deadline_exceeded"
|
|
102
|
+
PARENT_LOST = "parent_lost"
|
|
103
|
+
LAUNCH_FAILED = "launch_failed"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_arguments(argv: list[str]) -> argparse.Namespace:
|
|
107
|
+
"""Read the supervisor's own arguments; everything after ``--`` is the eval."""
|
|
108
|
+
parser = argparse.ArgumentParser(
|
|
109
|
+
prog="techtree-eval-supervisor",
|
|
110
|
+
description="Bound one Verifiers evaluation to its worker's lifetime.",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--variant", required=True)
|
|
113
|
+
parser.add_argument("--parent-fd", required=True, type=int)
|
|
114
|
+
parser.add_argument("--record", required=True, type=Path)
|
|
115
|
+
parser.add_argument("--deadline-seconds", required=True, type=float)
|
|
116
|
+
parser.add_argument("--grace-seconds", required=True, type=float)
|
|
117
|
+
parser.add_argument("eval_argv", nargs=argparse.REMAINDER)
|
|
118
|
+
parsed = parser.parse_args(argv)
|
|
119
|
+
if parsed.eval_argv[:1] == ["--"]:
|
|
120
|
+
parsed.eval_argv = parsed.eval_argv[1:]
|
|
121
|
+
if not parsed.eval_argv:
|
|
122
|
+
parser.error("the evaluation to supervise follows --")
|
|
123
|
+
return parsed
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Supervisor:
|
|
127
|
+
"""The one evaluation this process was started for."""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
variant: str,
|
|
133
|
+
parent_fd: int,
|
|
134
|
+
record_path: Path,
|
|
135
|
+
deadline_seconds: float,
|
|
136
|
+
grace_seconds: float,
|
|
137
|
+
eval_argv: list[str],
|
|
138
|
+
) -> None:
|
|
139
|
+
self._variant = variant
|
|
140
|
+
self._parent_fd = parent_fd
|
|
141
|
+
self._record_path = record_path
|
|
142
|
+
self._deadline_seconds = deadline_seconds
|
|
143
|
+
self._grace_seconds = grace_seconds
|
|
144
|
+
self._eval_argv = eval_argv
|
|
145
|
+
|
|
146
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
147
|
+
self._group: int | None = None
|
|
148
|
+
self._started_at = datetime.now(UTC)
|
|
149
|
+
self._started_monotonic = time.monotonic()
|
|
150
|
+
self._signalled = False
|
|
151
|
+
self._escalated = False
|
|
152
|
+
self._shutdown_seconds: float | None = None
|
|
153
|
+
|
|
154
|
+
# -- the loop ---------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def run(self) -> int:
|
|
157
|
+
"""Supervise the evaluation and return the code this process exits with."""
|
|
158
|
+
signal.signal(signal.SIGTERM, self._on_signal)
|
|
159
|
+
signal.signal(signal.SIGINT, self._on_signal)
|
|
160
|
+
|
|
161
|
+
reason = SupervisionReason.LAUNCH_FAILED
|
|
162
|
+
exit_code = SUPERVISOR_FAILURE_EXIT_CODE
|
|
163
|
+
try:
|
|
164
|
+
self._launch()
|
|
165
|
+
except OSError:
|
|
166
|
+
self._finish(reason=reason, exit_code=exit_code)
|
|
167
|
+
return exit_code
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
reason = self._watch()
|
|
171
|
+
exit_code = self._stop_if_running(reason)
|
|
172
|
+
except Exception:
|
|
173
|
+
# Whatever went wrong in here, an evaluation must not outlive the
|
|
174
|
+
# process that was supposed to bound it. Stop it, then report the
|
|
175
|
+
# supervisor's own failure rather than the eval's.
|
|
176
|
+
reason = SupervisionReason.CANCELLED
|
|
177
|
+
exit_code = SUPERVISOR_FAILURE_EXIT_CODE
|
|
178
|
+
with contextlib.suppress(Exception):
|
|
179
|
+
self._stop_if_running(reason)
|
|
180
|
+
finally:
|
|
181
|
+
self._finish(reason=reason, exit_code=exit_code)
|
|
182
|
+
return exit_code
|
|
183
|
+
|
|
184
|
+
def _finish(self, *, reason: SupervisionReason, exit_code: int) -> None:
|
|
185
|
+
"""Leave the record behind and let go of the worker's pipe."""
|
|
186
|
+
self._write_record(reason=reason, exit_code=exit_code)
|
|
187
|
+
self._close_parent_fd()
|
|
188
|
+
|
|
189
|
+
def _launch(self) -> None:
|
|
190
|
+
"""Start the evaluation in a process group of its own."""
|
|
191
|
+
self._process = subprocess.Popen(
|
|
192
|
+
self._eval_argv,
|
|
193
|
+
stdin=subprocess.DEVNULL,
|
|
194
|
+
start_new_session=True,
|
|
195
|
+
close_fds=True,
|
|
196
|
+
)
|
|
197
|
+
with contextlib.suppress(OSError):
|
|
198
|
+
self._group = os.getpgid(self._process.pid)
|
|
199
|
+
|
|
200
|
+
def _watch(self) -> SupervisionReason:
|
|
201
|
+
"""Wait for the first of: the eval finishing, EOF, the deadline, a signal."""
|
|
202
|
+
process = self._process
|
|
203
|
+
assert process is not None
|
|
204
|
+
deadline = self._started_monotonic + self._deadline_seconds
|
|
205
|
+
|
|
206
|
+
with selectors.DefaultSelector() as selector:
|
|
207
|
+
selector.register(self._parent_fd, selectors.EVENT_READ)
|
|
208
|
+
while True:
|
|
209
|
+
if process.poll() is not None:
|
|
210
|
+
return SupervisionReason.COMPLETED
|
|
211
|
+
if self._signalled:
|
|
212
|
+
return SupervisionReason.CANCELLED
|
|
213
|
+
if time.monotonic() >= deadline:
|
|
214
|
+
return SupervisionReason.DEADLINE_EXCEEDED
|
|
215
|
+
if selector.select(timeout=_POLL_INTERVAL_SECONDS) and self._at_eof():
|
|
216
|
+
return SupervisionReason.PARENT_LOST
|
|
217
|
+
|
|
218
|
+
def _at_eof(self) -> bool:
|
|
219
|
+
"""Whether the worker's end of the liveness pipe has closed.
|
|
220
|
+
|
|
221
|
+
Nothing is ever written to this pipe, so anything readable is either
|
|
222
|
+
end-of-file or a byte nobody sent. A byte is not a reason to stop an
|
|
223
|
+
evaluation, so only the empty read counts.
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
return os.read(self._parent_fd, 1) == b""
|
|
227
|
+
except OSError as error:
|
|
228
|
+
# Nothing to read yet is not a death; anything else means the pipe
|
|
229
|
+
# can no longer answer the question, which is treated as one.
|
|
230
|
+
return error.errno != errno.EAGAIN
|
|
231
|
+
|
|
232
|
+
# -- stopping ---------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _stop_if_running(self, reason: SupervisionReason) -> int:
|
|
235
|
+
"""Return the exit code for ``reason``, stopping the eval if it still runs."""
|
|
236
|
+
process = self._process
|
|
237
|
+
assert process is not None
|
|
238
|
+
if reason == SupervisionReason.COMPLETED:
|
|
239
|
+
return _exit_code_of(process.returncode)
|
|
240
|
+
|
|
241
|
+
started = time.monotonic()
|
|
242
|
+
self._signal_group(signal.SIGTERM)
|
|
243
|
+
grace_until = started + max(self._grace_seconds, 0.0)
|
|
244
|
+
while time.monotonic() < grace_until:
|
|
245
|
+
if process.poll() is not None:
|
|
246
|
+
break
|
|
247
|
+
time.sleep(_REAP_INTERVAL_SECONDS)
|
|
248
|
+
else:
|
|
249
|
+
if process.poll() is None:
|
|
250
|
+
self._escalated = True
|
|
251
|
+
self._signal_group(signal.SIGKILL)
|
|
252
|
+
process.wait()
|
|
253
|
+
self._shutdown_seconds = time.monotonic() - started
|
|
254
|
+
|
|
255
|
+
if reason == SupervisionReason.DEADLINE_EXCEEDED:
|
|
256
|
+
return DEADLINE_EXIT_CODE
|
|
257
|
+
return STOPPED_EXIT_CODE
|
|
258
|
+
|
|
259
|
+
def _signal_group(self, number: int) -> None:
|
|
260
|
+
"""Signal the evaluation's whole group, tolerating a race with its exit."""
|
|
261
|
+
process = self._process
|
|
262
|
+
assert process is not None
|
|
263
|
+
if self._group is not None:
|
|
264
|
+
try:
|
|
265
|
+
os.killpg(self._group, number)
|
|
266
|
+
return
|
|
267
|
+
except ProcessLookupError:
|
|
268
|
+
return
|
|
269
|
+
except PermissionError:
|
|
270
|
+
pass
|
|
271
|
+
with contextlib.suppress(ProcessLookupError):
|
|
272
|
+
process.send_signal(number)
|
|
273
|
+
|
|
274
|
+
def _on_signal(self, _number: int, _frame: object) -> None:
|
|
275
|
+
"""Record that a stop was asked for; the loop does the stopping."""
|
|
276
|
+
self._signalled = True
|
|
277
|
+
|
|
278
|
+
# -- what it leaves behind --------------------------------------------
|
|
279
|
+
|
|
280
|
+
def _write_record(self, *, reason: SupervisionReason, exit_code: int) -> None:
|
|
281
|
+
"""Write the private supervision record.
|
|
282
|
+
|
|
283
|
+
Deliberately last and deliberately unconditional: the record is the
|
|
284
|
+
only evidence that a hard-killed worker's evaluation was stopped, and a
|
|
285
|
+
record that is only written on the happy path proves the wrong half.
|
|
286
|
+
"""
|
|
287
|
+
process = self._process
|
|
288
|
+
finished = datetime.now(UTC)
|
|
289
|
+
document: dict[str, JsonValue] = {
|
|
290
|
+
"schema_version": RECORD_SCHEMA_VERSION,
|
|
291
|
+
"variant": self._variant,
|
|
292
|
+
"reason": reason.value,
|
|
293
|
+
"started_at": self._started_at.isoformat(),
|
|
294
|
+
"finished_at": finished.isoformat(),
|
|
295
|
+
"elapsed_seconds": round(time.monotonic() - self._started_monotonic, 3),
|
|
296
|
+
"deadline_seconds": self._deadline_seconds,
|
|
297
|
+
"grace_seconds": self._grace_seconds,
|
|
298
|
+
"supervisor_pid": os.getpid(),
|
|
299
|
+
"eval_pid": None if process is None else process.pid,
|
|
300
|
+
"eval_process_group": self._group,
|
|
301
|
+
# As the operating system reported it: negative means the
|
|
302
|
+
# evaluation was killed by that signal rather than exiting.
|
|
303
|
+
"eval_exit_code": None if process is None else process.returncode,
|
|
304
|
+
"supervisor_exit_code": exit_code,
|
|
305
|
+
"escalated_to_sigkill": self._escalated,
|
|
306
|
+
"shutdown_seconds": (
|
|
307
|
+
None
|
|
308
|
+
if self._shutdown_seconds is None
|
|
309
|
+
else round(self._shutdown_seconds, 3)
|
|
310
|
+
),
|
|
311
|
+
}
|
|
312
|
+
with contextlib.suppress(OSError):
|
|
313
|
+
ensure_private_directory(self._record_path.parent)
|
|
314
|
+
atomic_write_json(self._record_path, document, mode=SUPERVISION_RECORD_MODE)
|
|
315
|
+
|
|
316
|
+
def _close_parent_fd(self) -> None:
|
|
317
|
+
"""Release the worker's liveness pipe."""
|
|
318
|
+
with contextlib.suppress(OSError):
|
|
319
|
+
os.close(self._parent_fd)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _exit_code_of(returncode: int) -> int:
|
|
323
|
+
"""Return one process's exit status as a shell would report it."""
|
|
324
|
+
return 128 + (-returncode) if returncode < 0 else returncode
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main(argv: list[str] | None = None) -> int:
|
|
328
|
+
"""Supervise one evaluation and return this process's exit code."""
|
|
329
|
+
parsed = _parse_arguments(sys.argv[1:] if argv is None else argv)
|
|
330
|
+
return _Supervisor(
|
|
331
|
+
variant=parsed.variant,
|
|
332
|
+
parent_fd=parsed.parent_fd,
|
|
333
|
+
record_path=parsed.record,
|
|
334
|
+
deadline_seconds=parsed.deadline_seconds,
|
|
335
|
+
grace_seconds=parsed.grace_seconds,
|
|
336
|
+
eval_argv=list(parsed.eval_argv),
|
|
337
|
+
).run()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__": # pragma: no cover - exercised as a subprocess
|
|
341
|
+
raise SystemExit(main())
|