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/runs/service.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Run control, and the transaction that makes starting safe. Spec PR8 §8.8.
|
|
2
|
+
|
|
3
|
+
Starting a run spends something that cannot be un-spent: a draft, and with it
|
|
4
|
+
a participant's approval of exactly what that draft would do. It also crosses
|
|
5
|
+
four separate pieces of state — the draft's start claim, the run directory, the
|
|
6
|
+
staged inputs, and a detached process — with a crash possible between any two of
|
|
7
|
+
them. The whole of :meth:`RunService.start` exists to make that sequence
|
|
8
|
+
survivable.
|
|
9
|
+
|
|
10
|
+
The rule it is built on is that the *draft* decides which run identifier a
|
|
11
|
+
draft becomes. :meth:`~techtree.drafts.store.DraftStore.claim_start` writes
|
|
12
|
+
``start.json`` under the draft's lock and returns whatever it already holds, so
|
|
13
|
+
the identifier is allocated exactly once no matter how many starts are
|
|
14
|
+
attempted or where they crash. Everything after that point is repair: the run
|
|
15
|
+
directory is created if it is missing, the inputs are staged if they are not
|
|
16
|
+
staged, the worker is launched only if no worker has ever announced itself.
|
|
17
|
+
Spec §9.2 through §9.5 are each a line in that algorithm, and each is tested.
|
|
18
|
+
|
|
19
|
+
Two things are deliberately not automatic.
|
|
20
|
+
|
|
21
|
+
*A failed launch does not produce a second run.* The run is marked failed and
|
|
22
|
+
the draft's claim is marked ``launch_failed``.
|
|
23
|
+
Retrying returns the same failed run rather than quietly allocating another,
|
|
24
|
+
because the participant asked to start one run and the honest answer is that
|
|
25
|
+
that run failed. A future explicit retry operation may say otherwise.
|
|
26
|
+
|
|
27
|
+
*Reading a run's status never changes it.* A worker that died leaves a
|
|
28
|
+
nonterminal run with a stale heartbeat, and that is exactly what
|
|
29
|
+
:meth:`RunService.status` reports. Inventing a failure transition on a read
|
|
30
|
+
would mean the run's history recorded something nobody observed; spec §8.8
|
|
31
|
+
reserves that for an explicit reconciliation path.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from contextlib import suppress
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Final, Literal
|
|
42
|
+
|
|
43
|
+
from techtree.canonical import digest_object, to_json_value
|
|
44
|
+
from techtree.constants import DEFAULT_STALE_HEARTBEAT_SECONDS
|
|
45
|
+
from techtree.drafts.store import (
|
|
46
|
+
DraftSnapshot,
|
|
47
|
+
DraftStartRecord,
|
|
48
|
+
DraftStartStatus,
|
|
49
|
+
DraftStore,
|
|
50
|
+
)
|
|
51
|
+
from techtree.errors import (
|
|
52
|
+
ConflictError,
|
|
53
|
+
NotFoundError,
|
|
54
|
+
PolicyError,
|
|
55
|
+
RunError,
|
|
56
|
+
TechtreeError,
|
|
57
|
+
UsageError,
|
|
58
|
+
ValidationError,
|
|
59
|
+
VerificationError,
|
|
60
|
+
error_to_cli_error,
|
|
61
|
+
)
|
|
62
|
+
from techtree.ids import new_id
|
|
63
|
+
from techtree.models.evaluation_backend import SUPPORTED_EVALUATION_BACKEND_KINDS
|
|
64
|
+
from techtree.models.run import (
|
|
65
|
+
PolicyAcknowledgement,
|
|
66
|
+
RunPhase,
|
|
67
|
+
RunRequest,
|
|
68
|
+
RunState,
|
|
69
|
+
RunStatus,
|
|
70
|
+
)
|
|
71
|
+
from techtree.models.uplift_report import UpliftReport
|
|
72
|
+
from techtree.paths import TechtreePaths
|
|
73
|
+
from techtree.runs.artifacts import RunArtifactStore
|
|
74
|
+
from techtree.runs.events import (
|
|
75
|
+
DETAIL_ACTOR,
|
|
76
|
+
DETAIL_APPROVED_AT,
|
|
77
|
+
DETAIL_DRAFT_DIGEST,
|
|
78
|
+
DETAIL_ERROR,
|
|
79
|
+
RUN_APPROVED,
|
|
80
|
+
RUN_FAILED,
|
|
81
|
+
)
|
|
82
|
+
from techtree.runs.launcher import WorkerLauncher
|
|
83
|
+
from techtree.runs.machine import is_terminal
|
|
84
|
+
from techtree.runs.real import executor_kind_for
|
|
85
|
+
from techtree.runs.store import RunStore
|
|
86
|
+
from techtree.verifiers.models import RunPaths, VariantName
|
|
87
|
+
from techtree.verifiers.outputs import EVAL_LOG_PATH
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"ACKNOWLEDGEMENT_METHODS",
|
|
91
|
+
"APPROVAL_ACTORS",
|
|
92
|
+
"DEFAULT_LOG_TAIL",
|
|
93
|
+
"DRAFT_ALREADY_STARTED",
|
|
94
|
+
"MAXIMUM_LOG_TAIL",
|
|
95
|
+
"MINIMUM_LOG_TAIL",
|
|
96
|
+
"POLICY_ACCEPTANCE_DIGEST_MISMATCH",
|
|
97
|
+
"POLICY_ACCEPTANCE_METHOD_INVALID",
|
|
98
|
+
"POLICY_ACCEPTANCE_REQUIRED",
|
|
99
|
+
"RUN_LOGS_UNAVAILABLE",
|
|
100
|
+
"RUN_RESULT_DIGEST_MISMATCH",
|
|
101
|
+
"RUN_RESULT_NOT_READY",
|
|
102
|
+
"ApprovalActor",
|
|
103
|
+
"CancellationOutcome",
|
|
104
|
+
"ProcessHealth",
|
|
105
|
+
"RunCancellation",
|
|
106
|
+
"RunLogs",
|
|
107
|
+
"RunService",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
#: Stable error codes. Spec PR8 §8.16.
|
|
111
|
+
DRAFT_ALREADY_STARTED: Final = "draft_already_started"
|
|
112
|
+
POLICY_ACCEPTANCE_REQUIRED: Final = "policy_acceptance_required"
|
|
113
|
+
POLICY_ACCEPTANCE_DIGEST_MISMATCH: Final = "policy_acceptance_digest_mismatch"
|
|
114
|
+
POLICY_ACCEPTANCE_METHOD_INVALID: Final = "policy_acceptance_method_invalid"
|
|
115
|
+
RUN_RESULT_NOT_READY: Final = "run_result_not_ready"
|
|
116
|
+
RUN_RESULT_DIGEST_MISMATCH: Final = "run_result_digest_mismatch"
|
|
117
|
+
RUN_LOGS_UNAVAILABLE: Final = "run_logs_unavailable"
|
|
118
|
+
|
|
119
|
+
type ApprovalActor = Literal["human_via_cli", "operator_via_flag", "human_via_hermes"]
|
|
120
|
+
"""Who gave the approval this run records.
|
|
121
|
+
|
|
122
|
+
``human_via_cli`` is a person who read the review and answered the prompt.
|
|
123
|
+
``operator_via_flag`` is an operator who passed the flag that stands in for
|
|
124
|
+
that answer where nobody can be asked. ``human_via_hermes`` is a person who
|
|
125
|
+
answered on the host agent's approval surface, after which the plugin started
|
|
126
|
+
the draft they approved.
|
|
127
|
+
|
|
128
|
+
The actor and the acceptance method say two halves of one fact and must agree:
|
|
129
|
+
a run whose method is ``host_agent_confirmation`` was approved by a person on
|
|
130
|
+
that surface, and a run whose method is ``explicit_cli_review`` was approved at
|
|
131
|
+
the command line by one of the two who can be there. The method is on the run's
|
|
132
|
+
immutable request; the actor is on its ``run.approved`` event, where an auditor
|
|
133
|
+
reads it.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
#: Every actor this build can record. A name outside it is a name nothing here
|
|
137
|
+
#: could have produced.
|
|
138
|
+
APPROVAL_ACTORS: Final[frozenset[str]] = frozenset(
|
|
139
|
+
{"human_via_cli", "operator_via_flag", "human_via_hermes"}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
#: Which actors belong to which acceptance surface. Decisions document 0019
|
|
143
|
+
#: section 2 names two surfaces and this build reaches both:
|
|
144
|
+
#: ``explicit_cli_review`` when the review was shown and accepted at the command
|
|
145
|
+
#: line, and ``host_agent_confirmation`` when it was shown and accepted on the
|
|
146
|
+
#: host agent's own approval surface and the plugin then started that exact
|
|
147
|
+
#: draft. Which one a run records is declared by whoever starts it, because the
|
|
148
|
+
#: process that writes the record is not the one that asked the question.
|
|
149
|
+
#:
|
|
150
|
+
#: A run that recorded a command-line acceptance and a host-agent actor would
|
|
151
|
+
#: describe two different approvals, and only one of them happened.
|
|
152
|
+
ACTORS_BY_METHOD: Final[dict[str, frozenset[str]]] = {
|
|
153
|
+
"explicit_cli_review": frozenset({"human_via_cli", "operator_via_flag"}),
|
|
154
|
+
"host_agent_confirmation": frozenset({"human_via_hermes"}),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#: How acceptance of a rights policy may be stated in this build, derived from
|
|
158
|
+
#: the surfaces above so that a method can never exist without the actors that
|
|
159
|
+
#: belong to it.
|
|
160
|
+
ACKNOWLEDGEMENT_METHODS: Final[frozenset[str]] = frozenset(ACTORS_BY_METHOD)
|
|
161
|
+
|
|
162
|
+
#: Log tail bounds. Spec PR8 §8.13.
|
|
163
|
+
DEFAULT_LOG_TAIL: Final = 200
|
|
164
|
+
MINIMUM_LOG_TAIL: Final = 1
|
|
165
|
+
MAXIMUM_LOG_TAIL: Final = 5000
|
|
166
|
+
|
|
167
|
+
type CancellationOutcome = Literal["requested", "already_requested", "already_terminal"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def utc_now() -> datetime:
|
|
171
|
+
"""Return the current instant in UTC."""
|
|
172
|
+
return datetime.now(UTC)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class ProcessHealth:
|
|
177
|
+
"""What the host can say about a run's worker that its log cannot."""
|
|
178
|
+
|
|
179
|
+
worker_pid: int | None
|
|
180
|
+
worker_alive: bool
|
|
181
|
+
heartbeat_at: datetime | None
|
|
182
|
+
heartbeat_age_seconds: float | None
|
|
183
|
+
heartbeat_stale: bool
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class RunCancellation:
|
|
188
|
+
"""The result of asking a run to stop, and which of the three it was."""
|
|
189
|
+
|
|
190
|
+
status: RunStatus
|
|
191
|
+
outcome: CancellationOutcome
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class RunLogs:
|
|
196
|
+
"""A bounded window onto one run's worker log, line for line as written."""
|
|
197
|
+
|
|
198
|
+
run_id: str
|
|
199
|
+
lines: list[str]
|
|
200
|
+
truncated: bool
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class RunService:
|
|
204
|
+
"""Transactional, idempotent run control for the CLI."""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
paths: TechtreePaths,
|
|
210
|
+
draft_store: DraftStore,
|
|
211
|
+
run_store: RunStore,
|
|
212
|
+
artifact_store: RunArtifactStore,
|
|
213
|
+
launcher: WorkerLauncher,
|
|
214
|
+
clock: Callable[[], datetime] = utc_now,
|
|
215
|
+
stale_heartbeat_seconds: float = DEFAULT_STALE_HEARTBEAT_SECONDS,
|
|
216
|
+
) -> None:
|
|
217
|
+
self._paths = paths
|
|
218
|
+
self._drafts = draft_store
|
|
219
|
+
self._runs = run_store
|
|
220
|
+
self._artifacts = artifact_store
|
|
221
|
+
self._launcher = launcher
|
|
222
|
+
self._clock = clock
|
|
223
|
+
self._stale_after = stale_heartbeat_seconds
|
|
224
|
+
|
|
225
|
+
# -- starting -----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def start(
|
|
228
|
+
self,
|
|
229
|
+
*,
|
|
230
|
+
draft_id: str,
|
|
231
|
+
policy_acknowledgement: PolicyAcknowledgement,
|
|
232
|
+
approved_by: ApprovalActor,
|
|
233
|
+
) -> RunStatus:
|
|
234
|
+
"""Claim the draft, create the run, stage inputs, and launch."""
|
|
235
|
+
snapshot = self._drafts.load_snapshot(draft_id)
|
|
236
|
+
self._require_startable(snapshot, policy_acknowledgement)
|
|
237
|
+
self._require_agreeing_approval(snapshot, policy_acknowledgement, approved_by)
|
|
238
|
+
|
|
239
|
+
existing = self._drafts.start_record(draft_id)
|
|
240
|
+
proposed = existing.run_id if existing is not None else new_id("run")
|
|
241
|
+
record = self._drafts.claim_start(draft_id=draft_id, run_id=proposed)
|
|
242
|
+
|
|
243
|
+
request = self._request_for(
|
|
244
|
+
record=record,
|
|
245
|
+
snapshot=snapshot,
|
|
246
|
+
policy_acknowledgement=policy_acknowledgement,
|
|
247
|
+
)
|
|
248
|
+
self._ensure_run_exists(record, request, approved_by)
|
|
249
|
+
self._artifacts.stage_inputs(
|
|
250
|
+
run_id=record.run_id, request=request, snapshot=snapshot
|
|
251
|
+
)
|
|
252
|
+
return self._ensure_launched(record)
|
|
253
|
+
|
|
254
|
+
def _require_startable(
|
|
255
|
+
self,
|
|
256
|
+
snapshot: DraftSnapshot,
|
|
257
|
+
acknowledgement: PolicyAcknowledgement,
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Check everything that must hold before any state is mutated."""
|
|
260
|
+
draft = snapshot.draft
|
|
261
|
+
if draft.policy_acceptance.required and acknowledgement.data_policy_digest != (
|
|
262
|
+
draft.data_policy_digest
|
|
263
|
+
):
|
|
264
|
+
raise PolicyError(
|
|
265
|
+
"the accepted DataPolicy is not the one this draft runs under",
|
|
266
|
+
code=POLICY_ACCEPTANCE_DIGEST_MISMATCH,
|
|
267
|
+
details={
|
|
268
|
+
"draft_id": draft.id,
|
|
269
|
+
"expected_digest": draft.data_policy_digest,
|
|
270
|
+
"accepted_digest": acknowledgement.data_policy_digest,
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
if acknowledgement.method not in ACKNOWLEDGEMENT_METHODS:
|
|
274
|
+
raise PolicyError(
|
|
275
|
+
f"{acknowledgement.method} is not a way this build can record "
|
|
276
|
+
"acceptance of a data policy",
|
|
277
|
+
code=POLICY_ACCEPTANCE_METHOD_INVALID,
|
|
278
|
+
details={"draft_id": draft.id, "method": acknowledgement.method},
|
|
279
|
+
)
|
|
280
|
+
if not snapshot.comparison.controlled:
|
|
281
|
+
raise VerificationError(
|
|
282
|
+
"this draft's candidate differs from its baseline somewhere "
|
|
283
|
+
"the Campaign does not permit, so it cannot be run",
|
|
284
|
+
code="manifest_comparison_invalid",
|
|
285
|
+
details={"draft_id": draft.id},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
kind = snapshot.source.campaign.evaluation_backend.kind
|
|
289
|
+
if kind not in SUPPORTED_EVALUATION_BACKEND_KINDS:
|
|
290
|
+
raise VerificationError(
|
|
291
|
+
f"this Campaign is evaluated by {kind.value}, which this build "
|
|
292
|
+
"does not run",
|
|
293
|
+
code="evaluation_backend_unsupported",
|
|
294
|
+
details={"draft_id": draft.id, "evaluation_backend": kind.value},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _require_agreeing_approval(
|
|
298
|
+
self,
|
|
299
|
+
snapshot: DraftSnapshot,
|
|
300
|
+
acknowledgement: PolicyAcknowledgement,
|
|
301
|
+
approved_by: ApprovalActor,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Refuse a run whose acceptance and whose actor describe two approvals."""
|
|
304
|
+
if approved_by not in ACTORS_BY_METHOD[acknowledgement.method]:
|
|
305
|
+
raise PolicyError(
|
|
306
|
+
f"{approved_by} did not give a {acknowledgement.method} "
|
|
307
|
+
"acceptance, so the run would record an approval nobody made",
|
|
308
|
+
code=POLICY_ACCEPTANCE_METHOD_INVALID,
|
|
309
|
+
details={
|
|
310
|
+
"draft_id": snapshot.draft.id,
|
|
311
|
+
"method": acknowledgement.method,
|
|
312
|
+
"actor": approved_by,
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _request_for(
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
record: DraftStartRecord,
|
|
320
|
+
snapshot: DraftSnapshot,
|
|
321
|
+
policy_acknowledgement: PolicyAcknowledgement,
|
|
322
|
+
) -> RunRequest:
|
|
323
|
+
"""Return the run's immutable request, existing or newly built.
|
|
324
|
+
|
|
325
|
+
A run that already exists keeps the request it was created with. The
|
|
326
|
+
acknowledgement recorded there is the one that was made at the time,
|
|
327
|
+
and a retried start must not overwrite it with a fresher timestamp.
|
|
328
|
+
"""
|
|
329
|
+
draft = snapshot.draft
|
|
330
|
+
try:
|
|
331
|
+
existing = self._runs.get_request(record.run_id)
|
|
332
|
+
except NotFoundError:
|
|
333
|
+
existing = None
|
|
334
|
+
|
|
335
|
+
if existing is not None:
|
|
336
|
+
if existing.draft_id != draft.id:
|
|
337
|
+
raise ConflictError(
|
|
338
|
+
f"draft {draft.id} is claimed by run {record.run_id}, which "
|
|
339
|
+
"was created from a different draft",
|
|
340
|
+
code=DRAFT_ALREADY_STARTED,
|
|
341
|
+
details={"draft_id": draft.id, "run_id": record.run_id},
|
|
342
|
+
)
|
|
343
|
+
return existing
|
|
344
|
+
|
|
345
|
+
return RunRequest(
|
|
346
|
+
run_id=record.run_id,
|
|
347
|
+
draft_id=draft.id,
|
|
348
|
+
draft_digest=digest_object(draft),
|
|
349
|
+
campaign_spec_digest=draft.campaign_spec_digest,
|
|
350
|
+
program_ref=draft.program_ref,
|
|
351
|
+
public_context=draft.public_context,
|
|
352
|
+
data_policy_digest=draft.data_policy_digest,
|
|
353
|
+
outcome_contract_digest=draft.outcome_contract_digest,
|
|
354
|
+
evaluation_backend=snapshot.source.campaign.evaluation_backend,
|
|
355
|
+
taskset_lock_digest=(
|
|
356
|
+
snapshot.source.publisher_validation.taskset_lock_digest
|
|
357
|
+
),
|
|
358
|
+
baseline_manifest_digest=draft.baseline_manifest_digest,
|
|
359
|
+
candidate_manifest_digest=draft.candidate_manifest_digest,
|
|
360
|
+
policy_acknowledgement=policy_acknowledgement,
|
|
361
|
+
executor_kind=executor_kind_for(snapshot.source.campaign),
|
|
362
|
+
created_at=self._clock(),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _ensure_run_exists(
|
|
366
|
+
self,
|
|
367
|
+
record: DraftStartRecord,
|
|
368
|
+
request: RunRequest,
|
|
369
|
+
approved_by: ApprovalActor,
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Create the run directory unless a previous attempt already did.
|
|
372
|
+
|
|
373
|
+
The approval is recorded here, in the same branch that creates the run,
|
|
374
|
+
so a retried start finds the run already present and does not write a
|
|
375
|
+
second approval. One run, one approval, however many times a start is
|
|
376
|
+
attempted.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
self._runs.get_request(record.run_id)
|
|
380
|
+
except NotFoundError:
|
|
381
|
+
self._runs.create(request)
|
|
382
|
+
self._runs.append(
|
|
383
|
+
record.run_id,
|
|
384
|
+
phase=RunPhase.CREATED,
|
|
385
|
+
kind=RUN_APPROVED,
|
|
386
|
+
details={
|
|
387
|
+
DETAIL_DRAFT_DIGEST: request.draft_digest,
|
|
388
|
+
DETAIL_ACTOR: approved_by,
|
|
389
|
+
DETAIL_APPROVED_AT: to_json_value(
|
|
390
|
+
request.policy_acknowledgement.acknowledged_at
|
|
391
|
+
),
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _ensure_launched(self, record: DraftStartRecord) -> RunStatus:
|
|
396
|
+
"""Launch the worker if, and only if, none has ever been launched."""
|
|
397
|
+
run_id = record.run_id
|
|
398
|
+
state = self._runs.state(run_id)
|
|
399
|
+
|
|
400
|
+
if record.status is DraftStartStatus.LAUNCH_FAILED:
|
|
401
|
+
raise RunError(
|
|
402
|
+
f"run {run_id} could not be started, and a draft is spent on "
|
|
403
|
+
"exactly one run",
|
|
404
|
+
code="worker_launch_failed",
|
|
405
|
+
details={
|
|
406
|
+
"run_id": run_id,
|
|
407
|
+
"draft_id": record.draft_id,
|
|
408
|
+
"launch_error_code": record.launch_error_code,
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# A worker announces its own process id as its first act, so evidence
|
|
413
|
+
# of a worker is evidence that the launch happened even if the
|
|
414
|
+
# launching process died before it could record anything.
|
|
415
|
+
if state.worker_pid is not None or is_terminal(state.phase):
|
|
416
|
+
return self.status(run_id)
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
pid = self._launcher.launch(run_id)
|
|
420
|
+
except TechtreeError as error:
|
|
421
|
+
self._record_launch_failure(record, error)
|
|
422
|
+
raise
|
|
423
|
+
|
|
424
|
+
# The pid file and the ``worker.started`` event are the launcher's to
|
|
425
|
+
# write only until the worker writes them itself; whichever gets there
|
|
426
|
+
# first, the other finds the run already carries a worker and leaves
|
|
427
|
+
# it alone. Spec §9.5: a worker records its own process id as its
|
|
428
|
+
# first act, so the run is complete either way.
|
|
429
|
+
if self._runs.state(run_id).worker_pid is None:
|
|
430
|
+
with suppress(ConflictError, RunError, ValidationError):
|
|
431
|
+
self._runs.write_pid(run_id, pid)
|
|
432
|
+
|
|
433
|
+
self._drafts.mark_launched(
|
|
434
|
+
draft_id=record.draft_id,
|
|
435
|
+
run_id=run_id,
|
|
436
|
+
launched_at=self._clock(),
|
|
437
|
+
)
|
|
438
|
+
return self.status(run_id)
|
|
439
|
+
|
|
440
|
+
def _record_launch_failure(
|
|
441
|
+
self, record: DraftStartRecord, error: TechtreeError
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Leave a failed launch addressable rather than invisible."""
|
|
444
|
+
state = self._runs.state(record.run_id)
|
|
445
|
+
if not is_terminal(state.phase):
|
|
446
|
+
self._runs.append(
|
|
447
|
+
record.run_id,
|
|
448
|
+
phase=RunPhase.FAILED,
|
|
449
|
+
kind=RUN_FAILED,
|
|
450
|
+
details={DETAIL_ERROR: to_json_value(error_to_cli_error(error))},
|
|
451
|
+
)
|
|
452
|
+
self._drafts.mark_launch_failed(
|
|
453
|
+
draft_id=record.draft_id,
|
|
454
|
+
run_id=record.run_id,
|
|
455
|
+
error_code=error.code,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# -- reading ------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
def status(self, run_id: str) -> RunStatus:
|
|
461
|
+
"""Return the projected state plus process and heartbeat health."""
|
|
462
|
+
state = self._runs.state(run_id)
|
|
463
|
+
health = self._health(run_id, state)
|
|
464
|
+
return RunStatus(
|
|
465
|
+
state=state,
|
|
466
|
+
worker_alive=health.worker_alive,
|
|
467
|
+
heartbeat_stale=health.heartbeat_stale,
|
|
468
|
+
result_available=self._runs.result_path(run_id).exists(),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def request(self, run_id: str) -> RunRequest:
|
|
472
|
+
"""Return the immutable request this run executes."""
|
|
473
|
+
return self._runs.get_request(run_id)
|
|
474
|
+
|
|
475
|
+
def process_health(self, run_id: str) -> ProcessHealth:
|
|
476
|
+
"""Return what the host knows about this run's worker process."""
|
|
477
|
+
return self._health(run_id, self._runs.state(run_id))
|
|
478
|
+
|
|
479
|
+
def _health(self, run_id: str, state: RunState) -> ProcessHealth:
|
|
480
|
+
alive = self._launcher.is_alive(run_id)
|
|
481
|
+
heartbeat = state.heartbeat_at
|
|
482
|
+
age = None if heartbeat is None else (self._clock() - heartbeat).total_seconds()
|
|
483
|
+
|
|
484
|
+
if is_terminal(state.phase):
|
|
485
|
+
# A run that has ended is not expected to have a heartbeat, and
|
|
486
|
+
# calling the absence of one "stale" would report every finished
|
|
487
|
+
# run as unhealthy.
|
|
488
|
+
stale = False
|
|
489
|
+
elif state.worker_pid is None:
|
|
490
|
+
stale = False
|
|
491
|
+
else:
|
|
492
|
+
stale = age is None or age > self._stale_after
|
|
493
|
+
|
|
494
|
+
return ProcessHealth(
|
|
495
|
+
worker_pid=state.worker_pid,
|
|
496
|
+
worker_alive=alive,
|
|
497
|
+
heartbeat_at=heartbeat,
|
|
498
|
+
heartbeat_age_seconds=age,
|
|
499
|
+
heartbeat_stale=stale,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def result(self, run_id: str) -> UpliftReport:
|
|
503
|
+
"""Return the report, once the run has finished and it verifies."""
|
|
504
|
+
state = self._runs.state(run_id)
|
|
505
|
+
if state.phase is not RunPhase.COMPLETED:
|
|
506
|
+
raise RunError(
|
|
507
|
+
f"run {run_id} is {state.phase.value} and has no result yet",
|
|
508
|
+
code=RUN_RESULT_NOT_READY,
|
|
509
|
+
retryable=not is_terminal(state.phase),
|
|
510
|
+
details={"run_id": run_id, "phase": state.phase.value},
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
report = self._runs.get_result(run_id)
|
|
514
|
+
recomputed = digest_object(report)
|
|
515
|
+
if state.result_digest is None or recomputed != state.result_digest:
|
|
516
|
+
raise VerificationError(
|
|
517
|
+
f"the report stored for run {run_id} is not the report its "
|
|
518
|
+
"journal recorded",
|
|
519
|
+
code=RUN_RESULT_DIGEST_MISMATCH,
|
|
520
|
+
details={
|
|
521
|
+
"run_id": run_id,
|
|
522
|
+
"expected": state.result_digest,
|
|
523
|
+
"computed": recomputed,
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
return report
|
|
527
|
+
|
|
528
|
+
def logs(self, run_id: str, *, tail: int = DEFAULT_LOG_TAIL) -> RunLogs:
|
|
529
|
+
"""Return the last ``tail`` lines of the worker log, as they were written."""
|
|
530
|
+
return self._read_log(
|
|
531
|
+
run_id,
|
|
532
|
+
path=self._runs.worker_log_path(run_id),
|
|
533
|
+
tail=tail,
|
|
534
|
+
missing=f"run {run_id} has written no log yet",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def variant_logs(
|
|
538
|
+
self, run_id: str, variant: VariantName, *, tail: int = DEFAULT_LOG_TAIL
|
|
539
|
+
) -> RunLogs:
|
|
540
|
+
"""Return one variant's evaluation log, as written. Spec section 6.20.
|
|
541
|
+
|
|
542
|
+
The engine's own ``eval.log`` is the answer, never the child's captured
|
|
543
|
+
stdout. With rich output disabled the pinned CLI dumps every trace to
|
|
544
|
+
stdout as indented JSON when the run ends
|
|
545
|
+
(``docs/verifiers-eval.md``), and those are the subject's transcripts.
|
|
546
|
+
They are retained for diagnosis on disk and are not something a log
|
|
547
|
+
command hands back to whoever asked.
|
|
548
|
+
"""
|
|
549
|
+
paths = RunPaths.for_run(self._paths, run_id)
|
|
550
|
+
return self._read_log(
|
|
551
|
+
run_id,
|
|
552
|
+
path=paths.variant_output_dir(variant) / EVAL_LOG_PATH,
|
|
553
|
+
tail=tail,
|
|
554
|
+
missing=(
|
|
555
|
+
f"the {variant.value} variant of run {run_id} has not started "
|
|
556
|
+
"evaluating yet"
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def _read_log(self, run_id: str, *, path: Path, tail: int, missing: str) -> RunLogs:
|
|
561
|
+
"""Read the tail of one log file, exactly as it was written."""
|
|
562
|
+
if tail < MINIMUM_LOG_TAIL or tail > MAXIMUM_LOG_TAIL:
|
|
563
|
+
raise UsageError(
|
|
564
|
+
f"--tail is between {MINIMUM_LOG_TAIL} and {MAXIMUM_LOG_TAIL} "
|
|
565
|
+
f"lines; {tail} is outside that",
|
|
566
|
+
details={
|
|
567
|
+
"run_id": run_id,
|
|
568
|
+
"tail": tail,
|
|
569
|
+
"minimum": MINIMUM_LOG_TAIL,
|
|
570
|
+
"maximum": MAXIMUM_LOG_TAIL,
|
|
571
|
+
},
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
576
|
+
except FileNotFoundError as error:
|
|
577
|
+
raise NotFoundError(
|
|
578
|
+
missing,
|
|
579
|
+
code=RUN_LOGS_UNAVAILABLE,
|
|
580
|
+
retryable=True,
|
|
581
|
+
details={"run_id": run_id},
|
|
582
|
+
) from error
|
|
583
|
+
|
|
584
|
+
lines = raw.splitlines()
|
|
585
|
+
return RunLogs(
|
|
586
|
+
run_id=run_id,
|
|
587
|
+
lines=lines[-tail:],
|
|
588
|
+
truncated=len(lines) > tail,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
def worker_log_path(self, run_id: str) -> Path:
|
|
592
|
+
"""Return the log file a follower reads.
|
|
593
|
+
|
|
594
|
+
The path is for the CLI's own use. It is never put in a response:
|
|
595
|
+
spec §8.13 is explicit that a run's answer must not hand another agent
|
|
596
|
+
an arbitrary file to read.
|
|
597
|
+
"""
|
|
598
|
+
return self._runs.worker_log_path(run_id)
|
|
599
|
+
|
|
600
|
+
# -- cancelling ---------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
def cancel(self, run_id: str, *, requested_by: str = "cli") -> RunCancellation:
|
|
603
|
+
"""Ask a run to stop, idempotently, and signal its worker."""
|
|
604
|
+
state = self._runs.state(run_id)
|
|
605
|
+
if is_terminal(state.phase):
|
|
606
|
+
return RunCancellation(
|
|
607
|
+
status=self.status(run_id), outcome="already_terminal"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
already = state.phase is RunPhase.CANCEL_REQUESTED
|
|
611
|
+
self._runs.request_cancel(run_id, requested_by=requested_by)
|
|
612
|
+
self._launcher.request_termination(run_id)
|
|
613
|
+
return RunCancellation(
|
|
614
|
+
status=self.status(run_id),
|
|
615
|
+
outcome="already_requested" if already else "requested",
|
|
616
|
+
)
|