techtree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )