hyperloop 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.
hyperloop/loop.py ADDED
@@ -0,0 +1,510 @@
1
+ """Orchestrator loop — wires decide, pipeline, state store, and runtime.
2
+
3
+ Runs the serial section from the spec: reap finished workers, check for halt,
4
+ run stubs for process-improver/intake, poll gates, merge PRs, decide what to
5
+ spawn, update state, spawn workers, and check convergence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING
12
+
13
+ from hyperloop.domain.decide import decide
14
+ from hyperloop.domain.model import (
15
+ ActionStep,
16
+ GateStep,
17
+ Halt,
18
+ LoopStep,
19
+ Phase,
20
+ PipelinePosition,
21
+ ReapWorker,
22
+ RoleStep,
23
+ SpawnWorker,
24
+ TaskStatus,
25
+ Verdict,
26
+ WorkerHandle,
27
+ WorkerState,
28
+ World,
29
+ )
30
+ from hyperloop.domain.pipeline import (
31
+ PerformAction,
32
+ PipelineComplete,
33
+ PipelineExecutor,
34
+ PipelineFailed,
35
+ SpawnRole,
36
+ WaitForGate,
37
+ )
38
+
39
+ if TYPE_CHECKING:
40
+ from hyperloop.compose import PromptComposer
41
+ from hyperloop.domain.model import PipelineStep, Process, Task, WorkerResult
42
+ from hyperloop.ports.pr import PRPort
43
+ from hyperloop.ports.runtime import Runtime
44
+ from hyperloop.ports.state import StateStore
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ class Orchestrator:
50
+ """Main orchestrator loop — one serial section per cycle.
51
+
52
+ The orchestrator tracks active workers and their pipeline positions.
53
+ Each cycle follows the spec's serial section order:
54
+ 1. Reap finished workers
55
+ 2. Halt if any task failed
56
+ 3. Process-improver (stub)
57
+ 4. Intake (stub)
58
+ 5. Poll gates
59
+ 6. Merge ready PRs
60
+ 7. Decide what to spawn (via decide())
61
+ 8. Update state (transition tasks, commit)
62
+ 9. Spawn workers
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ state: StateStore,
68
+ runtime: Runtime,
69
+ process: Process,
70
+ max_workers: int = 6,
71
+ max_rounds: int = 50,
72
+ pr_manager: PRPort | None = None,
73
+ composer: PromptComposer | None = None,
74
+ ) -> None:
75
+ self._state = state
76
+ self._runtime = runtime
77
+ self._process = process
78
+ self._max_workers = max_workers
79
+ self._max_rounds = max_rounds
80
+ self._pr_manager = pr_manager
81
+ self._composer = composer
82
+
83
+ # Active worker tracking: task_id -> (handle, pipeline_position)
84
+ self._workers: dict[str, tuple[WorkerHandle, PipelinePosition]] = {}
85
+
86
+ def run_loop(self, max_cycles: int = 1000) -> str:
87
+ """Run the orchestrator loop until halt or max_cycles. Returns halt reason."""
88
+ for _ in range(max_cycles):
89
+ reason = self.run_cycle()
90
+ if reason is not None:
91
+ return reason
92
+ return "max_cycles exhausted"
93
+
94
+ def recover(self) -> None:
95
+ """Recover from a crash by reconciling persisted state with runtime.
96
+
97
+ Reads all tasks from the state store. For IN_PROGRESS tasks with no
98
+ active worker: checks for orphaned workers via the runtime and cancels
99
+ them, then adds the task to internal tracking for re-spawn.
100
+ """
101
+ world = self._state.get_world()
102
+
103
+ for task in world.tasks.values():
104
+ if task.status != TaskStatus.IN_PROGRESS:
105
+ continue
106
+ if task.id in self._workers:
107
+ continue
108
+
109
+ branch = task.branch or f"worker/{task.id}"
110
+
111
+ # Check for orphaned workers left from a previous session
112
+ orphan = self._runtime.find_orphan(task.id, branch)
113
+ if orphan is not None:
114
+ self._runtime.cancel(orphan)
115
+
116
+ # We don't add to _workers here — instead leave it workerless
117
+ # so decide() will emit a SpawnWorker for it next cycle.
118
+ logger.info(
119
+ "recover: task %s is IN_PROGRESS at phase %s, will re-spawn",
120
+ task.id,
121
+ task.phase,
122
+ )
123
+
124
+ def run_cycle(self) -> str | None:
125
+ """Run one serial section cycle.
126
+
127
+ Returns a halt reason string if the loop should stop, or None to continue.
128
+ """
129
+ executor = PipelineExecutor(self._process.pipeline)
130
+
131
+ # Cache world snapshot once per cycle — augmented with worker state
132
+ world = self._build_world()
133
+
134
+ # ---- 1. Reap finished workers ----------------------------------------
135
+ reaped_results: dict[str, WorkerResult] = {}
136
+ had_failures_this_cycle = False
137
+
138
+ actions = decide(world, self._max_workers, self._max_rounds)
139
+
140
+ # Process ReapWorker actions from decide()
141
+ for action in actions:
142
+ if isinstance(action, ReapWorker):
143
+ task_id = action.task_id
144
+ if task_id in self._workers:
145
+ handle, _pos = self._workers[task_id]
146
+ result = self._runtime.reap(handle)
147
+ reaped_results[task_id] = result
148
+
149
+ # ---- 2. Process reaped results through pipeline ----------------------
150
+ to_spawn: list[tuple[str, str, PipelinePosition]] = []
151
+ halt_reason: str | None = None
152
+
153
+ for task_id, result in reaped_results.items():
154
+ _handle, position = self._workers.pop(task_id)
155
+ task = self._state.get_task(task_id)
156
+
157
+ pipe_action, new_pos = executor.next_action(position, result)
158
+
159
+ if isinstance(pipe_action, PipelineComplete):
160
+ self._state.transition_task(task_id, TaskStatus.COMPLETE, phase=None)
161
+ self._state.clear_findings(task_id)
162
+
163
+ elif isinstance(pipe_action, PipelineFailed):
164
+ self._state.transition_task(task_id, TaskStatus.FAILED, phase=None)
165
+ self._state.store_findings(task_id, result.detail)
166
+ halt_reason = f"task {task_id} pipeline failed: {pipe_action.reason}"
167
+ had_failures_this_cycle = True
168
+
169
+ elif isinstance(pipe_action, SpawnRole):
170
+ if result.verdict in (Verdict.FAIL, Verdict.ERROR, Verdict.TIMEOUT):
171
+ had_failures_this_cycle = True
172
+ new_round = task.round + 1
173
+ if new_round >= self._max_rounds:
174
+ self._state.transition_task(
175
+ task_id,
176
+ TaskStatus.FAILED,
177
+ phase=None,
178
+ round=new_round,
179
+ )
180
+ halt_reason = f"task {task_id} exceeded max_rounds ({self._max_rounds})"
181
+ continue
182
+ self._state.store_findings(task_id, result.detail)
183
+ self._state.transition_task(
184
+ task_id,
185
+ TaskStatus.IN_PROGRESS,
186
+ phase=Phase(pipe_action.role),
187
+ round=new_round,
188
+ )
189
+ else:
190
+ self._state.transition_task(
191
+ task_id,
192
+ TaskStatus.IN_PROGRESS,
193
+ phase=Phase(pipe_action.role),
194
+ )
195
+ to_spawn.append((task_id, pipe_action.role, new_pos))
196
+
197
+ else:
198
+ # WaitForGate, PerformAction — record phase, don't spawn
199
+ phase_name = _phase_for_action(pipe_action)
200
+ self._state.transition_task(
201
+ task_id,
202
+ TaskStatus.IN_PROGRESS,
203
+ phase=Phase(phase_name) if phase_name else None,
204
+ )
205
+
206
+ # ---- 2b. Halt if any task failed -------------------------------------
207
+ if halt_reason is not None:
208
+ self._state.commit("orchestrator: halt")
209
+ return halt_reason
210
+
211
+ # ---- 3. Process-improver (stub) --------------------------------------
212
+ if had_failures_this_cycle:
213
+ logger.info("process-improver: stub — would run on trunk with this cycle's findings")
214
+
215
+ # ---- 4. Intake (stub) ------------------------------------------------
216
+ logger.debug("intake: stub — would run if configured and new specs exist")
217
+
218
+ # ---- 5. Poll gates ---------------------------------------------------
219
+ self._poll_gates(executor, to_spawn)
220
+
221
+ # ---- 6. Merge ready PRs ----------------------------------------------
222
+ self._merge_ready_prs()
223
+
224
+ # ---- 7. Decide what to spawn (via decide()) -------------------------
225
+ # Rebuild world after reaping + gates + merges (tasks may have changed)
226
+ world_after_reap = self._build_world()
227
+ spawn_actions = decide(world_after_reap, self._max_workers, self._max_rounds)
228
+
229
+ # Check for Halt from decide() (convergence or max_rounds)
230
+ for action in spawn_actions:
231
+ if isinstance(action, Halt):
232
+ self._state.commit("orchestrator: halt")
233
+ return action.reason
234
+
235
+ # Process SpawnWorker actions
236
+ already_spawning = {tid for tid, _role, _pos in to_spawn}
237
+ for action in spawn_actions:
238
+ if isinstance(action, SpawnWorker) and action.task_id not in already_spawning:
239
+ task = self._state.get_task(action.task_id)
240
+
241
+ if action.role == "rebase-resolver":
242
+ pos = executor.initial_position()
243
+ self._state.transition_task(
244
+ action.task_id,
245
+ TaskStatus.IN_PROGRESS,
246
+ phase=Phase("rebase-resolver"),
247
+ )
248
+ to_spawn.append((action.task_id, "rebase-resolver", pos))
249
+ elif task.status == TaskStatus.IN_PROGRESS and task.phase is not None:
250
+ pos = self._position_from_phase(executor, task)
251
+ to_spawn.append((action.task_id, str(task.phase), pos))
252
+ else:
253
+ pos = executor.initial_position()
254
+ pipe_action, pos = executor.next_action(pos, result=None)
255
+ if isinstance(pipe_action, SpawnRole):
256
+ self._state.transition_task(
257
+ action.task_id,
258
+ TaskStatus.IN_PROGRESS,
259
+ phase=Phase(pipe_action.role),
260
+ )
261
+ to_spawn.append((action.task_id, pipe_action.role, pos))
262
+
263
+ # ---- 8. Commit state -------------------------------------------------
264
+ if reaped_results or to_spawn:
265
+ self._state.commit("orchestrator: cycle update")
266
+
267
+ # ---- 9. Spawn workers ------------------------------------------------
268
+ for task_id, role, position in to_spawn:
269
+ task = self._state.get_task(task_id)
270
+ branch = task.branch or f"worker/{task_id}"
271
+ prompt = self._compose_prompt(task, role)
272
+ handle = self._runtime.spawn(task_id, role, prompt=prompt, branch=branch)
273
+ self._workers[task_id] = (handle, position)
274
+
275
+ # ---- Check convergence -----------------------------------------------
276
+ all_tasks = self._state.get_world().tasks
277
+ if not all_tasks:
278
+ return None
279
+
280
+ all_complete = all(t.status == TaskStatus.COMPLETE for t in all_tasks.values())
281
+ no_workers = len(self._workers) == 0
282
+
283
+ if all_complete and no_workers:
284
+ return "all tasks complete"
285
+
286
+ return None
287
+
288
+ # -----------------------------------------------------------------------
289
+ # Gate polling and merge
290
+ # -----------------------------------------------------------------------
291
+
292
+ def _poll_gates(
293
+ self,
294
+ executor: PipelineExecutor,
295
+ to_spawn: list[tuple[str, str, PipelinePosition]],
296
+ ) -> None:
297
+ """Poll gates for tasks at a gate step. If cleared, advance the task."""
298
+ if self._pr_manager is None:
299
+ logger.debug("gates: no PRManager — skipping gate polling")
300
+ return
301
+
302
+ all_tasks = self._state.get_world().tasks
303
+ for task in all_tasks.values():
304
+ if task.status != TaskStatus.IN_PROGRESS:
305
+ continue
306
+ if task.phase is None:
307
+ continue
308
+ if task.pr is None:
309
+ continue
310
+
311
+ # Check if this phase corresponds to a gate step in the pipeline
312
+ pos = self._find_position_for_step(executor, str(task.phase))
313
+ if pos is None:
314
+ continue
315
+
316
+ step = PipelineExecutor.resolve_step(executor.pipeline, pos.path)
317
+ if not isinstance(step, GateStep):
318
+ continue
319
+
320
+ # Task is at a gate — poll it
321
+ cleared = self._pr_manager.check_gate(task.pr, step.gate)
322
+ if not cleared:
323
+ continue
324
+
325
+ # Gate cleared — advance to next pipeline step
326
+ logger.info("Gate '%s' cleared for task %s", step.gate, task.id)
327
+ advanced = PipelineExecutor.advance_from(executor.pipeline, pos.path)
328
+ if advanced is not None:
329
+ next_action, new_pos = advanced
330
+ phase_name = _phase_for_pipe_action(next_action)
331
+ self._state.transition_task(
332
+ task.id,
333
+ TaskStatus.IN_PROGRESS,
334
+ phase=Phase(phase_name) if phase_name else None,
335
+ )
336
+ if isinstance(next_action, SpawnRole):
337
+ to_spawn.append((task.id, next_action.role, new_pos))
338
+ else:
339
+ self._state.transition_task(task.id, TaskStatus.COMPLETE, phase=None)
340
+ self._state.clear_findings(task.id)
341
+
342
+ def _merge_ready_prs(self) -> None:
343
+ """Merge PRs for tasks at the merge-pr action step.
344
+
345
+ Rebases branch first; on conflict transitions to NEEDS_REBASE.
346
+ """
347
+ if self._pr_manager is None:
348
+ logger.debug("merge: no PRManager — skipping merge")
349
+ return
350
+
351
+ all_tasks = self._state.get_world().tasks
352
+ for task in all_tasks.values():
353
+ if task.status != TaskStatus.IN_PROGRESS:
354
+ continue
355
+ if task.phase != Phase("merge-pr"):
356
+ continue
357
+ if task.pr is None:
358
+ continue
359
+
360
+ branch = task.branch or f"worker/{task.id}"
361
+
362
+ # Step 1: Rebase onto base branch
363
+ if not self._pr_manager.rebase_branch(branch, "main"):
364
+ logger.warning("Rebase conflict for task %s, marking NEEDS_REBASE", task.id)
365
+ self._state.transition_task(
366
+ task.id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr")
367
+ )
368
+ continue
369
+
370
+ # Step 2: Squash-merge the PR
371
+ if not self._pr_manager.merge(task.pr, task.id, task.spec_ref):
372
+ logger.warning("Merge conflict for task %s, marking NEEDS_REBASE", task.id)
373
+ self._state.transition_task(
374
+ task.id, TaskStatus.NEEDS_REBASE, phase=Phase("merge-pr")
375
+ )
376
+ continue
377
+
378
+ # Merge succeeded — mark task complete
379
+ self._state.transition_task(task.id, TaskStatus.COMPLETE, phase=None)
380
+ self._state.clear_findings(task.id)
381
+ logger.info("Merged PR for task %s", task.id)
382
+
383
+ # -----------------------------------------------------------------------
384
+ # World building
385
+ # -----------------------------------------------------------------------
386
+
387
+ def _build_world(self) -> World:
388
+ """Build a World snapshot including current worker state from runtime."""
389
+ base_world = self._state.get_world()
390
+ workers: dict[str, WorkerState] = {}
391
+ for task_id, (handle, _pos) in self._workers.items():
392
+ poll_status = self._runtime.poll(handle)
393
+ workers[task_id] = WorkerState(
394
+ task_id=task_id,
395
+ role=handle.role,
396
+ status=poll_status,
397
+ )
398
+ return World(
399
+ tasks=base_world.tasks,
400
+ workers=workers,
401
+ epoch=base_world.epoch,
402
+ )
403
+
404
+ # -----------------------------------------------------------------------
405
+ # Prompt composition
406
+ # -----------------------------------------------------------------------
407
+
408
+ def _compose_prompt(self, task: Task, role: str) -> str:
409
+ """Compose a prompt for a worker using PromptComposer if available."""
410
+ if self._composer is None:
411
+ return ""
412
+
413
+ findings = self._state.get_findings(task.id)
414
+ return self._composer.compose(
415
+ role=role,
416
+ task_id=task.id,
417
+ spec_ref=task.spec_ref,
418
+ findings=findings,
419
+ )
420
+
421
+ # -----------------------------------------------------------------------
422
+ # Pipeline position helpers
423
+ # -----------------------------------------------------------------------
424
+
425
+ def _position_from_phase(self, executor: PipelineExecutor, task: Task) -> PipelinePosition:
426
+ """Determine pipeline position from a task's current phase."""
427
+ if task.phase is not None:
428
+ pos = self._find_position_for_role(executor, str(task.phase))
429
+ if pos is not None:
430
+ return pos
431
+ return executor.initial_position()
432
+
433
+ @staticmethod
434
+ def _find_position_for_role(executor: PipelineExecutor, role: str) -> PipelinePosition | None:
435
+ """Walk the pipeline for a RoleStep matching the given role name."""
436
+
437
+ def _search(
438
+ steps: tuple[PipelineStep, ...], prefix: tuple[int, ...]
439
+ ) -> PipelinePosition | None:
440
+ for i, step in enumerate(steps):
441
+ path = (*prefix, i)
442
+ if isinstance(step, RoleStep) and step.role == role:
443
+ return PipelinePosition(path=path)
444
+ if isinstance(step, LoopStep):
445
+ found = _search(step.steps, path)
446
+ if found is not None:
447
+ return found
448
+ return None
449
+
450
+ return _search(executor.pipeline, ())
451
+
452
+ @staticmethod
453
+ def _find_position_for_step(
454
+ executor: PipelineExecutor, phase_name: str
455
+ ) -> PipelinePosition | None:
456
+ """Walk the pipeline for any step whose name matches phase_name.
457
+
458
+ Searches depth-first across RoleStep, GateStep, and ActionStep.
459
+ """
460
+
461
+ def _step_name(step: PipelineStep) -> str | None:
462
+ if isinstance(step, RoleStep):
463
+ return step.role
464
+ if isinstance(step, GateStep):
465
+ return step.gate
466
+ if isinstance(step, ActionStep):
467
+ return step.action
468
+ return None
469
+
470
+ def _search(
471
+ steps: tuple[PipelineStep, ...], prefix: tuple[int, ...]
472
+ ) -> PipelinePosition | None:
473
+ for i, step in enumerate(steps):
474
+ path = (*prefix, i)
475
+ if _step_name(step) == phase_name:
476
+ return PipelinePosition(path=path)
477
+ if isinstance(step, LoopStep):
478
+ found = _search(step.steps, path)
479
+ if found is not None:
480
+ return found
481
+ return None
482
+
483
+ return _search(executor.pipeline, ())
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Module-level helpers
488
+ # ---------------------------------------------------------------------------
489
+
490
+
491
+ def _phase_for_action(action: object) -> str | None:
492
+ """Extract a phase name from a pipeline action (WaitForGate or PerformAction)."""
493
+ if isinstance(action, WaitForGate):
494
+ return action.gate
495
+ if isinstance(action, PerformAction):
496
+ return action.action
497
+ return None
498
+
499
+
500
+ def _phase_for_pipe_action(
501
+ action: SpawnRole | WaitForGate | PerformAction | PipelineComplete | PipelineFailed,
502
+ ) -> str | None:
503
+ """Extract a phase name from any pipeline action type."""
504
+ if isinstance(action, SpawnRole):
505
+ return action.role
506
+ if isinstance(action, WaitForGate):
507
+ return action.gate
508
+ if isinstance(action, PerformAction):
509
+ return action.action
510
+ return None
File without changes
hyperloop/ports/pr.py ADDED
@@ -0,0 +1,32 @@
1
+ """PRManager port — interface for PR lifecycle operations.
2
+
3
+ Implementations: PRManager (gh CLI), FakePRManager (in-memory for tests).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Protocol
9
+
10
+
11
+ class PRPort(Protocol):
12
+ """Interface for PR lifecycle: gate polling, rebase, and merge."""
13
+
14
+ def create_draft(self, task_id: str, branch: str, title: str, spec_ref: str) -> str:
15
+ """Create a draft PR. Returns PR URL. Adds spec/task labels."""
16
+ ...
17
+
18
+ def check_gate(self, pr_url: str, gate: str) -> bool:
19
+ """Check if a gate signal is present. Returns True if gate is cleared."""
20
+ ...
21
+
22
+ def mark_ready(self, pr_url: str) -> None:
23
+ """Mark a draft PR as ready for review."""
24
+ ...
25
+
26
+ def merge(self, pr_url: str, task_id: str, spec_ref: str) -> bool:
27
+ """Squash-merge a PR. Returns True on success, False on conflict."""
28
+ ...
29
+
30
+ def rebase_branch(self, branch: str, base_branch: str) -> bool:
31
+ """Rebase a branch onto base. Returns True if clean, False if conflicts."""
32
+ ...
@@ -0,0 +1,37 @@
1
+ """Runtime port — interface for managing worker agent sessions.
2
+
3
+ Implementations: LocalRuntime (worktrees + CLI), AmbientRuntime (platform API).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Literal, Protocol
9
+
10
+ if TYPE_CHECKING:
11
+ from hyperloop.domain.model import WorkerHandle, WorkerResult
12
+
13
+ WorkerPollStatus = Literal["running", "done", "failed"]
14
+
15
+
16
+ class Runtime(Protocol):
17
+ """Spawn, poll, and collect results from worker agents."""
18
+
19
+ def spawn(self, task_id: str, role: str, prompt: str, branch: str) -> WorkerHandle:
20
+ """Start a worker agent session on the given branch."""
21
+ ...
22
+
23
+ def poll(self, handle: WorkerHandle) -> WorkerPollStatus:
24
+ """Check worker status. Returns 'running', 'done', or 'failed'."""
25
+ ...
26
+
27
+ def reap(self, handle: WorkerHandle) -> WorkerResult:
28
+ """Collect the result from a finished worker and clean up."""
29
+ ...
30
+
31
+ def cancel(self, handle: WorkerHandle) -> None:
32
+ """Terminate a running worker session."""
33
+ ...
34
+
35
+ def find_orphan(self, task_id: str, branch: str) -> WorkerHandle | None:
36
+ """Find a worker left running from a previous orchestrator session (crash recovery)."""
37
+ ...
@@ -0,0 +1,61 @@
1
+ """StateStore port — interface for persisting orchestrator state.
2
+
3
+ Implementations: GitStateStore (git commits), AmbientStateStore (platform API).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Protocol
9
+
10
+ if TYPE_CHECKING:
11
+ from hyperloop.domain.model import Phase, Task, TaskStatus, World
12
+
13
+
14
+ class StateStore(Protocol):
15
+ """Read and write orchestrator state (tasks, findings, epochs)."""
16
+
17
+ def get_world(self) -> World:
18
+ """Return a complete snapshot of all tasks, workers, and the current epoch."""
19
+ ...
20
+
21
+ def get_task(self, task_id: str) -> Task:
22
+ """Return a single task by ID."""
23
+ ...
24
+
25
+ def transition_task(
26
+ self,
27
+ task_id: str,
28
+ status: TaskStatus,
29
+ phase: Phase | None,
30
+ round: int | None = None,
31
+ ) -> None:
32
+ """Update a task's status, phase, and optionally round."""
33
+ ...
34
+
35
+ def store_findings(self, task_id: str, detail: str) -> None:
36
+ """Append findings detail text to the task file's Findings section on trunk."""
37
+ ...
38
+
39
+ def get_findings(self, task_id: str) -> str:
40
+ """Return stored findings for a task. Empty string if none."""
41
+ ...
42
+
43
+ def clear_findings(self, task_id: str) -> None:
44
+ """Clear the findings section of a task file (on completion)."""
45
+ ...
46
+
47
+ def get_epoch(self, key: str) -> str:
48
+ """Return the content fingerprint for skip logic."""
49
+ ...
50
+
51
+ def set_epoch(self, key: str, value: str) -> None:
52
+ """Record a last-run marker."""
53
+ ...
54
+
55
+ def read_file(self, path: str) -> str | None:
56
+ """Read a file from trunk. Returns None if the file does not exist."""
57
+ ...
58
+
59
+ def commit(self, message: str) -> None:
60
+ """Persist all pending state changes."""
61
+ ...