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
techtree/version.py ADDED
@@ -0,0 +1,39 @@
1
+ """Package, protocol, and CLI-schema versions.
2
+
3
+ Spec section 10.3. This module has no side effects beyond reading installed
4
+ distribution metadata, so it is safe to import from anywhere in the package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import version as distribution_version
11
+
12
+ DISTRIBUTION_NAME = "techtree"
13
+
14
+ #: Version reported when the package is executed straight from a source tree
15
+ #: that has no installed distribution metadata.
16
+ SOURCE_VERSION = "0.0.0+source"
17
+
18
+ PROTOCOL_VERSION = "v1alpha1"
19
+ CLI_SCHEMA_VERSION = "techtree.cli.v1"
20
+
21
+
22
+ def package_version() -> str:
23
+ """Return installed package version through importlib.metadata."""
24
+ try:
25
+ return distribution_version(DISTRIBUTION_NAME)
26
+ except PackageNotFoundError:
27
+ return SOURCE_VERSION
28
+
29
+
30
+ def version_info() -> dict[str, str]:
31
+ """Return package and protocol versions for CLI and Doctor output."""
32
+ return {
33
+ "package_version": package_version(),
34
+ "protocol_version": PROTOCOL_VERSION,
35
+ "cli_schema_version": CLI_SCHEMA_VERSION,
36
+ }
37
+
38
+
39
+ __version__ = package_version()
@@ -0,0 +1,18 @@
1
+ """The detached Techtree run worker. Spec section 19, PR8 §8.9-§8.10.
2
+
3
+ One process, one run, no supervisor. The worker is launched by
4
+ :class:`~techtree.runs.launcher.WorkerLauncher` in its own session so that it
5
+ outlives the command that started it, and everything it learns about the run
6
+ it is executing goes into the run's journal rather than into a terminal
7
+ somebody may already have closed.
8
+
9
+ :mod:`techtree.worker.main` is the argument parser. :mod:`techtree.worker.
10
+ execute` is the process: it announces itself, keeps a heartbeat, installs the
11
+ signal handlers that make cancellation cooperative, hands the work to a
12
+ :class:`~techtree.runs.executor.RunExecutor`, and makes sure the run ends in a
13
+ terminal state no matter how the work ended.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ __all__: list[str] = []
@@ -0,0 +1,487 @@
1
+ """What the detached worker does with a run. Spec PR8 §8.10.
2
+
3
+ This is the only code that runs in the worker process, and it is written for
4
+ the two facts that make that process unusual: nobody is watching it, and it may
5
+ be asked to stop at any moment.
6
+
7
+ Nobody is watching, so everything it learns has to be written down. Its process
8
+ id goes into the run's journal as its first act — spec §9.5 depends on that,
9
+ because a launcher that crashed before recording the pid must still leave a run
10
+ that can be found and signalled. A heartbeat thread then refreshes one file
11
+ every couple of seconds, which is what lets a later reader distinguish "still
12
+ working" from "died holding the run open" without a supervisor.
13
+
14
+ It may be asked to stop, so the signal handlers do the least possible work: set
15
+ a flag, return. Everything else — appending the cancellation, winding down the
16
+ executor, writing the terminal event — happens on the main thread at a boundary
17
+ the executor chose. A handler that took the run lock could deadlock against the
18
+ work it interrupted, and a handler that wrote a file could tear it.
19
+
20
+ Every exit is terminal for the run and specific for the caller. A cancelled run
21
+ exits 130, the shell's convention for interruption. A typed failure exits its
22
+ own documented code. Anything else is a defect: the message is flattened onto
23
+ one line before it is stored, the run is recorded as failed, and the process
24
+ exits 5. In no case does a raw traceback reach ``RunState.error``, which is a
25
+ protocol-visible field that a host agent will read back.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import signal
32
+ import threading
33
+ from collections.abc import Callable
34
+ from datetime import UTC, datetime
35
+ from pathlib import Path
36
+ from types import FrameType
37
+ from typing import Final, Protocol
38
+
39
+ from techtree.canonical import digest_object, to_json_value
40
+ from techtree.constants import DEFAULT_WORKER_HEARTBEAT_SECONDS
41
+ from techtree.engines.registry import EngineRegistry
42
+ from techtree.errors import (
43
+ CancellationError,
44
+ RunError,
45
+ TechtreeError,
46
+ error_to_cli_error,
47
+ exit_code_for,
48
+ stable_exception_message,
49
+ )
50
+ from techtree.identity.service import IdentityService
51
+ from techtree.identity.store import IdentityStore
52
+ from techtree.models.run import RunPhase, RunRequest
53
+ from techtree.models.uplift_report import UpliftReport
54
+ from techtree.paths import TechtreePaths, default_paths, paths_from_root
55
+ from techtree.runs.artifacts import RunArtifactStore
56
+ from techtree.runs.child_registry import ChildRegistry
57
+ from techtree.runs.events import DETAIL_ERROR, RUN_CANCELLED, RUN_FAILED
58
+ from techtree.runs.executor import (
59
+ ExecutionContext,
60
+ request_local_cancellation,
61
+ )
62
+ from techtree.runs.fake import FakeRunExecutor
63
+ from techtree.runs.machine import is_terminal
64
+ from techtree.runs.real import (
65
+ RealVerifiersExecutor,
66
+ executor_kind_for,
67
+ real_execution_result_path,
68
+ )
69
+ from techtree.runs.store import RunStore
70
+ from techtree.runs.validation import TasksetValidationProvider
71
+ from techtree.settings import load_settings
72
+ from techtree.tasksets.provider import worker_validation_provider
73
+ from techtree.uplift.service import RealUpliftReportService
74
+ from techtree.verifiers.models import RealExecutionResult
75
+
76
+ __all__ = [
77
+ "EXIT_CANCELLED",
78
+ "EXIT_UNEXPECTED",
79
+ "REPORT_STAGE_UNAVAILABLE",
80
+ "TECHTREE_HOME_VARIABLE",
81
+ "AnyExecutor",
82
+ "ExecutorFactory",
83
+ "ValidationProviderFactory",
84
+ "execute_run",
85
+ "executor_for",
86
+ "handle_worker_cancelled",
87
+ "handle_worker_failure",
88
+ "heartbeat_loop",
89
+ "install_signal_handlers",
90
+ "validation_provider_for",
91
+ "worker_log",
92
+ "worker_paths",
93
+ ]
94
+
95
+
96
+ class AnyExecutor(Protocol):
97
+ """Anything the worker knows how to run to the end of a run.
98
+
99
+ Two shapes qualify and the difference is what each one is entitled to
100
+ produce. A development executor invents a whole
101
+ :class:`~techtree.models.uplift_report.UpliftReport`. A real executor
102
+ produces a :class:`~techtree.verifiers.models.RealExecutionResult` and stops
103
+ there, because deciding what a measurement means is the report stage's
104
+ (spec section 6.22). Stating the union as a protocol rather than as two
105
+ class names is what lets a test drive the worker's own sequence with an
106
+ executor that replays recorded evidence.
107
+ """
108
+
109
+ def execute(self, context: ExecutionContext) -> UpliftReport | RealExecutionResult:
110
+ """Execute one run and return what it produced."""
111
+ ...
112
+
113
+
114
+ type ExecutorFactory = Callable[[RunRequest], AnyExecutor]
115
+ type ValidationProviderFactory = Callable[[RunRequest], TasksetValidationProvider]
116
+
117
+ #: The shell's convention for "terminated by an interrupt", which is what a
118
+ #: cancelled run is from the caller's point of view.
119
+ EXIT_CANCELLED: Final = 130
120
+
121
+ #: What an unexpected exception exits with. Spec PR8 §8.10 fixes the value.
122
+ EXIT_UNEXPECTED: Final = 5
123
+
124
+ #: How the launcher tells the worker where Techtree keeps its state. The
125
+ #: worker inherits nothing else that could point at a run directory.
126
+ TECHTREE_HOME_VARIABLE: Final = "TECHTREE_HOME"
127
+
128
+ #: What a run whose executor produced neither a report nor complete evaluation
129
+ #: evidence reports. Both executors this build has are handled explicitly, so
130
+ #: this is now a build defect rather than a missing stage; it keeps its code and
131
+ #: its message so that an operator who hits it still learns where the expensive
132
+ #: part of the run was written.
133
+ REPORT_STAGE_UNAVAILABLE: Final = "run_report_stage_unavailable"
134
+
135
+ _HEARTBEAT_SECONDS: Final = float(DEFAULT_WORKER_HEARTBEAT_SECONDS)
136
+
137
+
138
+ def worker_log(message: str) -> None:
139
+ """Write one operational line to the run's log.
140
+
141
+ The worker's stdout *is* ``worker.log``, so this is the whole logging
142
+ apparatus. A line is written as it was composed. Nothing filters it:
143
+ decision 0036 rules that the log is a record of what happened, and an
144
+ edited record is a worse record.
145
+ """
146
+ moment = datetime.now(UTC).isoformat().replace("+00:00", "Z")
147
+ print(f"{moment} {message}", flush=True)
148
+
149
+
150
+ def worker_paths() -> TechtreePaths:
151
+ """Resolve where this worker's run lives.
152
+
153
+ The launcher passes ``TECHTREE_HOME`` explicitly rather than letting the
154
+ worker guess, so a run started against a temporary home is executed
155
+ against that home and not against the operator's real one.
156
+ """
157
+ home = os.environ.get(TECHTREE_HOME_VARIABLE)
158
+ if home:
159
+ return paths_from_root(Path(home))
160
+ return default_paths()
161
+
162
+
163
+ def executor_for(
164
+ request: RunRequest,
165
+ *,
166
+ paths: TechtreePaths | None = None,
167
+ ) -> AnyExecutor:
168
+ """Return the executor this run's Campaign is entitled to.
169
+
170
+ The Campaign decides, not the request. Spec section 16 requires that the
171
+ development executor stop being what a real Campaign gets, and a request
172
+ cannot express the difference: it names the Climb it entered, and whether
173
+ that Climb's subject is a real model on a real image or the placeholder
174
+ pair this build ships is a fact about the Campaign. So the run's own staged
175
+ Campaign is read and :func:`~techtree.runs.real.campaign_is_executable`
176
+ answers it — the same predicate ``techtree doctor --for-evaluation`` uses,
177
+ so an operator's pre-flight and the worker's choice can never disagree.
178
+ """
179
+ resolved = worker_paths() if paths is None else paths
180
+ inputs = RunArtifactStore(resolved).load_inputs(request.run_id, request)
181
+ kind = executor_kind_for(inputs.campaign)
182
+ if kind != request.executor_kind:
183
+ raise RunError(
184
+ f"this run was created to be executed by the {request.executor_kind} "
185
+ f"executor and its Campaign is one the {kind} executor runs",
186
+ code="run_executor_mismatch",
187
+ details={
188
+ "run_id": request.run_id,
189
+ "requested": request.executor_kind,
190
+ "campaign": kind,
191
+ },
192
+ )
193
+ if kind == "verifiers":
194
+ return RealVerifiersExecutor(
195
+ paths=resolved,
196
+ engine_registry=EngineRegistry(resolved, load_settings(resolved)),
197
+ child_registry=ChildRegistry(),
198
+ )
199
+ return FakeRunExecutor()
200
+
201
+
202
+ def validation_provider_for(
203
+ request: RunRequest,
204
+ *,
205
+ paths: TechtreePaths | None = None,
206
+ ) -> TasksetValidationProvider:
207
+ """Return the validation source this run's Campaign actually has here.
208
+
209
+ A Campaign whose taskset lives in a package the engine bundle ships is
210
+ validated for real, with the pinned Verifiers build, before anything is
211
+ scored on it. A Campaign whose taskset this build cannot obtain has only
212
+ the publisher's snapshotted commitment, which a fake run may re-check
213
+ because it produces nothing that could be mistaken for evidence. The
214
+ routing reads the run's own inputs, so it lives in the provider rather
215
+ than here; what happens is recorded in the run's validation marker either
216
+ way. Which executor the run gets therefore does not change the answer, and
217
+ both kinds are handed the same provider.
218
+ """
219
+ del request
220
+ return worker_validation_provider(worker_paths() if paths is None else paths)
221
+
222
+
223
+ def install_signal_handlers(cancellation_event: threading.Event) -> None:
224
+ """Make ``SIGTERM`` and ``SIGINT`` record a request and nothing else."""
225
+
226
+ def handle(number: int, frame: FrameType | None) -> None:
227
+ cancellation_event.set()
228
+
229
+ for number in (signal.SIGTERM, signal.SIGINT):
230
+ signal.signal(number, handle)
231
+
232
+
233
+ def heartbeat_loop(
234
+ *,
235
+ stop_event: threading.Event,
236
+ run_store: RunStore,
237
+ run_id: str,
238
+ interval_seconds: float,
239
+ ) -> None:
240
+ """Refresh the run's heartbeat until asked to stop.
241
+
242
+ A heartbeat that cannot be written is not worth ending a run over — the
243
+ run itself may be completing at that moment, and the store refuses to
244
+ touch a run that has ended. The loop stops instead of failing the work.
245
+ """
246
+ while not stop_event.is_set():
247
+ try:
248
+ run_store.write_heartbeat(run_id, run_store.state(run_id).phase)
249
+ except TechtreeError:
250
+ return
251
+ stop_event.wait(interval_seconds)
252
+
253
+
254
+ def execute_run(
255
+ run_id: str,
256
+ *,
257
+ paths: TechtreePaths | None = None,
258
+ executor_factory: ExecutorFactory | None = None,
259
+ validation_provider_factory: ValidationProviderFactory | None = None,
260
+ ) -> int:
261
+ """Execute one run in this process and return the exit code."""
262
+ resolved = worker_paths() if paths is None else paths
263
+ run_store = RunStore(resolved)
264
+ artifact_store = RunArtifactStore(resolved)
265
+
266
+ try:
267
+ request = run_store.get_request(run_id)
268
+ except TechtreeError as error:
269
+ # Nothing can be recorded against a run that cannot be read, so the
270
+ # exit code is the only report available.
271
+ return exit_code_for(error)
272
+
273
+ worker_log(
274
+ f"worker {os.getpid()} taking on run {run_id} with the "
275
+ f"{request.executor_kind} executor"
276
+ )
277
+
278
+ try:
279
+ _announce_worker(run_store, run_id)
280
+ except TechtreeError as error:
281
+ # A run that has already ended does not take on a second worker, and
282
+ # there is nothing to record against it either.
283
+ return exit_code_for(error)
284
+
285
+ cancellation = threading.Event()
286
+ install_signal_handlers(cancellation)
287
+ _watch_for_signals(cancellation)
288
+
289
+ stop_heartbeat = threading.Event()
290
+ heartbeat = threading.Thread(
291
+ target=heartbeat_loop,
292
+ kwargs={
293
+ "stop_event": stop_heartbeat,
294
+ "run_store": run_store,
295
+ "run_id": run_id,
296
+ "interval_seconds": _HEARTBEAT_SECONDS,
297
+ },
298
+ name=f"techtree-heartbeat-{run_id}",
299
+ daemon=True,
300
+ )
301
+ heartbeat.start()
302
+
303
+ try:
304
+ executor = (
305
+ executor_for(request, paths=resolved)
306
+ if executor_factory is None
307
+ else executor_factory(request)
308
+ )
309
+ provider = (
310
+ validation_provider_for(request, paths=resolved)
311
+ if validation_provider_factory is None
312
+ else validation_provider_factory(request)
313
+ )
314
+ produced = executor.execute(
315
+ ExecutionContext(
316
+ request=request,
317
+ run_store=run_store,
318
+ artifact_store=artifact_store,
319
+ validation_provider=provider,
320
+ clock=_utc_now,
321
+ )
322
+ )
323
+ report = _require_report(
324
+ produced,
325
+ request=request,
326
+ run_store=run_store,
327
+ artifact_store=artifact_store,
328
+ paths=resolved,
329
+ )
330
+ _verify_recorded_result(run_store, run_id, digest_object(report))
331
+ except CancellationError:
332
+ worker_log(f"run {run_id} stopped because it was asked to")
333
+ handle_worker_cancelled(run_store=run_store, run_id=run_id)
334
+ return EXIT_CANCELLED
335
+ except TechtreeError as error:
336
+ worker_log(f"run {run_id} failed: {error.code}: {error.message}")
337
+ handle_worker_failure(run_store=run_store, run_id=run_id, error=error)
338
+ return exit_code_for(error)
339
+ except Exception as unexpected:
340
+ worker_log(
341
+ f"run {run_id} failed unexpectedly: {stable_exception_message(unexpected)}"
342
+ )
343
+ handle_worker_failure(run_store=run_store, run_id=run_id, error=unexpected)
344
+ return EXIT_UNEXPECTED
345
+ finally:
346
+ stop_heartbeat.set()
347
+ heartbeat.join(timeout=_HEARTBEAT_SECONDS)
348
+
349
+ worker_log(f"run {run_id} completed and its report was recorded")
350
+ return 0
351
+
352
+
353
+ def _require_report(
354
+ produced: object,
355
+ *,
356
+ request: RunRequest,
357
+ run_store: RunStore,
358
+ artifact_store: RunArtifactStore,
359
+ paths: TechtreePaths,
360
+ ) -> UpliftReport:
361
+ """Return the report the run finishes with, or say why there is none.
362
+
363
+ Two executors reach here and they hand back different things. The
364
+ development executor invents a whole report and records it itself. The real
365
+ executor stops the moment the evidence is complete and returns a
366
+ :class:`~techtree.verifiers.models.RealExecutionResult`, because spec
367
+ section 6.22 keeps WP6 out of the business of deciding what a measurement
368
+ means; turning that evidence into receipts, a controlled comparison and a
369
+ report is :class:`~techtree.uplift.service.RealUpliftReportService`'s, and
370
+ this is where the two paths meet again.
371
+
372
+ Neither branch invents a report from nothing, so an executor that produced
373
+ something else is a build defect and is reported as one, with the evidence
374
+ named rather than discarded.
375
+ """
376
+ run_id = request.run_id
377
+ if isinstance(produced, UpliftReport):
378
+ return produced
379
+ if isinstance(produced, RealExecutionResult):
380
+ return RealUpliftReportService(
381
+ paths=paths,
382
+ run_store=run_store,
383
+ artifact_store=artifact_store,
384
+ # A run a person started is explicit enough to create the local
385
+ # signing key it needs. Nothing creates one at import time, and
386
+ # nothing creates one for a machine that is only being inspected.
387
+ identity=IdentityService(IdentityStore(paths)),
388
+ clock=_utc_now,
389
+ ).complete(request=request, execution=produced)
390
+ raise RunError(
391
+ "the evaluation finished and its results were recorded, but this "
392
+ "build cannot yet turn them into a comparison report",
393
+ code=REPORT_STAGE_UNAVAILABLE,
394
+ details={
395
+ "run_id": run_id,
396
+ "results": str(real_execution_result_path(paths.run_dir(run_id))),
397
+ },
398
+ )
399
+
400
+
401
+ def handle_worker_failure(
402
+ *,
403
+ run_store: RunStore,
404
+ run_id: str,
405
+ error: Exception,
406
+ ) -> None:
407
+ """Record the failure against the run, if it can still record one."""
408
+ if isinstance(error, TechtreeError):
409
+ cli_error = error_to_cli_error(error)
410
+ else:
411
+ cli_error = error_to_cli_error(
412
+ TechtreeError(
413
+ stable_exception_message(error),
414
+ code="internal_error",
415
+ details={"exception_type": type(error).__name__},
416
+ )
417
+ )
418
+
419
+ try:
420
+ if is_terminal(run_store.state(run_id).phase):
421
+ return
422
+ run_store.append(
423
+ run_id,
424
+ phase=RunPhase.FAILED,
425
+ kind=RUN_FAILED,
426
+ details={DETAIL_ERROR: to_json_value(cli_error)},
427
+ )
428
+ except TechtreeError:
429
+ # The run's journal is unwritable. The exit code is then the only
430
+ # thing the worker can still say, and it has already been decided.
431
+ return
432
+
433
+
434
+ def handle_worker_cancelled(*, run_store: RunStore, run_id: str) -> None:
435
+ """Record that the run stopped because it was asked to.
436
+
437
+ A run signalled directly may not yet carry the request that a CLI
438
+ cancellation would have appended, so the request is made first. It is
439
+ idempotent, which is what lets both paths end the same way.
440
+ """
441
+ try:
442
+ state = run_store.state(run_id)
443
+ if is_terminal(state.phase):
444
+ return
445
+ if state.phase is not RunPhase.CANCEL_REQUESTED:
446
+ run_store.request_cancel(run_id, requested_by="worker")
447
+ run_store.append(run_id, phase=RunPhase.CANCELLED, kind=RUN_CANCELLED)
448
+ except TechtreeError:
449
+ return
450
+
451
+
452
+ def _announce_worker(run_store: RunStore, run_id: str) -> None:
453
+ """Record this process as the run's worker, unless one is already named."""
454
+ if run_store.state(run_id).worker_pid is not None:
455
+ return
456
+ run_store.write_pid(run_id, os.getpid())
457
+
458
+
459
+ def _watch_for_signals(cancellation: threading.Event) -> None:
460
+ """Turn a signalled event into the executor's cancellation flag.
461
+
462
+ The handler itself may only set an event. A tiny daemon thread waits on
463
+ that event and does the one unsafe-in-a-handler thing that is needed:
464
+ telling the executor, wherever it is, that this process is stopping.
465
+ """
466
+
467
+ def wait() -> None:
468
+ cancellation.wait()
469
+ request_local_cancellation()
470
+
471
+ threading.Thread(target=wait, name="techtree-cancellation", daemon=True).start()
472
+
473
+
474
+ def _verify_recorded_result(run_store: RunStore, run_id: str, expected: str) -> None:
475
+ """Require the journal to name the report the executor just produced."""
476
+ recorded = run_store.state(run_id).result_digest
477
+ if recorded == expected:
478
+ return
479
+ raise RunError(
480
+ f"run {run_id} finished with a report its journal does not name",
481
+ code="run_result_digest_mismatch",
482
+ details={"run_id": run_id, "expected": expected, "recorded": recorded},
483
+ )
484
+
485
+
486
+ def _utc_now() -> datetime:
487
+ return datetime.now(UTC)
@@ -0,0 +1,57 @@
1
+ """Detached worker entry point. Spec sections 19.1 and PR8 §8.9.
2
+
3
+ The command surface is one line: ``techtree-worker execute --run-id <id>``.
4
+ It is not a user-facing program — the CLI launches it, and a person reading
5
+ ``techtree --help`` will never see it — so it has none of the CLI's apparatus.
6
+ No Rich console, no envelope, no next actions. Its stdout and stderr are the
7
+ run's ``worker.log``, and its exit code is the only thing a caller reads.
8
+
9
+ Everything the worker actually does is in :mod:`techtree.worker.execute`. This
10
+ module parses one argument and gets out of the way.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ from collections.abc import Sequence
18
+
19
+ from techtree.version import package_version
20
+ from techtree.worker.execute import execute_run
21
+
22
+ __all__ = ["build_parser", "main"]
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ """Create internal worker parser."""
27
+ parser = argparse.ArgumentParser(
28
+ prog="techtree-worker",
29
+ description="Techtree detached run worker. Not a user-facing command.",
30
+ )
31
+ parser.add_argument(
32
+ "--version",
33
+ action="version",
34
+ version=package_version(),
35
+ )
36
+ subparsers = parser.add_subparsers(dest="command", required=True)
37
+ execute = subparsers.add_parser(
38
+ "execute",
39
+ help="Execute a prepared run in this process.",
40
+ )
41
+ execute.add_argument(
42
+ "--run-id",
43
+ required=True,
44
+ metavar="ID",
45
+ help="Identifier of the prepared run to execute.",
46
+ )
47
+ return parser
48
+
49
+
50
+ def main(argv: Sequence[str] | None = None) -> None:
51
+ """Parse and execute one run, then exit with the code it reports."""
52
+ arguments = build_parser().parse_args(argv)
53
+ sys.exit(execute_run(arguments.run_id))
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()