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,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())