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

1"""The keel backbone: the fixed, ordered step machine and its extension slots. 

2 

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. 

7 

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.) 

14 

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""" 

20 

21from __future__ import annotations 

22 

23from dataclasses import dataclass 

24 

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 

28 

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 

33 

34 

35@dataclass(frozen=True) 

36class Step: 

37 """One backbone step. 

38 

39 ``slot`` is the historical primary extension point, kept for compatibility. 

40 New hook-aware code should use :func:`slots_for_step`. 

41 """ 

42 

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) 

47 

48 

49@dataclass(frozen=True) 

50class Slot: 

51 """One named extension hook exposed by a backbone step.""" 

52 

53 name: str 

54 step_id: str 

55 execution_mode: str = "deterministic" 

56 may_block: bool = False 

57 adapter_required: bool = False 

58 

59 

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) 

76 

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) 

108 

109#: The named slots, in backbone order. Extensions are add-only into these. 

110SLOTS: tuple[str, ...] = tuple(slot.name for slot in SLOT_DEFINITIONS) 

111 

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) 

120 

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} 

124 

125 

126def step_ids() -> tuple[str, ...]: 

127 """All backbone step IDs in order.""" 

128 return tuple(s.id for s in BACKBONE) 

129 

130 

131def get_step(step_id: str) -> Step: 

132 """Return the step with ``step_id`` (raises ``KeyError`` if unknown).""" 

133 return _BY_ID[step_id] 

134 

135 

136def step_for_slot(slot: str) -> Step: 

137 """Return the backbone step that exposes ``slot`` (raises ``KeyError``).""" 

138 return _BY_SLOT[slot] 

139 

140 

141def slot_meta(slot: str) -> Slot: 

142 """Return metadata for a named extension hook (raises ``KeyError``).""" 

143 return _SLOT_META[slot] 

144 

145 

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)