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
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)
|
techtree/worker/main.py
ADDED
|
@@ -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()
|