Coverage for src/keel/model.py: 100%
34 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""The keel backbone: the fixed, ordered step machine and its extension slots.
3This module is the single source of truth for the step IDs, the named slots an
4extension may register into, and the invariants the backbone always preserves.
5It is pure data — no I/O, no config — so consumers (config, extensions,
6orchestrator) and tests can all agree on one definition.
8It also holds the two gate wall-clock defaults, :data:`DEFAULT_GATE_TIMEOUT_S` and
9:data:`DEFAULT_JURY_TIMEOUT_S`. Neither is part of the step machine; they live here
10because this is the only module with no intra-package imports, so ``config``, ``gates``,
11``runner``, and ``jury`` can share one value without any of them importing each other.
12(Defining them in ``gates`` or ``jury`` instead closes a real cycle — ``gates`` names
13``config`` in its ``TYPE_CHECKING`` imports, which CodeQL counts as an edge.)
15Two related constants is the ceiling for this arrangement: a **third** shared constant,
16or a first one unrelated to gate timeouts, should become a dedicated leaf module
17(``limits.py``) that this one imports, rather than letting ``model`` accrete values that
18have nothing to do with the backbone.
19"""
21from __future__ import annotations
23from dataclasses import dataclass
25#: Wall-clock seconds a command gate may run, when neither the gate's own ``timeout:``
26#: nor ``knobs.gate_timeout_s`` overrides it. (Why it lives here: module docstring.)
27DEFAULT_GATE_TIMEOUT_S: int = 600
29#: Wall-clock seconds the ``jury`` built-in may run. Separate from the above on purpose:
30#: the jury is a cross-vendor agent CLI, not a project test command, so its budget should
31#: be raisable without loosening every test gate — and vice versa.
32DEFAULT_JURY_TIMEOUT_S: int = 600
35@dataclass(frozen=True)
36class Step:
37 """One backbone step.
39 ``slot`` is the historical primary extension point, kept for compatibility.
40 New hook-aware code should use :func:`slots_for_step`.
41 """
43 id: str
44 name: str
45 slot: str | None = None
46 agentic: bool = False # True if the step dispatches to an agent (implement/review/classify)
49@dataclass(frozen=True)
50class Slot:
51 """One named extension hook exposed by a backbone step."""
53 name: str
54 step_id: str
55 execution_mode: str = "deterministic"
56 may_block: bool = False
57 adapter_required: bool = False
60#: The fixed backbone, in execution order. Changing this is a keel-core change.
61BACKBONE: tuple[Step, ...] = (
62 Step("s0", "config"),
63 Step("s1", "select"),
64 Step("s2", "branch"),
65 Step("s3", "guard"),
66 Step("s4", "implement", slot="after-implement", agentic=True),
67 Step("s5", "classify", agentic=True),
68 Step("s6", "ci"),
69 Step("s7", "review", slot="reviewers", agentic=True),
70 Step("s8", "test", slot="tester"),
71 Step("s9", "fixloop"),
72 Step("s10", "merge", slot="pre-merge"),
73 Step("s11", "capture", slot="post-merge"),
74 Step("s12", "close"),
75)
77#: The named hooks, in backbone order. Extensions are add-only into these.
78SLOT_DEFINITIONS: tuple[Slot, ...] = (
79 Slot("after:config", "s0"),
80 Slot("before:select", "s1"),
81 Slot("select", "s1", adapter_required=True),
82 Slot("after:select", "s1"),
83 Slot("before:branch", "s2"),
84 Slot("after:branch", "s2"),
85 Slot("guard", "s3", may_block=True),
86 Slot("before:implement", "s4", adapter_required=True),
87 Slot("after-implement", "s4", adapter_required=True),
88 Slot("classify", "s5", adapter_required=True),
89 Slot("after:classify", "s5"),
90 Slot("before:ci", "s6"),
91 Slot("after:ci", "s6"),
92 Slot("reviewers", "s7", execution_mode="agentic", adapter_required=True),
93 Slot("after:review", "s7", adapter_required=True),
94 Slot("tester", "s8", execution_mode="hybrid", may_block=True, adapter_required=True),
95 Slot("test", "s8", execution_mode="hybrid", may_block=True, adapter_required=True),
96 Slot("after:test", "s8"),
97 Slot("before:fixloop", "s9", adapter_required=True),
98 Slot("fixloop", "s9", execution_mode="hybrid", adapter_required=True),
99 Slot("after:fixloop", "s9"),
100 Slot("pre-merge", "s10", may_block=True),
101 Slot("after:merge", "s10"),
102 Slot("capture", "s11", adapter_required=True),
103 Slot("post-merge", "s11", adapter_required=True),
104 Slot("before:close", "s12"),
105 Slot("on-close", "s12", adapter_required=True),
106 Slot("after:close", "s12"),
107)
109#: The named slots, in backbone order. Extensions are add-only into these.
110SLOTS: tuple[str, ...] = tuple(slot.name for slot in SLOT_DEFINITIONS)
112#: Invariants the backbone always preserves — no config or extension can override.
113INVARIANTS: tuple[str, ...] = (
114 "merge_lock", # every merge goes through the mkdir-based lock
115 "window_gate", # the night no-merge window is enforced
116 "fail_soft", # a soft failure degrades to a no-op, never aborts
117 "orchestrator_only_writes", # only the orchestrator writes to the PR
118 "attribution", # implementer/reviewer vendor+model is recorded
119)
121_BY_ID: dict[str, Step] = {s.id: s for s in BACKBONE}
122_SLOT_META: dict[str, Slot] = {slot.name: slot for slot in SLOT_DEFINITIONS}
123_BY_SLOT: dict[str, Step] = {slot: _BY_ID[_SLOT_META[slot].step_id] for slot in SLOTS}
126def step_ids() -> tuple[str, ...]:
127 """All backbone step IDs in order."""
128 return tuple(s.id for s in BACKBONE)
131def get_step(step_id: str) -> Step:
132 """Return the step with ``step_id`` (raises ``KeyError`` if unknown)."""
133 return _BY_ID[step_id]
136def step_for_slot(slot: str) -> Step:
137 """Return the backbone step that exposes ``slot`` (raises ``KeyError``)."""
138 return _BY_SLOT[slot]
141def slot_meta(slot: str) -> Slot:
142 """Return metadata for a named extension hook (raises ``KeyError``)."""
143 return _SLOT_META[slot]
146def slots_for_step(step_id: str) -> tuple[Slot, ...]:
147 """Return extension hooks exposed by ``step_id`` in declared order."""
148 return tuple(slot for slot in SLOT_DEFINITIONS if slot.step_id == step_id)