autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/transitions.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Controller phase state machine.
|
|
2
|
+
|
|
3
|
+
Legal transitions::
|
|
4
|
+
|
|
5
|
+
INITIALIZING -> ANALYZE_EXECUTE
|
|
6
|
+
ANALYZE_EXECUTE -> REVIEW
|
|
7
|
+
REVIEW -> REVIEW (the reviewed revision moved during the review:
|
|
8
|
+
the round is consumed, then re-reviewed)
|
|
9
|
+
REVIEW -> FIX (needs_fix_round=true)
|
|
10
|
+
REVIEW -> REPLAN_REEXECUTE (controller policy; findings remain)
|
|
11
|
+
REPLAN_REEXECUTE -> REVIEW (replacement PR, fresh round 1)
|
|
12
|
+
REVIEW -> READY_FOR_MERGE (needs_fix_round=false, revision unchanged)
|
|
13
|
+
FIX -> REVIEW
|
|
14
|
+
READY_FOR_MERGE -> MERGE (only behind the merge safety gate)
|
|
15
|
+
READY_FOR_MERGE -> REVIEW (the revision moved after the clean review)
|
|
16
|
+
MERGE -> UPDATE_EPIC (controller merged; GitHub confirms MERGED)
|
|
17
|
+
MERGE -> REVIEW (the revision moved after the clean review)
|
|
18
|
+
MERGE -> ANALYZE_EXECUTE (reserved; unused)
|
|
19
|
+
MERGE -> DONE (reserved; unused)
|
|
20
|
+
Both are reserved edges: batching is decided
|
|
21
|
+
inside UPDATE_EPIC, which runs after every merge.
|
|
22
|
+
UPDATE_EPIC -> ANALYZE_EXECUTE (next_issue_url != null)
|
|
23
|
+
UPDATE_EPIC -> DONE (next_issue_url == null)
|
|
24
|
+
|
|
25
|
+
LOCAL runs (``WorkflowMode.LOCAL``) drive a much smaller topology over a
|
|
26
|
+
feature Markdown file and the local working tree — no GitHub, no PR, no
|
|
27
|
+
merge::
|
|
28
|
+
|
|
29
|
+
INITIALIZING -> ANALYZE_EXECUTE
|
|
30
|
+
ANALYZE_EXECUTE -> REVIEW
|
|
31
|
+
REVIEW -> FIX (needs_fix_round=true, fix budget left)
|
|
32
|
+
FIX -> REVIEW
|
|
33
|
+
REVIEW -> DONE (needs_fix_round=false)
|
|
34
|
+
|
|
35
|
+
``REVIEW -> READY_FOR_MERGE`` is deliberately *not* legal in LOCAL mode and
|
|
36
|
+
``REVIEW -> DONE`` is deliberately *not* legal in REMOTE mode: nothing may
|
|
37
|
+
reach DONE locally through the merge machinery, and no remote run may skip
|
|
38
|
+
it. A LOCAL review that still has findings once the fix budget is spent
|
|
39
|
+
enters BLOCKED, exactly like a remote loop bound.
|
|
40
|
+
|
|
41
|
+
BLOCKED / FAILED are exceptional holding states reachable from any agent
|
|
42
|
+
phase (agent reports blocked/failure, or the controller cannot verify
|
|
43
|
+
GitHub state deterministically); they are outside the happy-path topology.
|
|
44
|
+
|
|
45
|
+
BLOCKED has *operator* edges out of it (``UNBLOCK_TARGETS``), taken only by
|
|
46
|
+
the explicit ``unblock`` command after the controller has re-inspected live
|
|
47
|
+
GitHub state and chosen the phase to re-enter; no agent result and no
|
|
48
|
+
``resume`` ever leaves BLOCKED on its own::
|
|
49
|
+
|
|
50
|
+
BLOCKED -> ANALYZE_EXECUTE (no PR bound; the entry adopts or launches)
|
|
51
|
+
BLOCKED -> REVIEW (open PR; no current review of its revision)
|
|
52
|
+
BLOCKED -> FIX (open PR at the reviewed HEAD with open findings)
|
|
53
|
+
BLOCKED -> READY_FOR_MERGE (open or merged PR at the clean-reviewed revision)
|
|
54
|
+
BLOCKED -> UPDATE_EPIC (PR merged and already counted)
|
|
55
|
+
|
|
56
|
+
``BLOCKED -> REPLAN_REEXECUTE`` is deliberately absent: REVIEW stays that
|
|
57
|
+
transaction's only entry. FAILED and DONE keep no outgoing edge, and the
|
|
58
|
+
LOCAL topology has no unblock edge at all.
|
|
59
|
+
|
|
60
|
+
All transition logic lives here — never scattered across CLI handlers or
|
|
61
|
+
the engine. ``decide_next_phase`` is the one function that chooses the next
|
|
62
|
+
phase of a lifecycle step; the engine verifies the agent's claims against
|
|
63
|
+
GitHub, makes its own observations, and hands both to it. The engine only
|
|
64
|
+
ever names BLOCKED and FAILED itself, which are not edges of this topology.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
from collections.abc import Callable
|
|
70
|
+
from enum import Enum
|
|
71
|
+
from typing import TYPE_CHECKING
|
|
72
|
+
|
|
73
|
+
from .errors import StateTransitionError
|
|
74
|
+
|
|
75
|
+
if TYPE_CHECKING:
|
|
76
|
+
# Typing only: the state module imports this one for Phase.
|
|
77
|
+
from .state import AutoForgeState
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WorkflowMode(Enum):
|
|
81
|
+
"""Which workflow a run executes.
|
|
82
|
+
|
|
83
|
+
REMOTE is the original GitHub lifecycle (Issue -> PR -> review -> merge).
|
|
84
|
+
LOCAL drives a feature Markdown file against the local working tree and
|
|
85
|
+
never touches GitHub. The mode is explicit controller state, never
|
|
86
|
+
inferred from whether some field happens to be empty.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
REMOTE = "REMOTE"
|
|
90
|
+
LOCAL = "LOCAL"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Phase(Enum):
|
|
94
|
+
INITIALIZING = "INITIALIZING"
|
|
95
|
+
ANALYZE_EXECUTE = "ANALYZE_EXECUTE"
|
|
96
|
+
REVIEW = "REVIEW"
|
|
97
|
+
FIX = "FIX"
|
|
98
|
+
REPLAN_REEXECUTE = "REPLAN_REEXECUTE"
|
|
99
|
+
READY_FOR_MERGE = "READY_FOR_MERGE"
|
|
100
|
+
MERGE = "MERGE"
|
|
101
|
+
UPDATE_EPIC = "UPDATE_EPIC"
|
|
102
|
+
DONE = "DONE"
|
|
103
|
+
BLOCKED = "BLOCKED"
|
|
104
|
+
FAILED = "FAILED"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
TERMINAL_PHASES = frozenset({Phase.DONE, Phase.BLOCKED, Phase.FAILED})
|
|
108
|
+
|
|
109
|
+
# Phases at which ``run``/``resume`` stop looping while the merge gate is
|
|
110
|
+
# closed. READY_FOR_MERGE is a safe holding state in this milestone: automatic
|
|
111
|
+
# merge is disabled by default. With the gate open (config AND --allow-merge)
|
|
112
|
+
# the loop only stops at TERMINAL_PHASES.
|
|
113
|
+
STOP_PHASES = TERMINAL_PHASES | frozenset({Phase.READY_FOR_MERGE})
|
|
114
|
+
|
|
115
|
+
# LOCAL phases whose agent can change the working tree. REVIEW is excluded: it
|
|
116
|
+
# is read-only and the controller *rejects* a reviewer that changed anything.
|
|
117
|
+
# A pending-invocation checkpoint (``AutoForgeState.local_pending_phase``) may
|
|
118
|
+
# name one of these and nothing else, which is why the tuple lives here rather
|
|
119
|
+
# than in the engine: ``state`` validates a loaded checkpoint against it.
|
|
120
|
+
LOCAL_WRITE_PHASES = (Phase.ANALYZE_EXECUTE, Phase.FIX)
|
|
121
|
+
|
|
122
|
+
# Phases whose step invokes an agent through a provider. MERGE is NOT one of
|
|
123
|
+
# them: the controller runs `gh pr merge` itself (agents never merge).
|
|
124
|
+
AGENT_PHASES = frozenset(
|
|
125
|
+
{Phase.ANALYZE_EXECUTE, Phase.REVIEW, Phase.FIX, Phase.REPLAN_REEXECUTE, Phase.UPDATE_EPIC}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Static topology: every edge that can ever be legal (conditions on the
|
|
129
|
+
# CONTROL_RESULT payload are checked in decide_next_phase).
|
|
130
|
+
LEGAL_EDGES: dict[Phase, frozenset[Phase]] = {
|
|
131
|
+
Phase.INITIALIZING: frozenset({Phase.ANALYZE_EXECUTE}),
|
|
132
|
+
Phase.ANALYZE_EXECUTE: frozenset({Phase.REVIEW}),
|
|
133
|
+
# REVIEW -> REVIEW: the PR HEAD moved during the review (stale review).
|
|
134
|
+
Phase.REVIEW: frozenset(
|
|
135
|
+
{Phase.FIX, Phase.REPLAN_REEXECUTE, Phase.READY_FOR_MERGE, Phase.REVIEW}
|
|
136
|
+
),
|
|
137
|
+
Phase.FIX: frozenset({Phase.REVIEW}),
|
|
138
|
+
Phase.REPLAN_REEXECUTE: frozenset({Phase.REVIEW}),
|
|
139
|
+
Phase.READY_FOR_MERGE: frozenset({Phase.MERGE, Phase.REVIEW}),
|
|
140
|
+
Phase.MERGE: frozenset({Phase.ANALYZE_EXECUTE, Phase.UPDATE_EPIC, Phase.DONE, Phase.REVIEW}),
|
|
141
|
+
Phase.UPDATE_EPIC: frozenset({Phase.ANALYZE_EXECUTE, Phase.DONE}),
|
|
142
|
+
Phase.DONE: frozenset(),
|
|
143
|
+
# Operator edges only: see UNBLOCK_TARGETS below and ControllerEngine.unblock.
|
|
144
|
+
Phase.BLOCKED: frozenset(
|
|
145
|
+
{
|
|
146
|
+
Phase.ANALYZE_EXECUTE,
|
|
147
|
+
Phase.REVIEW,
|
|
148
|
+
Phase.FIX,
|
|
149
|
+
Phase.READY_FOR_MERGE,
|
|
150
|
+
Phase.UPDATE_EPIC,
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
Phase.FAILED: frozenset(),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# The phases an explicit `unblock` may re-enter from BLOCKED (REMOTE only).
|
|
157
|
+
# `validate_transition(Phase.BLOCKED, target)` is the check the unblock path
|
|
158
|
+
# goes through, exactly like every other transition; this name exists so the
|
|
159
|
+
# engine, the docs and the tests refer to one decision rather than to a
|
|
160
|
+
# literal set repeated in each. REPLAN_REEXECUTE is never a target.
|
|
161
|
+
UNBLOCK_TARGETS: frozenset[Phase] = LEGAL_EDGES[Phase.BLOCKED]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Settlement(Enum):
|
|
165
|
+
"""Whether a per-issue state needs more automatic work (ADR 0008 D15.1, #181).
|
|
166
|
+
|
|
167
|
+
The parallel EPIC scheduler's only view of the engine: it never branches
|
|
168
|
+
on a phase, only on this. Every value but ``IN_PROGRESS`` is settled: the
|
|
169
|
+
scheduler launches nothing more for the issue.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
#: #126's durable pause holds: the run waits for a human's answer.
|
|
173
|
+
AWAITING_INPUT = "awaiting_input"
|
|
174
|
+
#: ``DONE``, ``BLOCKED`` or ``FAILED``.
|
|
175
|
+
TERMINAL = "terminal"
|
|
176
|
+
#: The stop phase reached with the merge gate closed; satisfies no dependency.
|
|
177
|
+
READY_FOR_MERGE = "ready_for_merge"
|
|
178
|
+
#: ``MERGE`` or ``UPDATE_EPIC``, which a scheduler worker never executes.
|
|
179
|
+
HANDOFF = "handoff"
|
|
180
|
+
#: Any other phase: an ordinary resume continues it.
|
|
181
|
+
IN_PROGRESS = "in_progress"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Total over Phase: ``test_transitions`` fails when a phase is added without one.
|
|
185
|
+
PHASE_SETTLEMENT: dict[Phase, Settlement] = {
|
|
186
|
+
Phase.INITIALIZING: Settlement.IN_PROGRESS,
|
|
187
|
+
Phase.ANALYZE_EXECUTE: Settlement.IN_PROGRESS,
|
|
188
|
+
Phase.REVIEW: Settlement.IN_PROGRESS,
|
|
189
|
+
Phase.FIX: Settlement.IN_PROGRESS,
|
|
190
|
+
Phase.REPLAN_REEXECUTE: Settlement.IN_PROGRESS,
|
|
191
|
+
Phase.READY_FOR_MERGE: Settlement.READY_FOR_MERGE,
|
|
192
|
+
Phase.MERGE: Settlement.HANDOFF,
|
|
193
|
+
Phase.UPDATE_EPIC: Settlement.HANDOFF,
|
|
194
|
+
Phase.DONE: Settlement.TERMINAL,
|
|
195
|
+
Phase.BLOCKED: Settlement.TERMINAL,
|
|
196
|
+
Phase.FAILED: Settlement.TERMINAL,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def durable_pause(state: AutoForgeState) -> bool:
|
|
201
|
+
"""#126's durable pause predicate: whether ``state`` waits for a human's answer.
|
|
202
|
+
|
|
203
|
+
The hook :func:`issue_settlement` checks before the phase. #126 has not
|
|
204
|
+
landed, so no state can hold a pause and this is ``False``; #126 wires
|
|
205
|
+
its predicate here (ADR 0008 D15.1, ADR 0007).
|
|
206
|
+
"""
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def issue_settlement(
|
|
211
|
+
state: AutoForgeState, *, paused: Callable[[AutoForgeState], bool] = durable_pause
|
|
212
|
+
) -> Settlement:
|
|
213
|
+
"""The settlement of a strictly loaded per-issue state (ADR 0008 D15.1); pure.
|
|
214
|
+
|
|
215
|
+
It takes the whole state, not its phase, because #126's pause is
|
|
216
|
+
invocation state: ``paused`` (the durable pause predicate) is checked
|
|
217
|
+
first and gives ``AWAITING_INPUT`` whatever the phase; then the phase is
|
|
218
|
+
mapped by :data:`PHASE_SETTLEMENT`.
|
|
219
|
+
"""
|
|
220
|
+
if paused(state):
|
|
221
|
+
return Settlement.AWAITING_INPUT
|
|
222
|
+
return PHASE_SETTLEMENT[state.phase]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# LOCAL topology: no PR, no merge, no replan, no EPIC. A clean review is the
|
|
226
|
+
# end of the run; findings the controller can no longer afford to fix enter
|
|
227
|
+
# BLOCKED (an exceptional state, not an edge).
|
|
228
|
+
LOCAL_LEGAL_EDGES: dict[Phase, frozenset[Phase]] = {
|
|
229
|
+
Phase.INITIALIZING: frozenset({Phase.ANALYZE_EXECUTE}),
|
|
230
|
+
Phase.ANALYZE_EXECUTE: frozenset({Phase.REVIEW}),
|
|
231
|
+
Phase.REVIEW: frozenset({Phase.FIX, Phase.DONE}),
|
|
232
|
+
Phase.FIX: frozenset({Phase.REVIEW}),
|
|
233
|
+
Phase.DONE: frozenset(),
|
|
234
|
+
Phase.BLOCKED: frozenset(),
|
|
235
|
+
Phase.FAILED: frozenset(),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# Phases a LOCAL run may ever execute. Everything else (REPLAN_REEXECUTE,
|
|
239
|
+
# READY_FOR_MERGE, MERGE, UPDATE_EPIC) belongs to the GitHub lifecycle.
|
|
240
|
+
LOCAL_PHASES = frozenset(
|
|
241
|
+
{
|
|
242
|
+
Phase.INITIALIZING,
|
|
243
|
+
Phase.ANALYZE_EXECUTE,
|
|
244
|
+
Phase.REVIEW,
|
|
245
|
+
Phase.FIX,
|
|
246
|
+
Phase.DONE,
|
|
247
|
+
Phase.BLOCKED,
|
|
248
|
+
Phase.FAILED,
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def edges_for(mode: WorkflowMode = WorkflowMode.REMOTE) -> dict[Phase, frozenset[Phase]]:
|
|
254
|
+
"""The static topology of ``mode`` (never mutate the returned mapping)."""
|
|
255
|
+
return LOCAL_LEGAL_EDGES if mode == WorkflowMode.LOCAL else LEGAL_EDGES
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def stop_phases_for(mode: WorkflowMode = WorkflowMode.REMOTE) -> frozenset[Phase]:
|
|
259
|
+
"""Phases at which ``run``/``resume`` stop looping.
|
|
260
|
+
|
|
261
|
+
A LOCAL run has no READY_FOR_MERGE holding state: it stops only on a
|
|
262
|
+
terminal phase.
|
|
263
|
+
"""
|
|
264
|
+
return TERMINAL_PHASES if mode == WorkflowMode.LOCAL else STOP_PHASES
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def is_legal(frm: Phase, to: Phase, mode: WorkflowMode = WorkflowMode.REMOTE) -> bool:
|
|
268
|
+
return to in edges_for(mode).get(frm, frozenset())
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def validate_transition(frm: Phase, to: Phase, mode: WorkflowMode = WorkflowMode.REMOTE) -> None:
|
|
272
|
+
"""Raise StateTransitionError if ``frm -> to`` is not a legal edge."""
|
|
273
|
+
if not is_legal(frm, to, mode):
|
|
274
|
+
raise StateTransitionError(
|
|
275
|
+
f"illegal transition in {mode.value} mode: {frm.value} -> {to.value}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def decide_next_phase(
|
|
280
|
+
current: Phase, result: dict, mode: WorkflowMode = WorkflowMode.REMOTE
|
|
281
|
+
) -> Phase:
|
|
282
|
+
"""Pure function: current phase + the controller's decision inputs -> next phase.
|
|
283
|
+
|
|
284
|
+
This is the single place that chooses the next phase of a lifecycle step;
|
|
285
|
+
the engine calls it wherever it advances a run and only verifies and
|
|
286
|
+
applies the outcome (``validate_transition`` is still run on it; BLOCKED
|
|
287
|
+
and FAILED are holding states outside the topology that the engine
|
|
288
|
+
enters on its own, never through this function).
|
|
289
|
+
|
|
290
|
+
``result`` carries the fields the decision is made on: the validated
|
|
291
|
+
CONTROL_RESULT fields the topology depends on (``needs_fix_round`` in
|
|
292
|
+
REVIEW, ``next_issue_url`` in UPDATE_EPIC, already verified by the
|
|
293
|
+
engine where verification applies) extended with the controller's own
|
|
294
|
+
observations and decisions:
|
|
295
|
+
|
|
296
|
+
- ``head_changed_after_review`` (REVIEW, READY_FOR_MERGE, MERGE): the
|
|
297
|
+
reviewed revision -- HEAD, base branch or merge base -- is no longer
|
|
298
|
+
the PR's. The review is stale and the actual revision is reviewed.
|
|
299
|
+
- ``replan`` (REVIEW): the controller's replan policy escalated this
|
|
300
|
+
round to REPLAN_REEXECUTE. Never a reviewer field.
|
|
301
|
+
|
|
302
|
+
Missing/invalid decision fields raise ControlResultValidationError;
|
|
303
|
+
terminal phases raise StateTransitionError.
|
|
304
|
+
"""
|
|
305
|
+
from .errors import ControlResultValidationError
|
|
306
|
+
|
|
307
|
+
def need(key: str): # helper with a meaningful error
|
|
308
|
+
if key not in result:
|
|
309
|
+
raise ControlResultValidationError(
|
|
310
|
+
f"CONTROL_RESULT for phase {current.value} missing decision field {key!r}"
|
|
311
|
+
)
|
|
312
|
+
return result[key]
|
|
313
|
+
|
|
314
|
+
def flag(key: str) -> bool:
|
|
315
|
+
value = result.get(key, False)
|
|
316
|
+
if not isinstance(value, bool):
|
|
317
|
+
raise ControlResultValidationError(f"{key!r} must be a boolean")
|
|
318
|
+
return value
|
|
319
|
+
|
|
320
|
+
if current == Phase.INITIALIZING:
|
|
321
|
+
return Phase.ANALYZE_EXECUTE
|
|
322
|
+
if current == Phase.ANALYZE_EXECUTE:
|
|
323
|
+
return Phase.REVIEW
|
|
324
|
+
if current == Phase.FIX:
|
|
325
|
+
return Phase.REVIEW
|
|
326
|
+
if mode == WorkflowMode.LOCAL:
|
|
327
|
+
if current == Phase.REVIEW:
|
|
328
|
+
needs_fix = need("needs_fix_round")
|
|
329
|
+
if not isinstance(needs_fix, bool):
|
|
330
|
+
raise ControlResultValidationError("'needs_fix_round' must be a boolean")
|
|
331
|
+
# Whether the fix budget still allows a FIX is a controller policy
|
|
332
|
+
# decision (see engine._local_review_stop_reason); the topology only
|
|
333
|
+
# says both edges exist. A LOCAL review is bound to a workspace
|
|
334
|
+
# fingerprint the reviewer may not change, so it has no stale edge.
|
|
335
|
+
return Phase.FIX if needs_fix else Phase.DONE
|
|
336
|
+
raise StateTransitionError(f"phase {current.value} is not part of the LOCAL workflow")
|
|
337
|
+
if current == Phase.REPLAN_REEXECUTE:
|
|
338
|
+
return Phase.REVIEW
|
|
339
|
+
if current == Phase.REVIEW:
|
|
340
|
+
needs_fix = need("needs_fix_round")
|
|
341
|
+
if not isinstance(needs_fix, bool):
|
|
342
|
+
raise ControlResultValidationError("'needs_fix_round' must be a boolean")
|
|
343
|
+
# ``replan`` is the controller's own decision (engine replan policy),
|
|
344
|
+
# never a reviewer field: the agent's CONTROL_RESULT has no say in
|
|
345
|
+
# whether its PR is replaced. It is only meaningful over findings --
|
|
346
|
+
# REVIEW -> REPLAN_REEXECUTE without a fix round to escalate is not a
|
|
347
|
+
# route this topology has.
|
|
348
|
+
replan = flag("replan")
|
|
349
|
+
if flag("head_changed_after_review"):
|
|
350
|
+
# The round is consumed either way, but its verdict describes a
|
|
351
|
+
# revision the PR no longer has: the actual one is reviewed next.
|
|
352
|
+
# A replan escalates the findings of the reviewed revision, so a
|
|
353
|
+
# stale round is not one the policy may have decided on.
|
|
354
|
+
if replan:
|
|
355
|
+
raise ControlResultValidationError(
|
|
356
|
+
"a replan decision cannot be made on a stale review round: "
|
|
357
|
+
"the revision moved, so the actual one is reviewed first"
|
|
358
|
+
)
|
|
359
|
+
return Phase.REVIEW
|
|
360
|
+
if replan:
|
|
361
|
+
if not needs_fix:
|
|
362
|
+
raise ControlResultValidationError(
|
|
363
|
+
"a replan decision requires needs_fix_round == true: "
|
|
364
|
+
"only a review with findings can escalate to REPLAN_REEXECUTE"
|
|
365
|
+
)
|
|
366
|
+
return Phase.REPLAN_REEXECUTE
|
|
367
|
+
return Phase.FIX if needs_fix else Phase.READY_FOR_MERGE
|
|
368
|
+
if current == Phase.READY_FOR_MERGE:
|
|
369
|
+
if flag("head_changed_after_review"):
|
|
370
|
+
return Phase.REVIEW
|
|
371
|
+
return Phase.MERGE
|
|
372
|
+
if current == Phase.MERGE:
|
|
373
|
+
# MERGE is controller-executed (no agent CONTROL_RESULT). ``result``
|
|
374
|
+
# here is the controller's own observation: the revision moved ->
|
|
375
|
+
# REVIEW, otherwise the verified merge always routes to UPDATE_EPIC,
|
|
376
|
+
# whose agent posts the progress comment and selects the next issue
|
|
377
|
+
# (or null -> DONE), and that selection is needed after every merge.
|
|
378
|
+
# Batching several merges per EPIC roadmap update
|
|
379
|
+
# (``workflow.epic_update_every``) is decided inside UPDATE_EPIC from
|
|
380
|
+
# ``merged_since_epic_update``, not by skipping the phase; the
|
|
381
|
+
# MERGE -> ANALYZE_EXECUTE and MERGE -> DONE edges stay reserved.
|
|
382
|
+
if flag("head_changed_after_review"):
|
|
383
|
+
return Phase.REVIEW
|
|
384
|
+
return Phase.UPDATE_EPIC
|
|
385
|
+
if current == Phase.UPDATE_EPIC:
|
|
386
|
+
nxt = result.get("next_issue_url")
|
|
387
|
+
return Phase.ANALYZE_EXECUTE if nxt else Phase.DONE
|
|
388
|
+
raise StateTransitionError(f"phase {current.value} is terminal; no next phase")
|