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
|
@@ -0,0 +1,2234 @@
|
|
|
1
|
+
"""CONTROL_RESULT protocol: extraction + strict per-phase validation.
|
|
2
|
+
|
|
3
|
+
Agent stdout may contain arbitrary logs, but must end with exactly one
|
|
4
|
+
machine-readable block::
|
|
5
|
+
|
|
6
|
+
<<<CONTROL_RESULT>>>
|
|
7
|
+
{ ... JSON object ... }
|
|
8
|
+
<<<END_CONTROL_RESULT>>>
|
|
9
|
+
|
|
10
|
+
Rules: find ALL complete blocks, use only the LAST one; JSON must be valid;
|
|
11
|
+
payload must be an object containing at least ``phase`` and ``status``; the
|
|
12
|
+
payload phase must equal the controller's current phase; per-phase required
|
|
13
|
+
fields are enforced with typed dataclass models.
|
|
14
|
+
|
|
15
|
+
The controller never "guesses what the agent meant": a REVIEW result whose
|
|
16
|
+
``needs_fix_round`` disagrees with its ``findings`` list, or a FIX
|
|
17
|
+
resolution without the evidence its kind requires, is rejected outright. So
|
|
18
|
+
is a REVIEW result larger than the controller is willing to persist and
|
|
19
|
+
render (``MAX_FINDINGS_PER_REVIEW`` and the per-field character bounds,
|
|
20
|
+
finding ids included): it is refused whole, never clipped, so the reviewer
|
|
21
|
+
can re-emit it. The FIX payload is bounded the same way
|
|
22
|
+
(``MAX_RESOLUTIONS_PER_FIX``, ``MAX_FIX_RATIONALE_CHARS``, the shape of
|
|
23
|
+
``commit_sha`` and the length of any URL): every resolution is persisted
|
|
24
|
+
whole. A finding's one-line fields (``title``, ``location``) may carry no
|
|
25
|
+
control character at all, and its ``required_resolution`` and a FIX
|
|
26
|
+
``rationale`` only a newline or a tab (#78): the prompt renderer would
|
|
27
|
+
escape or indent anything else, and a value the controller would have to
|
|
28
|
+
rewrite before it can show it is refused, not repaired. The same holds for
|
|
29
|
+
the block as a whole
|
|
30
|
+
(``MAX_CONTROL_RESULT_CHARS``): the accepted payload is persisted whole and
|
|
31
|
+
is what the next phase acts on, so its size is checked before it is decoded.
|
|
32
|
+
|
|
33
|
+
Agent text the controller publishes is held to one content policy (ADR 0004
|
|
34
|
+
D8.2, D8.3, D8.5): no controller marker opener of either prefix, no
|
|
35
|
+
credential-shaped string (refused by the redactor's pattern class, never
|
|
36
|
+
redacted), no closing keyword followed by an issue reference, and no
|
|
37
|
+
``@``-mention outside code. :func:`published_text_problem` judges one field,
|
|
38
|
+
:func:`published_payload_problem` a whole rendered payload,
|
|
39
|
+
:func:`published_markdown_problem` the Markdown several fields compose and
|
|
40
|
+
:func:`commit_message_problem` a commit message; each names the subject and
|
|
41
|
+
the rule, never the text. An ANALYZE_EXECUTE result carries the PR title
|
|
42
|
+
and body the controller publishes (``pr_title``, ``pr_body``), under that
|
|
43
|
+
policy and bounded (``MAX_PR_TITLE_CHARS``, ``MAX_PR_BODY_CHARS``), plus
|
|
44
|
+
the issue URL and the candidate SHA as cross-checks; it names no PR, branch
|
|
45
|
+
or push target (#161). A REPLAN_REEXECUTE result carries the replacement
|
|
46
|
+
PR's title and body under the same policy and bounds, the candidate SHA and
|
|
47
|
+
the transaction checkpoint's cross-checks, and names no replacement PR,
|
|
48
|
+
branch or push target either (#164). A REMOTE REVIEW result carries the prose sections of
|
|
49
|
+
the review comment the controller renders and posts (``spec``,
|
|
50
|
+
``standards``, ``assessment``, ``observations``, ``verification``,
|
|
51
|
+
``summary``), each under that policy and bounded
|
|
52
|
+
(``MAX_REVIEW_SECTION_CHARS``, ``MAX_REVIEW_SUMMARY_CHARS``), and so is
|
|
53
|
+
every finding field the comment shows; ``round`` and ``reviewed_head_sha``
|
|
54
|
+
are cross-checks, and it names no comment (#162). A REMOTE FIX result
|
|
55
|
+
carries one resolution per open finding; a deferral either names an issue
|
|
56
|
+
the controller handed over or carries the title and body of a new follow-up
|
|
57
|
+
issue the controller creates, under that policy and bounded
|
|
58
|
+
(``MAX_FOLLOW_UP_TITLE_CHARS``, ``MAX_FOLLOW_UP_BODY_CHARS``), and
|
|
59
|
+
``previous_head_sha`` and ``head_sha`` are cross-checks; it names no push
|
|
60
|
+
target and creates nothing (#163). One of its resolutions may instead ask
|
|
61
|
+
the controller to publish an acceptance comment on the run's own issue: a
|
|
62
|
+
closed ``kind`` and a bounded body under the same policy
|
|
63
|
+
(``MAX_ACCEPTANCE_BODY_CHARS``, ``MAX_ACCEPTANCE_ACTIONS_PER_FIX``), never
|
|
64
|
+
a target, URL, method, path or command (#211). An UPDATE_EPIC result is validated
|
|
65
|
+
against the schema of the request that launched the agent
|
|
66
|
+
(:class:`UpdateEpicRequest`, D4.7): a re-request after publication carries
|
|
67
|
+
only what it asks for.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import html
|
|
73
|
+
import json
|
|
74
|
+
import re
|
|
75
|
+
from collections import deque
|
|
76
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
77
|
+
from dataclasses import dataclass, field
|
|
78
|
+
from enum import StrEnum
|
|
79
|
+
from urllib.parse import unquote
|
|
80
|
+
|
|
81
|
+
from .errors import ConfigurationError, ControlResultError, ControlResultValidationError
|
|
82
|
+
from .prompts import CONTROL_CHAR_RE, CONTROL_CHARS
|
|
83
|
+
from .redaction import credential_classes
|
|
84
|
+
from .roadmap import ROADMAP_END_MARKER, ROADMAP_START_MARKER
|
|
85
|
+
from .transitions import Phase, WorkflowMode
|
|
86
|
+
from .validation import parse_comment_url, parse_issue_url, parse_pr_url
|
|
87
|
+
|
|
88
|
+
BEGIN = "<<<CONTROL_RESULT>>>"
|
|
89
|
+
END = "<<<END_CONTROL_RESULT>>>"
|
|
90
|
+
|
|
91
|
+
_BLOCK_RE = re.compile(r"<<<CONTROL_RESULT>>>(.*?)<<<END_CONTROL_RESULT>>>", re.DOTALL)
|
|
92
|
+
# A SHA field is the full 40-character object id, as the prompts require and
|
|
93
|
+
# as GitHub reports it: every SHA the controller accepts is compared by
|
|
94
|
+
# equality with one read from GitHub, so an abbreviated SHA could only pass
|
|
95
|
+
# the schema and then fail that comparison with a misleading "mismatch"
|
|
96
|
+
# instead of the schema error it is (#19). The same shape ``autoforge.claims``
|
|
97
|
+
# reads back from a marker.
|
|
98
|
+
_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
99
|
+
# The one finding-id rule: the parser applies it to CONTROL_RESULT ids and
|
|
100
|
+
# ``autoforge.claims`` to the ids read back from follow-up markers, so a
|
|
101
|
+
# marker can never carry an id the parser would have refused. ``\Z``, not
|
|
102
|
+
# ``$``: ``$`` also matches before a trailing newline, and ``"R1-F1\n"`` is
|
|
103
|
+
# not a finding id (it would look like one and compare unequal to it).
|
|
104
|
+
FINDING_ID_RE = re.compile(r"^R(?P<round>[1-9][0-9]*)-F(?P<n>[1-9][0-9]*)\Z")
|
|
105
|
+
_FINDING_ID_RE = FINDING_ID_RE
|
|
106
|
+
_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
107
|
+
|
|
108
|
+
ALLOWED_STATUSES = ("success", "failure", "blocked")
|
|
109
|
+
FINDING_CLASSIFICATIONS = ("blocked", "non-blocked", "nit")
|
|
110
|
+
FIX_RESOLUTIONS = (
|
|
111
|
+
"fixed",
|
|
112
|
+
"follow_up_created",
|
|
113
|
+
"no_change_with_rationale",
|
|
114
|
+
"acceptance_action",
|
|
115
|
+
"operator_acceptance",
|
|
116
|
+
)
|
|
117
|
+
# LOCAL mode has no GitHub, so a finding cannot be deferred to a follow-up
|
|
118
|
+
# Issue. "unresolved" is the honest local disposition: the fixer could not
|
|
119
|
+
# resolve it and says why. The controller treats it as a remaining finding.
|
|
120
|
+
LOCAL_FIX_RESOLUTIONS = ("fixed", "no_change_with_rationale", "unresolved")
|
|
121
|
+
MIN_RATIONALE_CHARS = 40
|
|
122
|
+
|
|
123
|
+
# Bounds on the REVIEW payload the controller accepts. Review output is
|
|
124
|
+
# untrusted project data, and every accepted finding is persisted in full in
|
|
125
|
+
# ``state.open_findings`` (a full atomic rewrite of ``state.json``) and
|
|
126
|
+
# rendered verbatim into the next FIX prompt. Without a controller-owned
|
|
127
|
+
# limit a single pathological round decides the size of both. An oversized
|
|
128
|
+
# result is *rejected*, never clipped: the findings are the work the FIX
|
|
129
|
+
# round has to act on, so clipping them would silently drop work, whereas a
|
|
130
|
+
# rejection is correctable (the reviewer re-emits a bounded result through the
|
|
131
|
+
# ordinary correction retry). Each bound is at or below the corresponding
|
|
132
|
+
# persisted-evidence bound in ``loop_guard`` so an accepted round is always
|
|
133
|
+
# retained complete (``tests/test_result_parser.py`` pins that relation).
|
|
134
|
+
MAX_FINDINGS_PER_REVIEW = 50
|
|
135
|
+
MAX_FINDING_RESOLUTION_CHARS = 2000
|
|
136
|
+
MAX_FINDING_TITLE_CHARS = 200
|
|
137
|
+
MAX_FINDING_LOCATION_CHARS = 300
|
|
138
|
+
# A finding id is ``R<round>-F<n>``; its shape does not bound its length (the
|
|
139
|
+
# digit runs are open-ended), and the id is persisted and rendered like every
|
|
140
|
+
# other finding field, so it is bounded explicitly. The bound is checked
|
|
141
|
+
# before the shape, so an oversized id is never echoed into a message. A FIX
|
|
142
|
+
# ``finding_id`` must equal an accepted finding's id, so the same bound
|
|
143
|
+
# applies to it at parse time rather than after the coverage check.
|
|
144
|
+
MAX_FINDING_ID_CHARS = 32
|
|
145
|
+
# Bounds on the FIX payload (#77). Every accepted resolution is persisted
|
|
146
|
+
# whole in ``state.last_fix_resolutions`` (after redaction, which can lengthen
|
|
147
|
+
# it), and in LOCAL mode an ``unresolved`` rationale is echoed into the
|
|
148
|
+
# persisted ``block_reason`` that ``status`` shows. A FIX can never
|
|
149
|
+
# legitimately report more resolutions than the controller accepted findings,
|
|
150
|
+
# so the count bound *is* the REVIEW's, derived rather than restated, and it
|
|
151
|
+
# is checked before any element is parsed. The rationale bound times
|
|
152
|
+
# ``redaction.MAX_GROWTH_FACTOR`` is at or below
|
|
153
|
+
# ``loop_guard.MAX_REQUIRED_RESOLUTION_CHARS`` (``tests/test_result_parser.py``
|
|
154
|
+
# pins that relation), so a persisted rationale is never larger than a
|
|
155
|
+
# persisted resolution. Rejected, never clipped, like a REVIEW field.
|
|
156
|
+
MAX_RESOLUTIONS_PER_FIX = MAX_FINDINGS_PER_REVIEW
|
|
157
|
+
MAX_FIX_RATIONALE_CHARS = 2000
|
|
158
|
+
# Bound on any URL field before the ``validation`` parser sees it: that
|
|
159
|
+
# parser quotes the value in its error, and the error is echoed into the
|
|
160
|
+
# correction prompt and the run log. A real GitHub issue, PR or comment URL
|
|
161
|
+
# is far shorter.
|
|
162
|
+
MAX_URL_CHARS = 512
|
|
163
|
+
# Bound on the whole CONTROL_RESULT block (the raw JSON text between the
|
|
164
|
+
# markers, in characters). The accepted payload is written whole to
|
|
165
|
+
# ``control-result.json`` and as one ``events.jsonl`` line, and its fields
|
|
166
|
+
# drive the next phase, so it is bounded where it is accepted: checked by
|
|
167
|
+
# size alone before ``json.loads``, rejected rather than clipped. The bound
|
|
168
|
+
# sits above the largest REVIEW the field bounds admit even in the worst
|
|
169
|
+
# JSON encoding (every character escaped as ``\uXXXX``, six per character:
|
|
170
|
+
# ``tests/test_result_parser.py`` pins that relation), so the field bounds,
|
|
171
|
+
# not this one, are what a reviewer is held to. Any stdout the parser sees is
|
|
172
|
+
# itself bounded by the executor's capture bound.
|
|
173
|
+
MAX_CONTROL_RESULT_CHARS = 1024 * 1024
|
|
174
|
+
# Bound on the EPIC's managed roadmap section an UPDATE_EPIC result may
|
|
175
|
+
# carry. The controller writes it into the EPIC body itself (between the
|
|
176
|
+
# roadmap markers, nothing else), so the section is the one piece of agent
|
|
177
|
+
# text that reaches a GitHub issue body verbatim; it is bounded where it is
|
|
178
|
+
# accepted and rejected, never clipped, so a truncated roadmap is never
|
|
179
|
+
# published. GitHub bounds an issue body at 65536 characters; the section
|
|
180
|
+
# leaves the operator's own text room.
|
|
181
|
+
MAX_ROADMAP_SECTION_CHARS = 32768
|
|
182
|
+
# Bound on an UPDATE_EPIC result's ``progress`` text. The controller posts it
|
|
183
|
+
# as the EPIC progress comment and appends its own marker, and the whole
|
|
184
|
+
# comment must stay far under GitHub's 65,536-character comment limit.
|
|
185
|
+
# Rejected, never clipped, like every other published field.
|
|
186
|
+
MAX_PROGRESS_CHARS = 16384
|
|
187
|
+
# Bounds on the PR text an ANALYZE_EXECUTE result carries (#161). The
|
|
188
|
+
# controller creates the PR with this title and with the body followed by
|
|
189
|
+
# its own ``Closes #n`` and implementation marker, so the body leaves room
|
|
190
|
+
# for those under GitHub's 65,536-character body limit and the effect
|
|
191
|
+
# payload's bound; the title is the effect's one-line title bound. Rejected,
|
|
192
|
+
# never clipped, like every other published field.
|
|
193
|
+
MAX_PR_TITLE_CHARS = 256
|
|
194
|
+
MAX_PR_BODY_CHARS = 60000
|
|
195
|
+
# Bounds on the prose sections of a REVIEW result (#162). The controller
|
|
196
|
+
# renders the round's review comment from the result: its heading, binding
|
|
197
|
+
# line, findings, needs-fix line and marker are its own, and each prose
|
|
198
|
+
# section is the reviewer's text under its fixed heading. Rejected, never
|
|
199
|
+
# clipped, like every other published field; the rendered comment as a
|
|
200
|
+
# whole is checked against GitHub's comment limit too (ADR 0004 D8.6),
|
|
201
|
+
# because the findings alone may already exceed it.
|
|
202
|
+
MAX_REVIEW_SECTION_CHARS = 6000
|
|
203
|
+
MAX_REVIEW_SUMMARY_CHARS = 2000
|
|
204
|
+
# The prose sections, in the order the comment renders them, each mapped to
|
|
205
|
+
# its bound.
|
|
206
|
+
REVIEW_PROSE_SECTIONS: tuple[tuple[str, int], ...] = (
|
|
207
|
+
("spec", MAX_REVIEW_SECTION_CHARS),
|
|
208
|
+
("standards", MAX_REVIEW_SECTION_CHARS),
|
|
209
|
+
("assessment", MAX_REVIEW_SECTION_CHARS),
|
|
210
|
+
("observations", MAX_REVIEW_SECTION_CHARS),
|
|
211
|
+
("verification", MAX_REVIEW_SECTION_CHARS),
|
|
212
|
+
("summary", MAX_REVIEW_SUMMARY_CHARS),
|
|
213
|
+
)
|
|
214
|
+
# Bounds on a new follow-up issue a FIX result asks for (#163). The
|
|
215
|
+
# controller creates the issue with this title and with the body followed by
|
|
216
|
+
# its own reference to the PR and finding and the finding's follow-up marker,
|
|
217
|
+
# so the body leaves room for those under GitHub's 65,536-character body
|
|
218
|
+
# limit and the effect payload's bound; the title is the effect's one-line
|
|
219
|
+
# title bound. Rejected, never clipped, like every other published field.
|
|
220
|
+
MAX_FOLLOW_UP_TITLE_CHARS = 256
|
|
221
|
+
MAX_FOLLOW_UP_BODY_CHARS = 60000
|
|
222
|
+
# An ``acceptance_action`` resolution (#211): the one kind of external action
|
|
223
|
+
# a fixer may request, a comment on the run's own issue whose text is the
|
|
224
|
+
# evidence a finding asks for. The kind is a closed set and the request
|
|
225
|
+
# names no target: the controller derives the issue from its state (ADR 0004
|
|
226
|
+
# D8.1), so any key that looks like one is refused rather than ignored. The
|
|
227
|
+
# body is the agent's part of the comment the controller renders (its
|
|
228
|
+
# header, the body, its marker), bounded like a follow-up issue body.
|
|
229
|
+
# Rejected, never clipped, like every other published field.
|
|
230
|
+
ACCEPTANCE_ACTION_KINDS = ("issue_comment",)
|
|
231
|
+
ACCEPTANCE_ACTION_KEYS = ("kind", "body")
|
|
232
|
+
ACCEPTANCE_TARGET_KEYS = ("url", "issue", "repository", "target", "method", "path", "command")
|
|
233
|
+
MAX_ACCEPTANCE_BODY_CHARS = 60000
|
|
234
|
+
MAX_ACCEPTANCE_ACTIONS_PER_FIX = 1
|
|
235
|
+
# An ``operator_acceptance`` resolution (#243): 1 to
|
|
236
|
+
# ``MAX_OPERATOR_ACTIONS_PER_FIX`` comments on *other* issues of the run's
|
|
237
|
+
# repository that the operator posts and the controller only verifies (K10).
|
|
238
|
+
# Each action is a closed schema: a kind, a target issue *number* the
|
|
239
|
+
# controller checks against the allowed set it read at FIX entry, and the
|
|
240
|
+
# text. Nothing names a URL, repository or command: those keys are refused
|
|
241
|
+
# by name. The operator often pipes the text unedited under their own
|
|
242
|
+
# identity, so beyond the published-text rules it carries no image, no raw
|
|
243
|
+
# HTML and no link other than one into the run's repository
|
|
244
|
+
# (:func:`operator_comment_link_problem`). Rejected, never clipped.
|
|
245
|
+
OPERATOR_ACTION_KINDS = ("related_issue_comment",)
|
|
246
|
+
OPERATOR_ACTION_KEYS = ("kind", "issue", "body")
|
|
247
|
+
OPERATOR_ACCEPTANCE_KEYS = ("actions",)
|
|
248
|
+
OPERATOR_TARGET_KEYS = (
|
|
249
|
+
"url",
|
|
250
|
+
"repository",
|
|
251
|
+
"owner",
|
|
252
|
+
"repo",
|
|
253
|
+
"target",
|
|
254
|
+
"method",
|
|
255
|
+
"path",
|
|
256
|
+
"command",
|
|
257
|
+
)
|
|
258
|
+
MAX_OPERATOR_COMMENT_CHARS = 16000
|
|
259
|
+
MAX_OPERATOR_ACTIONS_PER_FIX = 4
|
|
260
|
+
# The largest issue number ``engine._ISSUE_REFERENCE_RE`` reads (nine digits).
|
|
261
|
+
MAX_OPERATOR_ISSUE_NUMBER = 999_999_999
|
|
262
|
+
# Bounds on the ``tests`` list: names of what the agent ran, recorded with
|
|
263
|
+
# the result and never published.
|
|
264
|
+
MAX_TESTS_REPORTED = 50
|
|
265
|
+
MAX_TEST_CHARS = 500
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class UpdateEpicRequest(StrEnum):
|
|
269
|
+
"""Which UPDATE_EPIC result the controller asked for (ADR 0004 D4.7).
|
|
270
|
+
|
|
271
|
+
``FULL`` is the phase's launch: ``progress`` (required),
|
|
272
|
+
``roadmap_section`` (optional) and ``next_issue_url`` (a required key,
|
|
273
|
+
``null`` when the EPIC is complete). The others are re-requests after the
|
|
274
|
+
phase's publication is complete: each asks only for the input a later
|
|
275
|
+
controller step rejected, and a field outside its schema is refused.
|
|
276
|
+
|
|
277
|
+
- ``SELECTION``: ``next_issue_url`` only.
|
|
278
|
+
- ``SELECTION_WITH_ROADMAP``: ``next_issue_url`` and an optional
|
|
279
|
+
``roadmap_section``.
|
|
280
|
+
- ``ROADMAP``: ``roadmap_section`` only, required and non-blank.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
FULL = "full"
|
|
284
|
+
SELECTION = "selection"
|
|
285
|
+
SELECTION_WITH_ROADMAP = "selection_with_roadmap"
|
|
286
|
+
ROADMAP = "roadmap"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# The opening every controller marker shares: ``<!--``, any whitespace (none
|
|
290
|
+
# included), ``ai-``. ``autoforge.claims`` builds each marker kind's scanner
|
|
291
|
+
# pattern on this expression, and the roadmap-section refusal below matches
|
|
292
|
+
# it, so what the section may not carry is exactly what the scanner would
|
|
293
|
+
# later read. The whitespace run is possessive as in the scanner (the
|
|
294
|
+
# expression runs over agent-supplied text; see ``MarkerKind.pattern``).
|
|
295
|
+
CONTROLLER_MARKER_OPEN_RE = re.compile(r"<!--\s*+ai-")
|
|
296
|
+
# A roadmap section may not carry a controller marker: the roadmap markers
|
|
297
|
+
# would split the body into more than one managed section, and any other
|
|
298
|
+
# ``<!-- ai-`` marker would plant a durable claim in an open issue that the
|
|
299
|
+
# controller later scans and trusts (``autoforge.claims``). The refusal uses
|
|
300
|
+
# the scanner's own opening, so ``<!--ai-`` and ``<!--\nai-`` are refused too.
|
|
301
|
+
_CONTROLLER_MARKER_PREFIX = "<!-- ai-"
|
|
302
|
+
# The opening of every marker the controller writes or later trusts: its own
|
|
303
|
+
# ``<!-- ai-`` claims (:data:`CONTROLLER_MARKER_OPEN_RE`) and the replan
|
|
304
|
+
# transaction's ``<!-- autoforge-`` markers (``autoforge.replan_txn``).
|
|
305
|
+
# Agent text may carry neither (ADR 0004 D8.2). Case-insensitive, unlike the
|
|
306
|
+
# scanner's own opening: a refusal wider than the scanner fails closed.
|
|
307
|
+
# Possessive like it, for the same reason.
|
|
308
|
+
AGENT_MARKER_OPEN_RE = re.compile(r"<!--\s*+(?:ai|autoforge)-", re.IGNORECASE)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def extract_last_block(stdout: str) -> str:
|
|
312
|
+
"""Return the raw JSON text of the last complete CONTROL_RESULT block."""
|
|
313
|
+
matches = _BLOCK_RE.findall(stdout or "")
|
|
314
|
+
if not matches:
|
|
315
|
+
if BEGIN in (stdout or ""):
|
|
316
|
+
raise ControlResultError(
|
|
317
|
+
"found '<<<CONTROL_RESULT>>>' but no closing "
|
|
318
|
+
"'<<<END_CONTROL_RESULT>>>' — incomplete block"
|
|
319
|
+
)
|
|
320
|
+
raise ControlResultError(
|
|
321
|
+
"no CONTROL_RESULT block found in agent stdout "
|
|
322
|
+
"(expected '<<<CONTROL_RESULT>>>{...}<<<END_CONTROL_RESULT>>>')"
|
|
323
|
+
)
|
|
324
|
+
return matches[-1].strip()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def parse_control_result(
|
|
328
|
+
stdout: str,
|
|
329
|
+
expected_phase: Phase,
|
|
330
|
+
mode: WorkflowMode = WorkflowMode.REMOTE,
|
|
331
|
+
*,
|
|
332
|
+
update_epic_request: UpdateEpicRequest = UpdateEpicRequest.FULL,
|
|
333
|
+
) -> dict:
|
|
334
|
+
"""Extract + JSON-parse + validate the CONTROL_RESULT payload.
|
|
335
|
+
|
|
336
|
+
Returns the payload dict. Raises ControlResultError on extraction/JSON
|
|
337
|
+
problems and ControlResultValidationError on schema/phase problems.
|
|
338
|
+
``mode`` selects the per-phase schema: a LOCAL phase reports semantic
|
|
339
|
+
facts (a summary, a workspace fingerprint, finding resolutions) and never
|
|
340
|
+
a PR URL, a comment URL or a follow-up Issue. ``update_epic_request``
|
|
341
|
+
selects the UPDATE_EPIC schema (:class:`UpdateEpicRequest`).
|
|
342
|
+
"""
|
|
343
|
+
raw = extract_last_block(stdout)
|
|
344
|
+
if len(raw) > MAX_CONTROL_RESULT_CHARS:
|
|
345
|
+
raise ControlResultValidationError(
|
|
346
|
+
f"CONTROL_RESULT block is {len(raw)} characters and the controller accepts at "
|
|
347
|
+
f"most {MAX_CONTROL_RESULT_CHARS}. Keep the block to the fields the phase "
|
|
348
|
+
"requires and re-emit it."
|
|
349
|
+
)
|
|
350
|
+
try:
|
|
351
|
+
payload = json.loads(raw)
|
|
352
|
+
except json.JSONDecodeError as exc:
|
|
353
|
+
raise ControlResultError(f"CONTROL_RESULT block is not valid JSON: {exc}") from exc
|
|
354
|
+
if not isinstance(payload, dict):
|
|
355
|
+
raise ControlResultValidationError(
|
|
356
|
+
f"CONTROL_RESULT must be a JSON object, got {type(payload).__name__}"
|
|
357
|
+
)
|
|
358
|
+
for key in ("phase", "status"):
|
|
359
|
+
if key not in payload:
|
|
360
|
+
raise ControlResultValidationError(f"CONTROL_RESULT missing required field {key!r}")
|
|
361
|
+
if payload["phase"] != expected_phase.value:
|
|
362
|
+
raise ControlResultValidationError(
|
|
363
|
+
f"phase mismatch: controller is in {expected_phase.value}, "
|
|
364
|
+
f"but CONTROL_RESULT says {payload['phase']!r}"
|
|
365
|
+
)
|
|
366
|
+
if payload["status"] not in ALLOWED_STATUSES:
|
|
367
|
+
raise ControlResultValidationError(
|
|
368
|
+
f"unknown status {payload['status']!r} (expected one of {ALLOWED_STATUSES})"
|
|
369
|
+
)
|
|
370
|
+
if payload["status"] == "success":
|
|
371
|
+
validate_for_phase(expected_phase, payload, mode, update_epic_request=update_epic_request)
|
|
372
|
+
else:
|
|
373
|
+
msg = payload.get("message")
|
|
374
|
+
if not isinstance(msg, str) or not msg.strip():
|
|
375
|
+
raise ControlResultValidationError(
|
|
376
|
+
f"status {payload['status']!r} requires a non-empty 'message' explaining why"
|
|
377
|
+
)
|
|
378
|
+
return payload
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# -- helpers --------------------------------------------------------------
|
|
382
|
+
def _req(payload: dict, key: str, phase: str):
|
|
383
|
+
if key not in payload or payload[key] in (None, ""):
|
|
384
|
+
raise ControlResultValidationError(
|
|
385
|
+
f"CONTROL_RESULT for {phase} missing required field {key!r}"
|
|
386
|
+
)
|
|
387
|
+
return payload[key]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _req_str(payload: dict, key: str, phase: str) -> str:
|
|
391
|
+
v = _req(payload, key, phase)
|
|
392
|
+
if not isinstance(v, str):
|
|
393
|
+
raise ControlResultValidationError(f"{phase}: field {key!r} must be a string")
|
|
394
|
+
# ``_req`` rejects "" before stripping; a whitespace-only value is just as
|
|
395
|
+
# absent (a blank required_resolution demands nothing) and is rejected
|
|
396
|
+
# with the same message rather than becoming an empty required field.
|
|
397
|
+
stripped = v.strip()
|
|
398
|
+
if not stripped:
|
|
399
|
+
raise ControlResultValidationError(
|
|
400
|
+
f"CONTROL_RESULT for {phase} missing required field {key!r}"
|
|
401
|
+
)
|
|
402
|
+
return stripped
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _opt_str(payload: dict, key: str, phase: str) -> str:
|
|
406
|
+
"""An optional free-text field: absent, JSON ``null``, or a string.
|
|
407
|
+
|
|
408
|
+
Never ``str(...)``. Coercion made a number, a list or an object into a
|
|
409
|
+
*successful* result whose text the controller then persisted and rendered
|
|
410
|
+
back into a prompt, so a schema violation arrived as content instead of as
|
|
411
|
+
a rejection. ``null`` is JSON's other spelling of "absent" and is treated
|
|
412
|
+
as such (the same rule :func:`_opt_str_list` already applies); anything
|
|
413
|
+
else is the protocol violation it looks like.
|
|
414
|
+
"""
|
|
415
|
+
raw = payload.get(key)
|
|
416
|
+
if raw is None:
|
|
417
|
+
return ""
|
|
418
|
+
if not isinstance(raw, str):
|
|
419
|
+
raise ControlResultValidationError(
|
|
420
|
+
f"{phase}: optional field {key!r} must be a string when present, got "
|
|
421
|
+
f"{type(raw).__name__}"
|
|
422
|
+
)
|
|
423
|
+
return raw.strip()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _checked_sha(v: str, key: str, phase: str) -> str:
|
|
427
|
+
"""``v`` as a lower-cased full git SHA, or a rejection that quotes it
|
|
428
|
+
only when it is no longer than a SHA: an oversized value is reported by
|
|
429
|
+
its length, never echoed into the correction prompt or the run log."""
|
|
430
|
+
if not _SHA_RE.match(v):
|
|
431
|
+
shown = repr(v) if len(v) <= 40 else f"{len(v)} characters"
|
|
432
|
+
raise ControlResultValidationError(
|
|
433
|
+
f"{phase}: field {key!r} must be a full git SHA (exactly 40 hex chars, as "
|
|
434
|
+
f"`git rev-parse HEAD` and `gh pr view --json headRefOid` report it), got {shown}"
|
|
435
|
+
)
|
|
436
|
+
return v.lower()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _req_sha(payload: dict, key: str, phase: str) -> str:
|
|
440
|
+
return _checked_sha(_req_str(payload, key, phase), key, phase)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _opt_sha(payload: dict, key: str, phase: str) -> str:
|
|
444
|
+
"""An optional SHA: absent or ``null`` is ``""``; a present value must be a SHA."""
|
|
445
|
+
v = _opt_str(payload, key, phase)
|
|
446
|
+
return _checked_sha(v, key, phase) if v else ""
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _checked_url(v: str, key: str, phase: str, kind: str) -> str:
|
|
450
|
+
"""``v`` validated as a GitHub ``kind`` URL, bounded first.
|
|
451
|
+
|
|
452
|
+
The ``validation`` parser quotes the value in its error, and that error
|
|
453
|
+
reaches the correction prompt and the run log, so the length is checked
|
|
454
|
+
before the parser sees the text.
|
|
455
|
+
"""
|
|
456
|
+
if len(v) > MAX_URL_CHARS:
|
|
457
|
+
raise ControlResultValidationError(
|
|
458
|
+
f"{phase}: field {key!r} is {len(v)} characters; a GitHub {kind} URL is at most "
|
|
459
|
+
f"{MAX_URL_CHARS}. Re-emit the CONTROL_RESULT with the real URL."
|
|
460
|
+
)
|
|
461
|
+
parser = {"issue": parse_issue_url, "pr": parse_pr_url, "comment": parse_comment_url}[kind]
|
|
462
|
+
try:
|
|
463
|
+
parser(v)
|
|
464
|
+
except ConfigurationError as exc:
|
|
465
|
+
raise ControlResultValidationError(
|
|
466
|
+
f"{phase}: field {key!r} must be a GitHub {kind} URL: {exc}"
|
|
467
|
+
) from exc
|
|
468
|
+
return v
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _req_url(payload: dict, key: str, phase: str, kind: str) -> str:
|
|
472
|
+
return _checked_url(_req_str(payload, key, phase), key, phase, kind)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _opt_url(payload: dict, key: str, phase: str, kind: str) -> str:
|
|
476
|
+
"""An optional URL: absent or ``null`` is ``""``; a present value must parse."""
|
|
477
|
+
v = _opt_str(payload, key, phase)
|
|
478
|
+
return _checked_url(v, key, phase, kind) if v else ""
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _req_bool(payload: dict, key: str, phase: str) -> bool:
|
|
482
|
+
if key not in payload:
|
|
483
|
+
raise ControlResultValidationError(
|
|
484
|
+
f"CONTROL_RESULT for {phase} missing required field {key!r}"
|
|
485
|
+
)
|
|
486
|
+
v = payload[key]
|
|
487
|
+
if not isinstance(v, bool):
|
|
488
|
+
raise ControlResultValidationError(f"{phase}: field {key!r} must be a boolean")
|
|
489
|
+
return v
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# -- published-content policy (ADR 0004 D8.2, D8.3, D8.5) ------------------
|
|
493
|
+
# Every rule refuses; none rewrites. Where a rule approximates how GitHub
|
|
494
|
+
# reads Markdown, the approximation errs toward refusing: a refusal costs a
|
|
495
|
+
# correction, a miss publishes. A message names the subject and the rule,
|
|
496
|
+
# and at most an index, never the text.
|
|
497
|
+
|
|
498
|
+
# A closing keyword followed by an issue reference: ``#n``, ``owner/repo#n``,
|
|
499
|
+
# an issue or pull URL, or ``GH-n``. Any case, an optional ``:`` after the
|
|
500
|
+
# keyword. Matched anywhere, code spans included: GitHub's own reading of a
|
|
501
|
+
# commit message has no code spans, and a wider refusal fails closed. Every
|
|
502
|
+
# quantifier is possessive and each candidate starts at a keyword, so a
|
|
503
|
+
# candidate scans only the run that follows its own keyword.
|
|
504
|
+
_REF_NAME = r"[A-Za-z0-9_.-]++"
|
|
505
|
+
_CLOSING_REFERENCE_RE = re.compile(
|
|
506
|
+
r"(?<![A-Za-z0-9_])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)(?::\s*+|\s++)(?:"
|
|
507
|
+
rf"https?://(?:www\.)?github\.com/(?P<url_repo>{_REF_NAME}/{_REF_NAME})"
|
|
508
|
+
r"/(?P<url_kind>issues|pull)/(?P<url_number>[0-9]++)"
|
|
509
|
+
rf"|(?P<repo>{_REF_NAME}/{_REF_NAME})#(?P<repo_number>[0-9]++)"
|
|
510
|
+
r"|#(?P<number>[0-9]++)"
|
|
511
|
+
r"|gh-(?P<gh_number>[0-9]++))",
|
|
512
|
+
re.IGNORECASE,
|
|
513
|
+
)
|
|
514
|
+
# An ``@`` that starts a GitHub mention: not after an ASCII word character
|
|
515
|
+
# (so ``a@b.com`` is an address, not a mention; a non-ASCII letter before
|
|
516
|
+
# the ``@`` counts as punctuation, which fails closed), followed by a name
|
|
517
|
+
# character. ``@org/team`` starts the same way.
|
|
518
|
+
_MENTION_RE = re.compile(r"(?<![A-Za-z0-9_])@[A-Za-z0-9]")
|
|
519
|
+
# A URL in UPDATE_EPIC ``progress`` (#160: the agent's progress text carries
|
|
520
|
+
# no URL; the controller's marker names the issue and the PR). A scheme
|
|
521
|
+
# separator ``://``, or a ``www.`` host not inside a word: the two forms
|
|
522
|
+
# GitHub autolinks, the first widened to any scheme so the rule errs toward
|
|
523
|
+
# refusing. Matched anywhere, code included, and in ``progress`` only:
|
|
524
|
+
# ``roadmap_section`` may link the EPIC's PRs.
|
|
525
|
+
_PROGRESS_URL_RE = re.compile(r"://|(?<![A-Za-z0-9])www\.", re.IGNORECASE)
|
|
526
|
+
# Raw HTML (a tag, a comment, an autolink): GitHub reads backticks and fences
|
|
527
|
+
# inside an HTML block or tag as text, so wherever raw HTML sits outside code
|
|
528
|
+
# the code exemption for mentions is not trusted at all.
|
|
529
|
+
_RAW_HTML_RE = re.compile(r"<[A-Za-z/!?]")
|
|
530
|
+
# CommonMark's line endings, and nothing else: ``str.splitlines`` would also
|
|
531
|
+
# break at characters GitHub reads as text, and so find fences GitHub does
|
|
532
|
+
# not.
|
|
533
|
+
_LINE_BREAK_RE = re.compile(r"\r\n|\r|\n")
|
|
534
|
+
# A fence opens at column 0 only. CommonMark allows three spaces of indent,
|
|
535
|
+
# but an indented ``` may sit inside a list item or block quote, where it is
|
|
536
|
+
# not the top-level fence it looks like; at column 0 it is one (or it is in
|
|
537
|
+
# an HTML block, which the raw-HTML rule covers). A backtick fence's info
|
|
538
|
+
# string has no backtick.
|
|
539
|
+
_FENCE_OPEN_RE = re.compile(r"(`{3,}+|~{3,}+)(.*)")
|
|
540
|
+
_FENCE_CLOSE_RE = re.compile(r" {0,3}(`{3,}+|~{3,}+)[ \t]*+\Z")
|
|
541
|
+
_BACKTICK_RUN_RE = re.compile(r"`++")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _lines(text: str) -> Iterator[tuple[int, str]]:
|
|
545
|
+
"""``(offset, line)`` for each line of ``text``, without its line ending."""
|
|
546
|
+
start = 0
|
|
547
|
+
for m in _LINE_BREAK_RE.finditer(text):
|
|
548
|
+
yield start, text[start : m.start()]
|
|
549
|
+
start = m.end()
|
|
550
|
+
yield start, text[start:]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _line_code_spans(line: str) -> tuple[list[tuple[int, int]], bool, bool]:
|
|
554
|
+
"""The inline code spans of one line, by CommonMark's backtick matching.
|
|
555
|
+
|
|
556
|
+
A run of ``n`` backticks opens a span closed by the next run of exactly
|
|
557
|
+
``n``; a run with no such closer is literal. Returns the spans' content
|
|
558
|
+
ranges, whether every run was paired (``balanced``: nothing can carry
|
|
559
|
+
into the next line) and whether the spans are ``trusted``. They are not
|
|
560
|
+
when an opener is escaped (``\\```), which CommonMark reads differently,
|
|
561
|
+
or a span holds a ``|``, which splits a GFM table cell before code spans
|
|
562
|
+
are parsed. Linear: the next run of each length is found through a
|
|
563
|
+
queue per length, each index dequeued once.
|
|
564
|
+
"""
|
|
565
|
+
runs = [(m.start(), m.end()) for m in _BACKTICK_RUN_RE.finditer(line)]
|
|
566
|
+
by_length: dict[int, deque[int]] = {}
|
|
567
|
+
for index, (start, end) in enumerate(runs):
|
|
568
|
+
by_length.setdefault(end - start, deque()).append(index)
|
|
569
|
+
spans: list[tuple[int, int]] = []
|
|
570
|
+
balanced = trusted = True
|
|
571
|
+
k = 0
|
|
572
|
+
while k < len(runs):
|
|
573
|
+
start, end = runs[k]
|
|
574
|
+
if start and line[start - 1] == "\\":
|
|
575
|
+
balanced = trusted = False
|
|
576
|
+
later = by_length[end - start]
|
|
577
|
+
while later and later[0] <= k:
|
|
578
|
+
later.popleft()
|
|
579
|
+
if not later:
|
|
580
|
+
balanced = False
|
|
581
|
+
k += 1
|
|
582
|
+
continue
|
|
583
|
+
closer = later.popleft()
|
|
584
|
+
content_end = runs[closer][0]
|
|
585
|
+
if line.find("|", end, content_end) != -1:
|
|
586
|
+
trusted = False
|
|
587
|
+
spans.append((end, content_end))
|
|
588
|
+
k = closer + 1
|
|
589
|
+
return spans, balanced, trusted
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _code_ranges(text: str) -> list[tuple[int, int]]:
|
|
593
|
+
"""Ascending, disjoint ranges of ``text`` that are certainly code.
|
|
594
|
+
|
|
595
|
+
Fenced blocks (``` or ~~~, at least three, closed by the same character
|
|
596
|
+
at least as long; an unclosed fence runs to the end) and inline code
|
|
597
|
+
spans. Spans are paired per line, and a line's spans count only when
|
|
598
|
+
they are trusted and no earlier line of the same paragraph run (lines
|
|
599
|
+
since the last blank line or fence) left a backtick unpaired: such a
|
|
600
|
+
backtick may pair across the line break and shift every span after it.
|
|
601
|
+
A multi-line span is therefore never exempt; it fails closed.
|
|
602
|
+
"""
|
|
603
|
+
ranges: list[tuple[int, int]] = []
|
|
604
|
+
fence: tuple[str, int] | None = None
|
|
605
|
+
carried = False
|
|
606
|
+
for start, line in _lines(text):
|
|
607
|
+
if fence is not None:
|
|
608
|
+
m = _FENCE_CLOSE_RE.match(line)
|
|
609
|
+
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= fence[1]:
|
|
610
|
+
fence = None
|
|
611
|
+
elif line:
|
|
612
|
+
ranges.append((start, start + len(line)))
|
|
613
|
+
continue
|
|
614
|
+
m = _FENCE_OPEN_RE.match(line)
|
|
615
|
+
if m and not (m.group(1)[0] == "`" and "`" in m.group(2)):
|
|
616
|
+
fence = (m.group(1)[0], len(m.group(1)))
|
|
617
|
+
carried = False
|
|
618
|
+
continue
|
|
619
|
+
if not line.strip(" \t"):
|
|
620
|
+
carried = False
|
|
621
|
+
continue
|
|
622
|
+
spans, balanced, trusted = _line_code_spans(line)
|
|
623
|
+
if trusted and not carried:
|
|
624
|
+
ranges.extend((start + s, start + e) for s, e in spans)
|
|
625
|
+
carried = carried or not balanced
|
|
626
|
+
return ranges
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _outside(ranges: list[tuple[int, int]], positions: Iterable[int]) -> Iterator[int]:
|
|
630
|
+
"""The ascending ``positions`` that no range of ascending, disjoint ``ranges`` holds."""
|
|
631
|
+
i = 0
|
|
632
|
+
for position in positions:
|
|
633
|
+
while i < len(ranges) and ranges[i][1] <= position:
|
|
634
|
+
i += 1
|
|
635
|
+
if i < len(ranges) and ranges[i][0] <= position:
|
|
636
|
+
continue
|
|
637
|
+
yield position
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _exposed_mention(text: str) -> tuple[int, int | None] | None:
|
|
641
|
+
"""The first ``@``-mention of ``text`` GitHub may read as one, if any.
|
|
642
|
+
|
|
643
|
+
``(index, None)`` for a mention outside every code range; ``(index,
|
|
644
|
+
html)`` for the first mention, even one in code, when raw HTML stands
|
|
645
|
+
outside code at ``html``, where the code exemption is not trusted.
|
|
646
|
+
"""
|
|
647
|
+
mentions = [m.start() for m in _MENTION_RE.finditer(text)]
|
|
648
|
+
if not mentions:
|
|
649
|
+
return None
|
|
650
|
+
ranges = _code_ranges(text)
|
|
651
|
+
outside = next(_outside(ranges, mentions), None)
|
|
652
|
+
raw_html = next(_outside(ranges, (m.start() for m in _RAW_HTML_RE.finditer(text))), None)
|
|
653
|
+
if raw_html is not None and outside != mentions[0]:
|
|
654
|
+
return mentions[0], raw_html
|
|
655
|
+
if outside is None:
|
|
656
|
+
return None
|
|
657
|
+
return outside, None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _mention_problem(subject: str, text: str) -> str | None:
|
|
661
|
+
"""The refusal of the first ``@``-mention outside code in ``text``, if any."""
|
|
662
|
+
exposed = _exposed_mention(text)
|
|
663
|
+
if exposed is None:
|
|
664
|
+
return None
|
|
665
|
+
index, raw_html = exposed
|
|
666
|
+
if raw_html is not None:
|
|
667
|
+
return (
|
|
668
|
+
f"{subject!r} contains an @-mention at index {index} and raw HTML (a tag, "
|
|
669
|
+
f"comment or autolink) outside code at index {raw_html}; GitHub may read code "
|
|
670
|
+
"near raw HTML as text, so the code exemption does not apply. Remove the HTML or "
|
|
671
|
+
"the mention and re-emit the CONTROL_RESULT."
|
|
672
|
+
)
|
|
673
|
+
return (
|
|
674
|
+
f"{subject!r} contains an @-mention at index {index} outside a code span or fenced "
|
|
675
|
+
"block; GitHub would notify that user or team. Put such tokens in code spans "
|
|
676
|
+
"(`@name`) and re-emit the CONTROL_RESULT."
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def published_text_problem(subject: str, text: str) -> str | None:
|
|
681
|
+
"""Why agent text ``subject`` may not be published as is, or ``None``.
|
|
682
|
+
|
|
683
|
+
The rules, in order (ADR 0004 D8.2, D8.3): a controller marker opener of
|
|
684
|
+
either prefix (:data:`AGENT_MARKER_OPEN_RE`); a credential-shaped string,
|
|
685
|
+
named by its redaction pattern class (refused, never redacted); a closing
|
|
686
|
+
keyword followed by an issue reference, anywhere; an ``@``-mention
|
|
687
|
+
outside a code span or fenced block. The answer is one line naming
|
|
688
|
+
``subject`` and the rule, never quoting ``text``. Roughly linear in the
|
|
689
|
+
length of ``text``.
|
|
690
|
+
"""
|
|
691
|
+
m = AGENT_MARKER_OPEN_RE.search(text)
|
|
692
|
+
if m is not None:
|
|
693
|
+
return (
|
|
694
|
+
f"{subject!r} contains a controller marker opener at index {m.start()} ('<!--' "
|
|
695
|
+
"then 'ai-' or 'autoforge-', in any case or spacing); only the controller writes "
|
|
696
|
+
"markers. Remove it and re-emit the CONTROL_RESULT."
|
|
697
|
+
)
|
|
698
|
+
classes = credential_classes(text)
|
|
699
|
+
if classes:
|
|
700
|
+
return (
|
|
701
|
+
f"{subject!r} contains a credential-shaped string (pattern class: "
|
|
702
|
+
f"{', '.join(classes)}); published text is refused, never redacted. Remove it "
|
|
703
|
+
"and re-emit the CONTROL_RESULT."
|
|
704
|
+
)
|
|
705
|
+
m = _CLOSING_REFERENCE_RE.search(text)
|
|
706
|
+
if m is not None:
|
|
707
|
+
return (
|
|
708
|
+
f"{subject!r} contains a closing keyword followed by an issue reference at index "
|
|
709
|
+
f"{m.start()} ('close', 'fix' or 'resolve' in any form, then '#n', 'owner/repo#n' "
|
|
710
|
+
"or an issue URL), even inside code; GitHub closes issues named that way, and the "
|
|
711
|
+
"controller links the run's own issue itself. Reword it and re-emit the "
|
|
712
|
+
"CONTROL_RESULT."
|
|
713
|
+
)
|
|
714
|
+
return _mention_problem(subject, text)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def published_payload_problem(subject: str, payload: str) -> str | None:
|
|
718
|
+
"""Why the rendered ``payload`` ``subject`` may not be published, or ``None``.
|
|
719
|
+
|
|
720
|
+
Judges the credential rule alone, on the whole payload (ADR 0004 D8.3):
|
|
721
|
+
fields that pass one by one do not make a payload that passes, because a
|
|
722
|
+
pattern can span a field's end and the text rendered after it (a field
|
|
723
|
+
ending in ``GITHUB_TOKEN=`` takes the next rendered word as its value).
|
|
724
|
+
Names the payload and the pattern classes, never the text.
|
|
725
|
+
"""
|
|
726
|
+
classes = credential_classes(payload)
|
|
727
|
+
if not classes:
|
|
728
|
+
return None
|
|
729
|
+
return (
|
|
730
|
+
f"the rendered {subject!r} contains a credential-shaped string (pattern class: "
|
|
731
|
+
f"{', '.join(classes)}), although each field may pass alone: a field's end can join "
|
|
732
|
+
"the text rendered after it. Published text is refused, never redacted; change the "
|
|
733
|
+
"fields so that no credential shape remains and re-emit the CONTROL_RESULT."
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def published_markdown_problem(subject: str, markdown: str) -> str | None:
|
|
738
|
+
"""Why the Markdown ``subject`` composed of several fields may not be published, or ``None``.
|
|
739
|
+
|
|
740
|
+
Judges the mention rule on the composition (ADR 0004 D8.2): fields that
|
|
741
|
+
pass one by one do not make Markdown that passes, because a field's
|
|
742
|
+
unclosed fence, unpaired backtick or raw HTML can change how GitHub
|
|
743
|
+
reads the text rendered after it (a fence one field leaves open is
|
|
744
|
+
closed by another's, and the mention that followed that fence is no
|
|
745
|
+
longer code). ``markdown`` is everything the controller renders before
|
|
746
|
+
its own marker, the one raw HTML it writes. Names the payload and an
|
|
747
|
+
index, never the text.
|
|
748
|
+
"""
|
|
749
|
+
exposed = _exposed_mention(markdown)
|
|
750
|
+
if exposed is None:
|
|
751
|
+
return None
|
|
752
|
+
index, raw_html = exposed
|
|
753
|
+
where = (
|
|
754
|
+
f"and raw HTML (a tag, comment or autolink) outside code at index {raw_html}, near "
|
|
755
|
+
"which GitHub may read code as text"
|
|
756
|
+
if raw_html is not None
|
|
757
|
+
else "outside a code span or fenced block"
|
|
758
|
+
)
|
|
759
|
+
return (
|
|
760
|
+
f"the rendered {subject!r} contains an @-mention at index {index} {where}, although "
|
|
761
|
+
"each field may pass alone: an unclosed fence, an unpaired backtick or raw HTML in one "
|
|
762
|
+
"field changes how GitHub reads the fields rendered after it. Close every code span and "
|
|
763
|
+
"fenced block in the field that opens it, drop the raw HTML or the mention, and "
|
|
764
|
+
"re-emit the CONTROL_RESULT."
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def markdown_code_span(text: str) -> str:
|
|
769
|
+
"""``text`` as one Markdown code span, whatever backticks it holds.
|
|
770
|
+
|
|
771
|
+
The delimiter is one backtick longer than the longest run inside, and a
|
|
772
|
+
space pads a value that starts or ends with a backtick, or that starts
|
|
773
|
+
and ends with a space (CommonMark strips one such pair), so the span
|
|
774
|
+
shows ``text`` exactly. One-line text only; an empty value is ``""``.
|
|
775
|
+
"""
|
|
776
|
+
if not text:
|
|
777
|
+
return ""
|
|
778
|
+
longest = max((len(run) for run in re.findall(r"`+", text)), default=0)
|
|
779
|
+
fence = "`" * (longest + 1)
|
|
780
|
+
pad = " " if text[0] == "`" or text[-1] == "`" or (text[0] == " " and text[-1] == " ") else ""
|
|
781
|
+
return f"{fence}{pad}{text}{pad}{fence}"
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _names_issue(m: re.Match[str], repository: str, issue_number: int) -> bool:
|
|
785
|
+
"""Whether closing reference ``m`` names issue ``issue_number`` of ``repository``.
|
|
786
|
+
|
|
787
|
+
Repository names compare case-insensitively, as GitHub treats them; the
|
|
788
|
+
number compares as digits, so a padded ``#07`` is another reference and
|
|
789
|
+
fails closed.
|
|
790
|
+
"""
|
|
791
|
+
number = str(issue_number)
|
|
792
|
+
repo = repository.strip().lower()
|
|
793
|
+
if m.group("url_number") is not None:
|
|
794
|
+
return (
|
|
795
|
+
m.group("url_kind").lower() == "issues"
|
|
796
|
+
and m.group("url_repo").lower() == repo
|
|
797
|
+
and m.group("url_number") == number
|
|
798
|
+
)
|
|
799
|
+
if m.group("repo_number") is not None:
|
|
800
|
+
return m.group("repo").lower() == repo and m.group("repo_number") == number
|
|
801
|
+
return (m.group("number") or m.group("gh_number")) == number
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def commit_message_problem(message: str, *, repository: str, issue_number: int) -> str | None:
|
|
805
|
+
"""Why commit ``message`` may not be published, or ``None`` (ADR 0004 D8.5).
|
|
806
|
+
|
|
807
|
+
Refuses a credential-shaped string, and a closing keyword that names any
|
|
808
|
+
issue other than the run's own: ``#<issue_number>``,
|
|
809
|
+
``<repository>#<issue_number>`` (``owner/repo``, any case) or that
|
|
810
|
+
issue's URL. GitHub closes every issue named that way once the commit
|
|
811
|
+
reaches the default branch. Never quotes the message.
|
|
812
|
+
"""
|
|
813
|
+
classes = credential_classes(message)
|
|
814
|
+
if classes:
|
|
815
|
+
return (
|
|
816
|
+
f"commit message contains a credential-shaped string (pattern class: "
|
|
817
|
+
f"{', '.join(classes)}); published text is refused, never redacted. Rewrite the "
|
|
818
|
+
"commit without it."
|
|
819
|
+
)
|
|
820
|
+
for m in _CLOSING_REFERENCE_RE.finditer(message):
|
|
821
|
+
if not _names_issue(m, repository, issue_number):
|
|
822
|
+
return (
|
|
823
|
+
"commit message names an issue other than this run's own "
|
|
824
|
+
f"#{issue_number} with a closing keyword at index {m.start()}; GitHub would "
|
|
825
|
+
"close that issue when the commit reaches the default branch. Reword the "
|
|
826
|
+
f"commit message so that a closing keyword names #{issue_number} only."
|
|
827
|
+
)
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# -- typed per-phase models -----------------------------------------------
|
|
832
|
+
@dataclass
|
|
833
|
+
class AnalyzeExecuteResult:
|
|
834
|
+
"""What an implementation agent reports; the controller publishes it (#161).
|
|
835
|
+
|
|
836
|
+
``issue_url`` and ``head_sha`` are cross-checks against the run's issue
|
|
837
|
+
and the worktree's ``HEAD``, which the controller reads itself; neither
|
|
838
|
+
is a target. ``pr_title`` and ``pr_body`` are the agent's text for the PR
|
|
839
|
+
the controller creates: bounded, held to the published-content policy,
|
|
840
|
+
and never carrying a marker (the controller appends ``Closes #n`` and
|
|
841
|
+
the marker itself). ``tests`` names what the agent ran.
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
issue_url: str
|
|
845
|
+
head_sha: str
|
|
846
|
+
pr_title: str
|
|
847
|
+
pr_body: str
|
|
848
|
+
tests: list[str] = field(default_factory=list)
|
|
849
|
+
|
|
850
|
+
@classmethod
|
|
851
|
+
def from_payload(cls, p: dict) -> AnalyzeExecuteResult:
|
|
852
|
+
ph = "ANALYZE_EXECUTE"
|
|
853
|
+
issue_url = _req_url(p, "issue_url", ph, "issue")
|
|
854
|
+
head_sha = _req_sha(p, "head_sha", ph)
|
|
855
|
+
title = validate_pr_title(_req_str(p, "pr_title", ph))
|
|
856
|
+
body = validate_pr_body(_req_str(p, "pr_body", ph))
|
|
857
|
+
tests = _opt_str_list(p, "tests", ph)
|
|
858
|
+
if len(tests) > MAX_TESTS_REPORTED:
|
|
859
|
+
raise ControlResultValidationError(
|
|
860
|
+
f"{ph}: field 'tests' lists {len(tests)} entries; the controller accepts at "
|
|
861
|
+
f"most {MAX_TESTS_REPORTED}. Summarise them and re-emit the CONTROL_RESULT."
|
|
862
|
+
)
|
|
863
|
+
for test in tests:
|
|
864
|
+
_one_line(_bounded(test, ph, "result", "tests", MAX_TEST_CHARS), ph, "result", "tests")
|
|
865
|
+
return cls(
|
|
866
|
+
issue_url=issue_url,
|
|
867
|
+
head_sha=head_sha,
|
|
868
|
+
pr_title=title,
|
|
869
|
+
pr_body=body,
|
|
870
|
+
tests=tests,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _published_field(text: str, key: str, phase: str) -> str:
|
|
875
|
+
"""Refuse ``phase``'s field ``key`` under the published-content policy."""
|
|
876
|
+
problem = published_text_problem(key, text)
|
|
877
|
+
if problem is not None:
|
|
878
|
+
raise ControlResultValidationError(f"{phase}: field {problem}")
|
|
879
|
+
return text
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _pr_text(
|
|
883
|
+
text: str, key: str, limit: int, lines: Callable[[str, str, str, str], str], ph: str
|
|
884
|
+
) -> str:
|
|
885
|
+
if not isinstance(text, str):
|
|
886
|
+
raise ControlResultValidationError(f"{ph}: field {key!r} must be a string")
|
|
887
|
+
if not text.strip():
|
|
888
|
+
raise ControlResultValidationError(
|
|
889
|
+
f"CONTROL_RESULT for {ph} missing required field {key!r}"
|
|
890
|
+
)
|
|
891
|
+
text = _bounded(text, ph, "result", key, limit)
|
|
892
|
+
return _published_field(lines(text, ph, "result", key), key, ph)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def validate_pr_title(text: str, *, phase: str = "ANALYZE_EXECUTE") -> str:
|
|
896
|
+
"""``text`` as a ``pr_title`` of ``phase``, or a rejection.
|
|
897
|
+
|
|
898
|
+
Non-blank, at most :data:`MAX_PR_TITLE_CHARS`, one line of printable
|
|
899
|
+
text, and publishable (:func:`published_text_problem`): the controller
|
|
900
|
+
creates the PR with it, the implementation PR of ANALYZE_EXECUTE (#161)
|
|
901
|
+
or the replacement PR of REPLAN_REEXECUTE (#164). The checks
|
|
902
|
+
:meth:`AnalyzeExecuteResult.from_payload` and
|
|
903
|
+
:meth:`ReplanReexecuteResult.from_payload` apply, with the same messages,
|
|
904
|
+
so a persisted K2 or K7 title can be re-validated under the parser's
|
|
905
|
+
rules.
|
|
906
|
+
"""
|
|
907
|
+
return _pr_text(text, "pr_title", MAX_PR_TITLE_CHARS, _one_line, phase)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def validate_pr_body(text: str, *, phase: str = "ANALYZE_EXECUTE") -> str:
|
|
911
|
+
"""``text`` as a ``pr_body`` of ``phase``, or a rejection.
|
|
912
|
+
|
|
913
|
+
Non-blank, at most :data:`MAX_PR_BODY_CHARS`, multi-line text with no
|
|
914
|
+
other control character, and publishable (:func:`published_text_problem`):
|
|
915
|
+
the agent's part of the PR body, before the controller's ``Closes #n``
|
|
916
|
+
and markers. The checks :meth:`AnalyzeExecuteResult.from_payload` and
|
|
917
|
+
:meth:`ReplanReexecuteResult.from_payload` apply, with the same messages,
|
|
918
|
+
so the agent's part of a persisted K2 or K7 body can be re-validated
|
|
919
|
+
under the parser's rules.
|
|
920
|
+
"""
|
|
921
|
+
return _pr_text(text, "pr_body", MAX_PR_BODY_CHARS, _multi_line, phase)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
# Control characters a *multi-line* text field may still carry: a newline
|
|
925
|
+
# structures a resolution and a tab is ordinary indentation; every other
|
|
926
|
+
# member of the class the prompt renderer escapes is refused (#78).
|
|
927
|
+
_MULTI_LINE_CONTROL_RE = re.compile(rf"(?![\n\t])[{CONTROL_CHARS}]")
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _one_line(text: str, phase: str, subject: str, key: str) -> str:
|
|
931
|
+
"""Reject a one-line text field of ``subject`` carrying a control character.
|
|
932
|
+
|
|
933
|
+
The class is ``prompts.CONTROL_CHAR_RE``, the one ``escape_inline`` would
|
|
934
|
+
otherwise escape when the field is rendered on one line of a prompt: a
|
|
935
|
+
value the controller would have to rewrite before it can show it is not
|
|
936
|
+
accepted. The message names the code point and its index, never the
|
|
937
|
+
text. Length is checked first (``_bounded``), so the index is into a
|
|
938
|
+
value of accepted size.
|
|
939
|
+
"""
|
|
940
|
+
m = CONTROL_CHAR_RE.search(text)
|
|
941
|
+
if m is not None:
|
|
942
|
+
raise ControlResultValidationError(
|
|
943
|
+
f"{phase}: {subject} field {key!r} contains a control character "
|
|
944
|
+
f"(U+{ord(m.group(0)):04X} at index {m.start()}); keep it to one line of "
|
|
945
|
+
"printable text and re-emit the CONTROL_RESULT."
|
|
946
|
+
)
|
|
947
|
+
return text
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _multi_line(text: str, phase: str, subject: str, key: str) -> str:
|
|
951
|
+
"""Reject a multi-line text field of ``subject`` carrying a control
|
|
952
|
+
character other than a newline or a tab. Same message discipline as
|
|
953
|
+
:func:`_one_line`."""
|
|
954
|
+
m = _MULTI_LINE_CONTROL_RE.search(text)
|
|
955
|
+
if m is not None:
|
|
956
|
+
raise ControlResultValidationError(
|
|
957
|
+
f"{phase}: {subject} field {key!r} contains a control character "
|
|
958
|
+
f"(U+{ord(m.group(0)):04X} at index {m.start()}); only newlines and tabs are "
|
|
959
|
+
f"accepted inside {key!r}. Remove it and re-emit the CONTROL_RESULT."
|
|
960
|
+
)
|
|
961
|
+
return text
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _bounded(text: str, phase: str, subject: str, key: str, limit: int) -> str:
|
|
965
|
+
"""Reject a text field of ``subject`` longer than ``limit`` characters.
|
|
966
|
+
|
|
967
|
+
The message reports the size, never the text: the oversized value is the
|
|
968
|
+
thing being refused, and the error is echoed into the correction prompt
|
|
969
|
+
and the run log.
|
|
970
|
+
"""
|
|
971
|
+
if len(text) > limit:
|
|
972
|
+
raise ControlResultValidationError(
|
|
973
|
+
f"{phase}: {subject} field {key!r} is {len(text)} characters; the controller "
|
|
974
|
+
f"accepts at most {limit}. Shorten it and re-emit the CONTROL_RESULT."
|
|
975
|
+
)
|
|
976
|
+
return text
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _raw_resolutions(p: dict) -> list:
|
|
980
|
+
"""The ``resolutions`` list of a FIX result, count-checked before any
|
|
981
|
+
element is parsed (both modes)."""
|
|
982
|
+
raw = p.get("resolutions")
|
|
983
|
+
if not isinstance(raw, list):
|
|
984
|
+
raise ControlResultValidationError("'resolutions' must be a list (one per finding)")
|
|
985
|
+
if len(raw) > MAX_RESOLUTIONS_PER_FIX:
|
|
986
|
+
raise ControlResultValidationError(
|
|
987
|
+
f"FIX: {len(raw)} resolutions reported; the controller accepts at most "
|
|
988
|
+
f"{MAX_RESOLUTIONS_PER_FIX}, one per open finding. Report exactly one resolution "
|
|
989
|
+
"per finding id listed in the prompt and re-emit the CONTROL_RESULT."
|
|
990
|
+
)
|
|
991
|
+
return raw
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _finding_id(payload: dict, key: str, phase: str, round: int | None = None) -> str:
|
|
995
|
+
"""The bounded, well-formed finding id at ``payload[key]``.
|
|
996
|
+
|
|
997
|
+
Length is checked first: the shape and round checks quote the id in their
|
|
998
|
+
messages, and those messages reach the correction prompt and the run log.
|
|
999
|
+
With ``round`` given (REVIEW), the id must belong to that round.
|
|
1000
|
+
"""
|
|
1001
|
+
fid = _req_str(payload, key, phase)
|
|
1002
|
+
if len(fid) > MAX_FINDING_ID_CHARS:
|
|
1003
|
+
raise ControlResultValidationError(
|
|
1004
|
+
f"{phase}: field {key!r} is {len(fid)} characters; a finding id is R<round>-F<n> "
|
|
1005
|
+
f"and the controller accepts at most {MAX_FINDING_ID_CHARS}. Re-emit the "
|
|
1006
|
+
"CONTROL_RESULT with well-formed ids."
|
|
1007
|
+
)
|
|
1008
|
+
m = _FINDING_ID_RE.match(fid)
|
|
1009
|
+
if not m:
|
|
1010
|
+
if round is None:
|
|
1011
|
+
raise ControlResultValidationError(f"{phase}: invalid {key} {fid!r}")
|
|
1012
|
+
raise ControlResultValidationError(
|
|
1013
|
+
f"{phase}: finding id {fid!r} must look like R<round>-F<n> (e.g. R{round}-F1)"
|
|
1014
|
+
)
|
|
1015
|
+
if round is not None and int(m.group("round")) != round:
|
|
1016
|
+
raise ControlResultValidationError(
|
|
1017
|
+
f"{phase}: finding id {fid!r} does not belong to review round {round}"
|
|
1018
|
+
)
|
|
1019
|
+
return fid
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _parse_findings(payload: dict, round: int, needs_fix: bool) -> list[Finding]:
|
|
1023
|
+
"""The bounded, validated findings list of a REVIEW result (any mode).
|
|
1024
|
+
|
|
1025
|
+
The count is checked before any element is parsed, so an oversized list is
|
|
1026
|
+
refused without the controller doing per-element work on it, and the
|
|
1027
|
+
review invariant (``needs_fix_round == (findings > 0)``) is enforced here
|
|
1028
|
+
so REMOTE and LOCAL reviews cannot drift apart.
|
|
1029
|
+
"""
|
|
1030
|
+
raw_findings = payload.get("findings")
|
|
1031
|
+
if not isinstance(raw_findings, list):
|
|
1032
|
+
raise ControlResultValidationError("'findings' must be a list (empty when clean)")
|
|
1033
|
+
if len(raw_findings) > MAX_FINDINGS_PER_REVIEW:
|
|
1034
|
+
raise ControlResultValidationError(
|
|
1035
|
+
f"REVIEW: {len(raw_findings)} findings reported; the controller accepts at most "
|
|
1036
|
+
f"{MAX_FINDINGS_PER_REVIEW} per review round. Keep the actionable findings, move "
|
|
1037
|
+
"non-actionable remarks to observations, and re-emit the CONTROL_RESULT."
|
|
1038
|
+
)
|
|
1039
|
+
findings = [Finding.from_payload(f, round, i) for i, f in enumerate(raw_findings)]
|
|
1040
|
+
ids = [f.id for f in findings]
|
|
1041
|
+
if len(set(ids)) != len(ids):
|
|
1042
|
+
raise ControlResultValidationError(f"duplicate finding ids: {ids}")
|
|
1043
|
+
# Controller invariant (§14): the boolean must agree with the list.
|
|
1044
|
+
if needs_fix != (len(findings) > 0):
|
|
1045
|
+
raise ControlResultValidationError(
|
|
1046
|
+
f"invalid REVIEW result: needs_fix_round={needs_fix} but "
|
|
1047
|
+
f"{len(findings)} finding(s) reported — the two must agree "
|
|
1048
|
+
"(any finding, even a nit, requires a fix round; observations are not findings)"
|
|
1049
|
+
)
|
|
1050
|
+
return findings
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
@dataclass
|
|
1054
|
+
class Finding:
|
|
1055
|
+
id: str
|
|
1056
|
+
classification: str
|
|
1057
|
+
required_resolution: str
|
|
1058
|
+
title: str = ""
|
|
1059
|
+
location: str = ""
|
|
1060
|
+
|
|
1061
|
+
@classmethod
|
|
1062
|
+
def from_payload(cls, raw: object, round: int, index: int) -> Finding:
|
|
1063
|
+
ph = "REVIEW"
|
|
1064
|
+
if not isinstance(raw, dict):
|
|
1065
|
+
raise ControlResultValidationError(
|
|
1066
|
+
f"{ph}: findings[{index}] must be an object with id/classification/"
|
|
1067
|
+
"required_resolution"
|
|
1068
|
+
)
|
|
1069
|
+
fid = _finding_id(raw, "id", ph, round)
|
|
1070
|
+
subject = f"finding {fid}"
|
|
1071
|
+
cls_ = _req_str(raw, "classification", ph)
|
|
1072
|
+
if cls_ not in FINDING_CLASSIFICATIONS:
|
|
1073
|
+
raise ControlResultValidationError(
|
|
1074
|
+
f"{ph}: finding {fid} classification must be one of "
|
|
1075
|
+
f"{FINDING_CLASSIFICATIONS}, got {cls_!r}"
|
|
1076
|
+
)
|
|
1077
|
+
return cls(
|
|
1078
|
+
id=fid,
|
|
1079
|
+
classification=cls_,
|
|
1080
|
+
required_resolution=_multi_line(
|
|
1081
|
+
_bounded(
|
|
1082
|
+
_req_str(raw, "required_resolution", ph),
|
|
1083
|
+
ph,
|
|
1084
|
+
subject,
|
|
1085
|
+
"required_resolution",
|
|
1086
|
+
MAX_FINDING_RESOLUTION_CHARS,
|
|
1087
|
+
),
|
|
1088
|
+
ph,
|
|
1089
|
+
subject,
|
|
1090
|
+
"required_resolution",
|
|
1091
|
+
),
|
|
1092
|
+
title=_one_line(
|
|
1093
|
+
_bounded(_opt_str(raw, "title", ph), ph, subject, "title", MAX_FINDING_TITLE_CHARS),
|
|
1094
|
+
ph,
|
|
1095
|
+
subject,
|
|
1096
|
+
"title",
|
|
1097
|
+
),
|
|
1098
|
+
location=_one_line(
|
|
1099
|
+
_bounded(
|
|
1100
|
+
_opt_str(raw, "location", ph),
|
|
1101
|
+
ph,
|
|
1102
|
+
subject,
|
|
1103
|
+
"location",
|
|
1104
|
+
MAX_FINDING_LOCATION_CHARS,
|
|
1105
|
+
),
|
|
1106
|
+
ph,
|
|
1107
|
+
subject,
|
|
1108
|
+
"location",
|
|
1109
|
+
),
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
def to_dict(self) -> dict:
|
|
1113
|
+
return {
|
|
1114
|
+
"id": self.id,
|
|
1115
|
+
"classification": self.classification,
|
|
1116
|
+
"required_resolution": self.required_resolution,
|
|
1117
|
+
"title": self.title,
|
|
1118
|
+
"location": self.location,
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
@dataclass
|
|
1123
|
+
class ReviewResult:
|
|
1124
|
+
"""What a REMOTE reviewer reports; the controller publishes it (#162).
|
|
1125
|
+
|
|
1126
|
+
``round`` and ``reviewed_head_sha`` are cross-checks against the round
|
|
1127
|
+
and the HEAD the controller bound; neither is a target, and the result
|
|
1128
|
+
names no comment. ``needs_fix_round`` and ``findings`` are the verdict.
|
|
1129
|
+
``sections`` holds the prose of :data:`REVIEW_PROSE_SECTIONS`, in that
|
|
1130
|
+
order. Every field the controller renders into the review comment (the
|
|
1131
|
+
prose, and each finding's ``title``, ``location`` and
|
|
1132
|
+
``required_resolution``) is held to the published-content policy; the
|
|
1133
|
+
findings' shape and bounds are LOCAL's too, unchanged.
|
|
1134
|
+
"""
|
|
1135
|
+
|
|
1136
|
+
round: int
|
|
1137
|
+
reviewed_head_sha: str
|
|
1138
|
+
needs_fix_round: bool
|
|
1139
|
+
findings: list[Finding] = field(default_factory=list)
|
|
1140
|
+
sections: dict[str, str] = field(default_factory=dict)
|
|
1141
|
+
|
|
1142
|
+
@classmethod
|
|
1143
|
+
def from_payload(cls, p: dict) -> ReviewResult:
|
|
1144
|
+
ph = "REVIEW"
|
|
1145
|
+
rnd = _req(p, "round", ph)
|
|
1146
|
+
if not isinstance(rnd, int) or isinstance(rnd, bool) or rnd < 1:
|
|
1147
|
+
raise ControlResultValidationError("'round' must be an int >= 1")
|
|
1148
|
+
needs_fix = _req_bool(p, "needs_fix_round", ph)
|
|
1149
|
+
findings = _parse_findings(p, rnd, needs_fix)
|
|
1150
|
+
for finding in findings:
|
|
1151
|
+
check_published_finding(finding)
|
|
1152
|
+
sections = {
|
|
1153
|
+
key: validate_review_section(key, p.get(key)) for key, _ in REVIEW_PROSE_SECTIONS
|
|
1154
|
+
}
|
|
1155
|
+
return cls(
|
|
1156
|
+
round=rnd,
|
|
1157
|
+
reviewed_head_sha=_req_sha(p, "reviewed_head_sha", ph),
|
|
1158
|
+
needs_fix_round=needs_fix,
|
|
1159
|
+
findings=findings,
|
|
1160
|
+
sections=sections,
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def check_published_finding(finding: Finding) -> None:
|
|
1165
|
+
"""Refuse ``finding`` unless the round's review comment may show it (#162).
|
|
1166
|
+
|
|
1167
|
+
Each field is judged as the comment renders it: the location inside a
|
|
1168
|
+
code span, the others as Markdown text. A persisted REVIEW plan's
|
|
1169
|
+
findings are held to the same rule when they are loaded (ADR 0004 D4.6).
|
|
1170
|
+
"""
|
|
1171
|
+
_review_published(finding.title, f"{finding.id}.title")
|
|
1172
|
+
_review_published(markdown_code_span(finding.location), f"{finding.id}.location")
|
|
1173
|
+
if credential_classes(finding.location):
|
|
1174
|
+
# The span's backticks must not hide a shape the bare value has: the
|
|
1175
|
+
# findings are persisted as reported, in plain state.
|
|
1176
|
+
_review_published(finding.location, f"{finding.id}.location")
|
|
1177
|
+
_review_published(finding.required_resolution, f"{finding.id}.required_resolution")
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def validate_review_section(key: str, text: object) -> str:
|
|
1181
|
+
"""``text`` as REVIEW prose section ``key``, or a rejection (#162).
|
|
1182
|
+
|
|
1183
|
+
Non-blank, within the section's bound (:data:`REVIEW_PROSE_SECTIONS`),
|
|
1184
|
+
multi-line text with no other control character, and publishable
|
|
1185
|
+
(:func:`published_text_problem`): the controller renders it into the
|
|
1186
|
+
round's review comment under its fixed heading. The checks
|
|
1187
|
+
:meth:`ReviewResult.from_payload` applies, with the same messages, so a
|
|
1188
|
+
persisted section can be re-validated under the parser's rules.
|
|
1189
|
+
"""
|
|
1190
|
+
ph = "REVIEW"
|
|
1191
|
+
limit = dict(REVIEW_PROSE_SECTIONS)[key]
|
|
1192
|
+
stripped = _req_str({key: text}, key, ph)
|
|
1193
|
+
stripped = _multi_line(_bounded(stripped, ph, "result", key, limit), ph, "result", key)
|
|
1194
|
+
return _review_published(stripped, key)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _review_published(text: str, subject: str) -> str:
|
|
1198
|
+
"""Refuse REVIEW text ``subject`` under the published-content policy."""
|
|
1199
|
+
problem = published_text_problem(subject, text)
|
|
1200
|
+
if problem is not None:
|
|
1201
|
+
raise ControlResultValidationError(f"REVIEW: field {problem}")
|
|
1202
|
+
return text
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _fix_text(text: object, subject: str, key: str, limit: int, one_line: bool) -> str:
|
|
1206
|
+
"""A FIX follow-up text the controller publishes: non-blank, bounded, publishable."""
|
|
1207
|
+
ph = "FIX"
|
|
1208
|
+
if not isinstance(text, str):
|
|
1209
|
+
raise ControlResultValidationError(f"{ph}: {subject} field {key!r} must be a string")
|
|
1210
|
+
stripped = text.strip()
|
|
1211
|
+
if not stripped:
|
|
1212
|
+
raise ControlResultValidationError(f"{ph}: {subject} is missing required field {key!r}")
|
|
1213
|
+
stripped = _bounded(stripped, ph, subject, key, limit)
|
|
1214
|
+
stripped = (_one_line if one_line else _multi_line)(stripped, ph, subject, key)
|
|
1215
|
+
problem = published_text_problem(f"{subject} {key}", stripped)
|
|
1216
|
+
if problem is not None:
|
|
1217
|
+
raise ControlResultValidationError(f"{ph}: field {problem}")
|
|
1218
|
+
return stripped
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def validate_follow_up_title(text: object, subject: str = "follow_up_issue") -> str:
|
|
1222
|
+
"""``text`` as the title of a new follow-up issue (#163), or a rejection.
|
|
1223
|
+
|
|
1224
|
+
Non-blank, at most :data:`MAX_FOLLOW_UP_TITLE_CHARS`, one line of
|
|
1225
|
+
printable text, and publishable (:func:`published_text_problem`): the
|
|
1226
|
+
controller creates the issue with it. The checks
|
|
1227
|
+
:meth:`FindingResolution.from_payload` applies, so a persisted K5 title
|
|
1228
|
+
can be re-validated under the parser's rules.
|
|
1229
|
+
"""
|
|
1230
|
+
return _fix_text(text, subject, "title", MAX_FOLLOW_UP_TITLE_CHARS, one_line=True)
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def validate_follow_up_body(text: object, subject: str = "follow_up_issue") -> str:
|
|
1234
|
+
"""``text`` as the agent's part of a new follow-up issue's body (#163), or a rejection.
|
|
1235
|
+
|
|
1236
|
+
Non-blank, at most :data:`MAX_FOLLOW_UP_BODY_CHARS`, multi-line text
|
|
1237
|
+
with no other control character, and publishable: the controller
|
|
1238
|
+
follows it with its own reference to the PR and finding and the
|
|
1239
|
+
finding's marker. The checks :meth:`FindingResolution.from_payload`
|
|
1240
|
+
applies, so the agent's part of a persisted K5 body can be re-validated.
|
|
1241
|
+
"""
|
|
1242
|
+
return _fix_text(text, subject, "body", MAX_FOLLOW_UP_BODY_CHARS, one_line=False)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def validate_acceptance_body(text: object, subject: str = "acceptance_action") -> str:
|
|
1246
|
+
"""``text`` as the agent's part of an acceptance comment (#211), or a rejection.
|
|
1247
|
+
|
|
1248
|
+
Non-blank, at most :data:`MAX_ACCEPTANCE_BODY_CHARS`, multi-line text
|
|
1249
|
+
with no other control character, and publishable: the controller puts
|
|
1250
|
+
its own header before it and the finding's ``ai-acceptance`` marker
|
|
1251
|
+
after it. The checks :meth:`FindingResolution.from_payload` applies, so
|
|
1252
|
+
the agent's part of a persisted K9 body can be re-validated.
|
|
1253
|
+
"""
|
|
1254
|
+
return _fix_text(text, subject, "body", MAX_ACCEPTANCE_BODY_CHARS, one_line=False)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _acceptance_body(raw: object, fid: str) -> str:
|
|
1258
|
+
"""The body of ``fid``'s ``acceptance_action`` object, or a rejection.
|
|
1259
|
+
|
|
1260
|
+
The object is a closed schema: ``kind`` (one of
|
|
1261
|
+
:data:`ACCEPTANCE_ACTION_KINDS`) and ``body``. A key that would name a
|
|
1262
|
+
target is refused by name, any other unknown key as unknown; nothing is
|
|
1263
|
+
ignored, because an ignored ``url`` would read as a target the
|
|
1264
|
+
controller honoured.
|
|
1265
|
+
"""
|
|
1266
|
+
ph = "FIX"
|
|
1267
|
+
if not isinstance(raw, dict):
|
|
1268
|
+
raise ControlResultValidationError(
|
|
1269
|
+
f"{ph}: {fid} field 'acceptance_action' must be an object with kind and body"
|
|
1270
|
+
)
|
|
1271
|
+
targets = sorted(k for k in raw if k in ACCEPTANCE_TARGET_KEYS)
|
|
1272
|
+
if targets:
|
|
1273
|
+
raise ControlResultValidationError(
|
|
1274
|
+
f"{ph}: {fid} acceptance_action carries {', '.join(repr(k) for k in targets)}; an "
|
|
1275
|
+
"acceptance action names no target, URL, method, path or command: the controller "
|
|
1276
|
+
"comments on the run's own issue. Remove the field and re-emit the CONTROL_RESULT."
|
|
1277
|
+
)
|
|
1278
|
+
unknown = sorted(str(k) for k in raw if k not in ACCEPTANCE_ACTION_KEYS)
|
|
1279
|
+
if unknown:
|
|
1280
|
+
raise ControlResultValidationError(
|
|
1281
|
+
f"{ph}: {fid} acceptance_action has unknown field(s) "
|
|
1282
|
+
f"{', '.join(repr(k[:MAX_FINDING_ID_CHARS]) for k in unknown)}; it carries "
|
|
1283
|
+
"exactly 'kind' and 'body'"
|
|
1284
|
+
)
|
|
1285
|
+
kind = raw.get("kind")
|
|
1286
|
+
if kind not in ACCEPTANCE_ACTION_KINDS:
|
|
1287
|
+
shown = repr(kind[:MAX_FINDING_ID_CHARS]) if isinstance(kind, str) else type(kind).__name__
|
|
1288
|
+
raise ControlResultValidationError(
|
|
1289
|
+
f"{ph}: {fid} acceptance_action kind {shown} is not supported; the one supported "
|
|
1290
|
+
f"kind is {ACCEPTANCE_ACTION_KINDS[0]!r} (a comment on the run's own issue)"
|
|
1291
|
+
)
|
|
1292
|
+
return validate_acceptance_body(raw.get("body"), f"acceptance_action of {fid}")
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
# A link or bare URL in operator text (#243): a scheme separator, an
|
|
1296
|
+
# extended ``www.`` autolink, a Markdown inline link destination or a link
|
|
1297
|
+
# reference definition. Matched anywhere, code included, like
|
|
1298
|
+
# :data:`_PROGRESS_URL_RE`: the operator copies the text whole. A
|
|
1299
|
+
# definition is read wherever ``]:`` occurs, not only at a line start:
|
|
1300
|
+
# CommonMark honours one inside a block quote or list item, with an escaped
|
|
1301
|
+
# bracket or a line break in its label, and with its destination on the next
|
|
1302
|
+
# line. A ``]:`` that starts no definition is refused too unless what follows
|
|
1303
|
+
# links into the repository; rephrasing it costs the fixer one correction.
|
|
1304
|
+
_OPERATOR_URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*+://[^\s<>\"'`)\]]*+")
|
|
1305
|
+
_OPERATOR_WWW_RE = re.compile(r"(?<![A-Za-z0-9.])www\.", re.IGNORECASE)
|
|
1306
|
+
_OPERATOR_LINK_DEST_RE = re.compile(r"\]\(\s*+<?([^\s)>]*+)")
|
|
1307
|
+
_OPERATOR_LINK_DEF_RE = re.compile(r"\]:[ \t]*+(?:\r?\n[ \t]*+)?(<?)([^\s>]*+)")
|
|
1308
|
+
_OPERATOR_IMAGE_RE = re.compile(r"!\[")
|
|
1309
|
+
# An e-mail address GitHub autolinks as a ``mailto:`` link.
|
|
1310
|
+
_OPERATOR_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]@[A-Za-z0-9-]++\.[A-Za-z]")
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _repository_url_prefix(repository: str) -> re.Pattern[str]:
|
|
1314
|
+
"""The one link form operator text may carry: ``https://github.com/<owner>/<repo>``.
|
|
1315
|
+
|
|
1316
|
+
Owner and repository compare case-insensitively, as GitHub does; the
|
|
1317
|
+
prefix must end the URL or be followed by ``/``, ``?`` or ``#``, so a
|
|
1318
|
+
longer name (``repo-evil``) or another host never matches.
|
|
1319
|
+
"""
|
|
1320
|
+
return re.compile(rf"https://github\.com/{re.escape(repository)}(?:[/?#]|\Z)", re.IGNORECASE)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def operator_comment_link_problem(subject: str, text: str, repository: str) -> str | None:
|
|
1324
|
+
"""Why operator text ``subject`` links outside ``repository``, or ``None`` (#243).
|
|
1325
|
+
|
|
1326
|
+
Every link or bare URL must be ``https://github.com/<repository>/...``;
|
|
1327
|
+
a relative link, another scheme, another host or repository, a ``www.``
|
|
1328
|
+
or e-mail autolink and a path that climbs out with ``..`` or a backslash
|
|
1329
|
+
are refused, as written and once entity and percent escapes are decoded
|
|
1330
|
+
(CommonMark decodes an entity in a destination, and a browser resolves
|
|
1331
|
+
``%2e%2e`` as ``..``). Names the subject, the index and the rule, never
|
|
1332
|
+
the text.
|
|
1333
|
+
"""
|
|
1334
|
+
allowed = _repository_url_prefix(repository)
|
|
1335
|
+
|
|
1336
|
+
def bad(url: str) -> bool:
|
|
1337
|
+
return any(
|
|
1338
|
+
allowed.match(form) is None
|
|
1339
|
+
or "\\" in form
|
|
1340
|
+
or re.search(r"/\.{1,2}(?:[/?#]|\Z)", form) is not None
|
|
1341
|
+
for form in (url, unquote(html.unescape(url)))
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
found: list[int] = []
|
|
1345
|
+
for m in _OPERATOR_URL_RE.finditer(text):
|
|
1346
|
+
if bad(m.group(0)):
|
|
1347
|
+
found.append(m.start())
|
|
1348
|
+
found.extend(m.start() for m in _OPERATOR_WWW_RE.finditer(text))
|
|
1349
|
+
found.extend(m.start() for m in _OPERATOR_EMAIL_RE.finditer(text))
|
|
1350
|
+
for m in _OPERATOR_LINK_DEST_RE.finditer(text):
|
|
1351
|
+
if bad(m.group(1)):
|
|
1352
|
+
found.append(m.start(1))
|
|
1353
|
+
for m in _OPERATOR_LINK_DEF_RE.finditer(text):
|
|
1354
|
+
if (m.group(1) or m.group(2)) and bad(m.group(2)):
|
|
1355
|
+
found.append(m.start(2))
|
|
1356
|
+
if not found:
|
|
1357
|
+
return None
|
|
1358
|
+
return (
|
|
1359
|
+
f"{subject!r} contains a link or URL at index {min(found)} that is not "
|
|
1360
|
+
f"'https://github.com/{repository}/...'; the operator posts this text under their own "
|
|
1361
|
+
"identity, so it links only into the run's repository (no relative link, other host, "
|
|
1362
|
+
"other repository, 'www.' form or e-mail address). Remove it and re-emit the "
|
|
1363
|
+
"CONTROL_RESULT."
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def operator_markup_problem(subject: str, text: str) -> str | None:
|
|
1368
|
+
"""Why operator text ``subject`` carries an image or raw HTML, or ``None`` (#243).
|
|
1369
|
+
|
|
1370
|
+
Both are judged outside code spans and fenced blocks, where GitHub
|
|
1371
|
+
renders them. Names the subject and an index, never the text.
|
|
1372
|
+
"""
|
|
1373
|
+
ranges = _code_ranges(text)
|
|
1374
|
+
image = next(_outside(ranges, (m.start() for m in _OPERATOR_IMAGE_RE.finditer(text))), None)
|
|
1375
|
+
if image is not None:
|
|
1376
|
+
return (
|
|
1377
|
+
f"{subject!r} contains a Markdown image at index {image} outside code; the operator "
|
|
1378
|
+
"posts this text under their own identity, so it embeds no image. Remove it and "
|
|
1379
|
+
"re-emit the CONTROL_RESULT."
|
|
1380
|
+
)
|
|
1381
|
+
html = next(_outside(ranges, (m.start() for m in _RAW_HTML_RE.finditer(text))), None)
|
|
1382
|
+
if html is not None:
|
|
1383
|
+
return (
|
|
1384
|
+
f"{subject!r} contains raw HTML (a tag, comment or autolink) at index {html} outside "
|
|
1385
|
+
"code; the operator posts this text under their own identity, so it carries no raw "
|
|
1386
|
+
"HTML. Remove it (or put it in a code span) and re-emit the CONTROL_RESULT."
|
|
1387
|
+
)
|
|
1388
|
+
return None
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def validate_operator_comment_body(
|
|
1392
|
+
text: object, subject: str = "operator_acceptance", repository: str = ""
|
|
1393
|
+
) -> str:
|
|
1394
|
+
"""``text`` as the agent's part of an operator comment (K10, #243), or a rejection.
|
|
1395
|
+
|
|
1396
|
+
Non-blank, at most :data:`MAX_OPERATOR_COMMENT_CHARS`, multi-line text
|
|
1397
|
+
with no other control character, publishable
|
|
1398
|
+
(:func:`published_text_problem`), and with no image or raw HTML
|
|
1399
|
+
(:func:`operator_markup_problem`). With ``repository``
|
|
1400
|
+
(``owner/name``) the link rule applies too
|
|
1401
|
+
(:func:`operator_comment_link_problem`): the parser does not know the
|
|
1402
|
+
run's repository, so the controller applies it when it checks the
|
|
1403
|
+
result and when it reloads a persisted record.
|
|
1404
|
+
"""
|
|
1405
|
+
body = _fix_text(text, subject, "body", MAX_OPERATOR_COMMENT_CHARS, one_line=False)
|
|
1406
|
+
problem = operator_markup_problem(f"{subject} body", body)
|
|
1407
|
+
if problem is None and repository:
|
|
1408
|
+
problem = operator_comment_link_problem(f"{subject} body", body, repository)
|
|
1409
|
+
if problem is not None:
|
|
1410
|
+
raise ControlResultValidationError(f"FIX: field {problem}")
|
|
1411
|
+
return body
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
@dataclass(frozen=True)
|
|
1415
|
+
class OperatorAction:
|
|
1416
|
+
"""One ``related_issue_comment`` an ``operator_acceptance`` resolution requests (#243).
|
|
1417
|
+
|
|
1418
|
+
``issue`` is a number of the run's repository the controller checks
|
|
1419
|
+
against the allowed set it read at FIX entry; ``body`` is the agent's
|
|
1420
|
+
text the controller renders between its header and marker.
|
|
1421
|
+
"""
|
|
1422
|
+
|
|
1423
|
+
issue: int
|
|
1424
|
+
body: str
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _operator_issue_number(value: object, fid: str, index: int) -> int:
|
|
1428
|
+
"""The target issue number of ``fid``'s action ``index``, or a rejection."""
|
|
1429
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1430
|
+
shown = "a string" if isinstance(value, str) else type(value).__name__
|
|
1431
|
+
raise ControlResultValidationError(
|
|
1432
|
+
f"FIX: {fid} operator_acceptance actions[{index}] field 'issue' is {shown}; it is "
|
|
1433
|
+
"a JSON integer, the issue number alone (never a URL, '#n' or 'owner/repo#n'). Fix "
|
|
1434
|
+
"it and re-emit the CONTROL_RESULT."
|
|
1435
|
+
)
|
|
1436
|
+
if not 1 <= value <= MAX_OPERATOR_ISSUE_NUMBER:
|
|
1437
|
+
raise ControlResultValidationError(
|
|
1438
|
+
f"FIX: {fid} operator_acceptance actions[{index}] field 'issue' must be between 1 "
|
|
1439
|
+
f"and {MAX_OPERATOR_ISSUE_NUMBER}"
|
|
1440
|
+
)
|
|
1441
|
+
return value
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _operator_actions(raw: object, fid: str) -> tuple[OperatorAction, ...]:
|
|
1445
|
+
"""The actions of ``fid``'s ``operator_acceptance`` object, or a rejection.
|
|
1446
|
+
|
|
1447
|
+
A closed schema at both levels: the object carries ``actions`` only, and
|
|
1448
|
+
each action exactly ``kind``, ``issue`` and ``body``. A key that would
|
|
1449
|
+
name a target is refused by name, any other unknown key as unknown;
|
|
1450
|
+
nothing is ignored, because an ignored ``url`` would read as a target
|
|
1451
|
+
the controller honoured.
|
|
1452
|
+
"""
|
|
1453
|
+
ph = "FIX"
|
|
1454
|
+
if not isinstance(raw, dict):
|
|
1455
|
+
raise ControlResultValidationError(
|
|
1456
|
+
f"{ph}: {fid} field 'operator_acceptance' must be an object with 'actions'"
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
def refuse_keys(obj: dict, allowed: tuple[str, ...], what: str) -> None:
|
|
1460
|
+
targets = sorted(str(k) for k in obj if k in OPERATOR_TARGET_KEYS)
|
|
1461
|
+
if targets:
|
|
1462
|
+
raise ControlResultValidationError(
|
|
1463
|
+
f"{ph}: {fid} {what} carries {', '.join(repr(k) for k in targets)}; an operator "
|
|
1464
|
+
"action names no URL, repository, owner, target, method, path or command: it "
|
|
1465
|
+
"names an issue number of the run's repository. Remove the field and re-emit "
|
|
1466
|
+
"the CONTROL_RESULT."
|
|
1467
|
+
)
|
|
1468
|
+
unknown = sorted(str(k) for k in obj if k not in allowed)
|
|
1469
|
+
if unknown:
|
|
1470
|
+
raise ControlResultValidationError(
|
|
1471
|
+
f"{ph}: {fid} {what} has unknown field(s) "
|
|
1472
|
+
f"{', '.join(repr(k[:MAX_FINDING_ID_CHARS]) for k in unknown)}; it carries "
|
|
1473
|
+
f"exactly {', '.join(repr(k) for k in allowed)}"
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
refuse_keys(raw, OPERATOR_ACCEPTANCE_KEYS, "operator_acceptance")
|
|
1477
|
+
actions = raw.get("actions")
|
|
1478
|
+
if not isinstance(actions, list) or not actions:
|
|
1479
|
+
raise ControlResultValidationError(
|
|
1480
|
+
f"{ph}: {fid} operator_acceptance must carry a non-empty 'actions' array"
|
|
1481
|
+
)
|
|
1482
|
+
if len(actions) > MAX_OPERATOR_ACTIONS_PER_FIX:
|
|
1483
|
+
raise ControlResultValidationError(
|
|
1484
|
+
f"{ph}: {fid} operator_acceptance carries {len(actions)} actions; the controller "
|
|
1485
|
+
f"accepts at most {MAX_OPERATOR_ACTIONS_PER_FIX} per FIX result. Re-emit the "
|
|
1486
|
+
"CONTROL_RESULT with fewer."
|
|
1487
|
+
)
|
|
1488
|
+
out: list[OperatorAction] = []
|
|
1489
|
+
for index, action in enumerate(actions):
|
|
1490
|
+
what = f"operator_acceptance actions[{index}]"
|
|
1491
|
+
if not isinstance(action, dict):
|
|
1492
|
+
raise ControlResultValidationError(
|
|
1493
|
+
f"{ph}: {fid} {what} must be an object with kind, issue and body"
|
|
1494
|
+
)
|
|
1495
|
+
refuse_keys(action, OPERATOR_ACTION_KEYS, what)
|
|
1496
|
+
kind = action.get("kind")
|
|
1497
|
+
if kind not in OPERATOR_ACTION_KINDS:
|
|
1498
|
+
shown = (
|
|
1499
|
+
repr(kind[:MAX_FINDING_ID_CHARS]) if isinstance(kind, str) else type(kind).__name__
|
|
1500
|
+
)
|
|
1501
|
+
raise ControlResultValidationError(
|
|
1502
|
+
f"{ph}: {fid} {what} kind {shown} is not supported; the one supported kind is "
|
|
1503
|
+
f"{OPERATOR_ACTION_KINDS[0]!r} (a comment the operator posts on another issue "
|
|
1504
|
+
"of the run's repository)"
|
|
1505
|
+
)
|
|
1506
|
+
issue = _operator_issue_number(action.get("issue"), fid, index)
|
|
1507
|
+
if any(a.issue == issue for a in out):
|
|
1508
|
+
raise ControlResultValidationError(
|
|
1509
|
+
f"{ph}: {fid} operator_acceptance names issue #{issue} twice; one finding "
|
|
1510
|
+
"requests at most one comment per issue"
|
|
1511
|
+
)
|
|
1512
|
+
body = validate_operator_comment_body(
|
|
1513
|
+
action.get("body"), f"operator_acceptance of {fid} for #{issue}"
|
|
1514
|
+
)
|
|
1515
|
+
out.append(OperatorAction(issue=issue, body=body))
|
|
1516
|
+
return tuple(out)
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
@dataclass
|
|
1520
|
+
class FindingResolution:
|
|
1521
|
+
"""One open finding's resolution, as a REMOTE fixer reports it (#163).
|
|
1522
|
+
|
|
1523
|
+
A ``follow_up_created`` resolution either names an issue the controller
|
|
1524
|
+
handed over (``follow_up_issue_url``) or carries the title and body of a
|
|
1525
|
+
new follow-up issue the controller creates (``follow_up_title``,
|
|
1526
|
+
``follow_up_body``; the payload's ``follow_up_issue`` object), never
|
|
1527
|
+
both. An ``acceptance_action`` resolution carries the body of the
|
|
1528
|
+
comment the controller publishes on the run's issue
|
|
1529
|
+
(``acceptance_body``; the payload's ``acceptance_action`` object, #211)
|
|
1530
|
+
and the rationale a ``no_change_with_rationale`` needs. An
|
|
1531
|
+
``operator_acceptance`` resolution carries the comments the operator
|
|
1532
|
+
posts on other issues (``operator_actions``; the payload's
|
|
1533
|
+
``operator_acceptance`` object, #243) and a rationale too. ``commit_sha``
|
|
1534
|
+
is allowed on ``fixed`` only. ``to_dict`` keeps the persisted shape of
|
|
1535
|
+
``state.last_fix_resolutions``.
|
|
1536
|
+
"""
|
|
1537
|
+
|
|
1538
|
+
finding_id: str
|
|
1539
|
+
resolution: str
|
|
1540
|
+
rationale: str = ""
|
|
1541
|
+
follow_up_issue_url: str = ""
|
|
1542
|
+
commit_sha: str = ""
|
|
1543
|
+
follow_up_title: str = ""
|
|
1544
|
+
follow_up_body: str = ""
|
|
1545
|
+
acceptance_body: str = ""
|
|
1546
|
+
operator_actions: tuple[OperatorAction, ...] = ()
|
|
1547
|
+
|
|
1548
|
+
@property
|
|
1549
|
+
def new_follow_up(self) -> bool:
|
|
1550
|
+
"""A follow-up issue the controller creates for this finding."""
|
|
1551
|
+
return bool(self.follow_up_title)
|
|
1552
|
+
|
|
1553
|
+
@property
|
|
1554
|
+
def acceptance_action(self) -> bool:
|
|
1555
|
+
"""An acceptance comment the controller publishes for this finding (#211)."""
|
|
1556
|
+
return self.resolution == "acceptance_action"
|
|
1557
|
+
|
|
1558
|
+
@property
|
|
1559
|
+
def operator_acceptance(self) -> bool:
|
|
1560
|
+
"""Comments the operator posts on other issues for this finding (#243)."""
|
|
1561
|
+
return self.resolution == "operator_acceptance"
|
|
1562
|
+
|
|
1563
|
+
@classmethod
|
|
1564
|
+
def from_payload(cls, raw: object, index: int) -> FindingResolution:
|
|
1565
|
+
ph = "FIX"
|
|
1566
|
+
if not isinstance(raw, dict):
|
|
1567
|
+
raise ControlResultValidationError(
|
|
1568
|
+
f"{ph}: resolutions[{index}] must be an object with finding_id/resolution"
|
|
1569
|
+
)
|
|
1570
|
+
fid = _finding_id(raw, "finding_id", ph)
|
|
1571
|
+
res = _req_str(raw, "resolution", ph)
|
|
1572
|
+
if res not in FIX_RESOLUTIONS:
|
|
1573
|
+
raise ControlResultValidationError(
|
|
1574
|
+
f"{ph}: resolution for {fid} must be one of {FIX_RESOLUTIONS}, got {res!r}"
|
|
1575
|
+
)
|
|
1576
|
+
subject = f"resolution for {fid}"
|
|
1577
|
+
rationale = _multi_line(
|
|
1578
|
+
_bounded(
|
|
1579
|
+
_opt_str(raw, "rationale", ph), ph, subject, "rationale", MAX_FIX_RATIONALE_CHARS
|
|
1580
|
+
),
|
|
1581
|
+
ph,
|
|
1582
|
+
subject,
|
|
1583
|
+
"rationale",
|
|
1584
|
+
)
|
|
1585
|
+
follow_up = _opt_url(raw, "follow_up_issue_url", ph, "issue")
|
|
1586
|
+
new_issue = raw.get("follow_up_issue")
|
|
1587
|
+
commit_sha = _opt_sha(raw, "commit_sha", ph)
|
|
1588
|
+
action = raw.get("acceptance_action")
|
|
1589
|
+
operator = raw.get("operator_acceptance")
|
|
1590
|
+
if res in ("no_change_with_rationale", "acceptance_action", "operator_acceptance"):
|
|
1591
|
+
if len(rationale) < MIN_RATIONALE_CHARS:
|
|
1592
|
+
raise ControlResultValidationError(
|
|
1593
|
+
f"{ph}: {fid} uses {res} but the rationale is missing "
|
|
1594
|
+
f"or too short (>= {MIN_RATIONALE_CHARS} chars of actual reasoning required)"
|
|
1595
|
+
)
|
|
1596
|
+
if res != "acceptance_action" and action is not None:
|
|
1597
|
+
raise ControlResultValidationError(
|
|
1598
|
+
f"{ph}: {fid} carries an acceptance_action but resolution is {res!r}"
|
|
1599
|
+
)
|
|
1600
|
+
if res != "operator_acceptance" and operator is not None:
|
|
1601
|
+
raise ControlResultValidationError(
|
|
1602
|
+
f"{ph}: {fid} carries an operator_acceptance but resolution is {res!r}"
|
|
1603
|
+
)
|
|
1604
|
+
if res != "follow_up_created":
|
|
1605
|
+
if follow_up or new_issue is not None:
|
|
1606
|
+
raise ControlResultValidationError(
|
|
1607
|
+
f"{ph}: {fid} carries a follow-up issue but resolution is {res!r}"
|
|
1608
|
+
)
|
|
1609
|
+
elif bool(follow_up) == (new_issue is not None):
|
|
1610
|
+
raise ControlResultValidationError(
|
|
1611
|
+
f"{ph}: {fid} uses follow_up_created and must carry exactly one of "
|
|
1612
|
+
"'follow_up_issue_url' (an issue the prompt lists) and 'follow_up_issue' "
|
|
1613
|
+
"(the title and body of a new issue the controller creates)"
|
|
1614
|
+
)
|
|
1615
|
+
if commit_sha and res != "fixed":
|
|
1616
|
+
raise ControlResultValidationError(
|
|
1617
|
+
f"{ph}: {fid} carries commit_sha but resolution is {res!r}; only a fixed "
|
|
1618
|
+
"finding names the commit that fixed it"
|
|
1619
|
+
)
|
|
1620
|
+
title = body = ""
|
|
1621
|
+
if new_issue is not None:
|
|
1622
|
+
if not isinstance(new_issue, dict):
|
|
1623
|
+
raise ControlResultValidationError(
|
|
1624
|
+
f"{ph}: {fid} field 'follow_up_issue' must be an object with title and body"
|
|
1625
|
+
)
|
|
1626
|
+
what = f"follow_up_issue of {fid}"
|
|
1627
|
+
title = validate_follow_up_title(new_issue.get("title"), what)
|
|
1628
|
+
body = validate_follow_up_body(new_issue.get("body"), what)
|
|
1629
|
+
acceptance = ""
|
|
1630
|
+
if res == "acceptance_action":
|
|
1631
|
+
if action is None:
|
|
1632
|
+
raise ControlResultValidationError(
|
|
1633
|
+
f"{ph}: {fid} uses acceptance_action and must carry an 'acceptance_action' "
|
|
1634
|
+
"object with kind and body"
|
|
1635
|
+
)
|
|
1636
|
+
acceptance = _acceptance_body(action, fid)
|
|
1637
|
+
operator_actions: tuple[OperatorAction, ...] = ()
|
|
1638
|
+
if res == "operator_acceptance":
|
|
1639
|
+
if operator is None:
|
|
1640
|
+
raise ControlResultValidationError(
|
|
1641
|
+
f"{ph}: {fid} uses operator_acceptance and must carry an "
|
|
1642
|
+
"'operator_acceptance' object with 'actions'"
|
|
1643
|
+
)
|
|
1644
|
+
operator_actions = _operator_actions(operator, fid)
|
|
1645
|
+
return cls(
|
|
1646
|
+
finding_id=fid,
|
|
1647
|
+
resolution=res,
|
|
1648
|
+
rationale=rationale,
|
|
1649
|
+
follow_up_issue_url=follow_up,
|
|
1650
|
+
commit_sha=commit_sha,
|
|
1651
|
+
follow_up_title=title,
|
|
1652
|
+
follow_up_body=body,
|
|
1653
|
+
acceptance_body=acceptance,
|
|
1654
|
+
operator_actions=operator_actions,
|
|
1655
|
+
)
|
|
1656
|
+
|
|
1657
|
+
def to_dict(self) -> dict:
|
|
1658
|
+
return {
|
|
1659
|
+
"finding_id": self.finding_id,
|
|
1660
|
+
"resolution": self.resolution,
|
|
1661
|
+
"rationale": self.rationale,
|
|
1662
|
+
"follow_up_issue_url": self.follow_up_issue_url,
|
|
1663
|
+
"commit_sha": self.commit_sha,
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
@dataclass
|
|
1668
|
+
class FixResult:
|
|
1669
|
+
"""What a REMOTE fixer reports; the controller publishes it (#163).
|
|
1670
|
+
|
|
1671
|
+
``previous_head_sha`` and ``head_sha`` are cross-checks against the
|
|
1672
|
+
reviewed HEAD and the worktree's ``HEAD``, which the controller reads
|
|
1673
|
+
itself; neither is a target. ``resolutions`` holds one resolution per
|
|
1674
|
+
open finding, ``tests`` names what the agent ran.
|
|
1675
|
+
"""
|
|
1676
|
+
|
|
1677
|
+
previous_head_sha: str
|
|
1678
|
+
head_sha: str
|
|
1679
|
+
resolutions: list[FindingResolution] = field(default_factory=list)
|
|
1680
|
+
tests: list[str] = field(default_factory=list)
|
|
1681
|
+
|
|
1682
|
+
@classmethod
|
|
1683
|
+
def from_payload(cls, p: dict) -> FixResult:
|
|
1684
|
+
ph = "FIX"
|
|
1685
|
+
previous = _req_sha(p, "previous_head_sha", ph)
|
|
1686
|
+
head = _req_sha(p, "head_sha", ph)
|
|
1687
|
+
resolutions = [
|
|
1688
|
+
FindingResolution.from_payload(r, i) for i, r in enumerate(_raw_resolutions(p))
|
|
1689
|
+
]
|
|
1690
|
+
ids = [r.finding_id for r in resolutions]
|
|
1691
|
+
if len(set(ids)) != len(ids):
|
|
1692
|
+
raise ControlResultValidationError(f"duplicate resolution finding_ids: {ids}")
|
|
1693
|
+
actions = [r.finding_id for r in resolutions if r.acceptance_action]
|
|
1694
|
+
if len(actions) > MAX_ACCEPTANCE_ACTIONS_PER_FIX:
|
|
1695
|
+
raise ControlResultValidationError(
|
|
1696
|
+
f"{ph}: {len(actions)} resolutions use acceptance_action "
|
|
1697
|
+
f"({', '.join(actions)}); the controller accepts at most "
|
|
1698
|
+
f"{MAX_ACCEPTANCE_ACTIONS_PER_FIX} per FIX result. Resolve the others another "
|
|
1699
|
+
"way and re-emit the CONTROL_RESULT."
|
|
1700
|
+
)
|
|
1701
|
+
operators = [r for r in resolutions if r.operator_acceptance]
|
|
1702
|
+
if actions and operators:
|
|
1703
|
+
raise ControlResultValidationError(
|
|
1704
|
+
f"{ph}: the result mixes acceptance_action ({', '.join(actions)}) and "
|
|
1705
|
+
f"operator_acceptance ({', '.join(r.finding_id for r in operators)}); one FIX "
|
|
1706
|
+
"result carries one or the other, never both. Resolve one of them another way "
|
|
1707
|
+
"and re-emit the CONTROL_RESULT."
|
|
1708
|
+
)
|
|
1709
|
+
requested = sum(len(r.operator_actions) for r in operators)
|
|
1710
|
+
if requested > MAX_OPERATOR_ACTIONS_PER_FIX:
|
|
1711
|
+
raise ControlResultValidationError(
|
|
1712
|
+
f"{ph}: the operator_acceptance resolutions request {requested} actions; the "
|
|
1713
|
+
f"controller accepts at most {MAX_OPERATOR_ACTIONS_PER_FIX} per FIX result. "
|
|
1714
|
+
"Re-emit the CONTROL_RESULT with fewer."
|
|
1715
|
+
)
|
|
1716
|
+
tests = _opt_str_list(p, "tests", ph)
|
|
1717
|
+
if len(tests) > MAX_TESTS_REPORTED:
|
|
1718
|
+
raise ControlResultValidationError(
|
|
1719
|
+
f"{ph}: field 'tests' lists {len(tests)} entries; the controller accepts at "
|
|
1720
|
+
f"most {MAX_TESTS_REPORTED}. Summarise them and re-emit the CONTROL_RESULT."
|
|
1721
|
+
)
|
|
1722
|
+
for test in tests:
|
|
1723
|
+
_one_line(_bounded(test, ph, "result", "tests", MAX_TEST_CHARS), ph, "result", "tests")
|
|
1724
|
+
return cls(previous_head_sha=previous, head_sha=head, resolutions=resolutions, tests=tests)
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
@dataclass
|
|
1728
|
+
class ReplanReexecuteResult:
|
|
1729
|
+
"""What a replan agent reports; the controller publishes it (#164).
|
|
1730
|
+
|
|
1731
|
+
``issue_url``, ``previous_pr_url``, ``previous_branch``,
|
|
1732
|
+
``previous_head_sha`` and ``execution_attempt`` are cross-checks against
|
|
1733
|
+
the transaction's checkpoint, and ``head_sha`` against the worktree's
|
|
1734
|
+
``HEAD``, which the controller reads itself; none of them is a target.
|
|
1735
|
+
The counts and ``verification.tests_passed`` become the transaction
|
|
1736
|
+
marker the controller renders. ``pr_title`` and ``pr_body`` are the
|
|
1737
|
+
agent's text for the replacement PR the controller creates, under the
|
|
1738
|
+
same bounds and content policy as ANALYZE_EXECUTE's: the controller
|
|
1739
|
+
appends ``Closes #n``, the implementation marker and the transaction
|
|
1740
|
+
marker itself. The result names no replacement PR, branch or push target.
|
|
1741
|
+
"""
|
|
1742
|
+
|
|
1743
|
+
issue_url: str
|
|
1744
|
+
previous_pr_url: str
|
|
1745
|
+
previous_branch: str
|
|
1746
|
+
previous_head_sha: str
|
|
1747
|
+
head_sha: str
|
|
1748
|
+
execution_attempt: int
|
|
1749
|
+
historical_findings_considered: int
|
|
1750
|
+
unique_failure_constraints: int
|
|
1751
|
+
fresh_review_round: int
|
|
1752
|
+
tests_run: list[str]
|
|
1753
|
+
tests_passed: bool
|
|
1754
|
+
pr_title: str
|
|
1755
|
+
pr_body: str
|
|
1756
|
+
|
|
1757
|
+
@classmethod
|
|
1758
|
+
def from_payload(cls, p: dict) -> ReplanReexecuteResult:
|
|
1759
|
+
ph = "REPLAN_REEXECUTE"
|
|
1760
|
+
previous_pr = _req_url(p, "previous_pr_url", ph, "pr")
|
|
1761
|
+
previous_branch = _req_str(p, "previous_branch", ph)
|
|
1762
|
+
ints: dict[str, int] = {}
|
|
1763
|
+
for key in (
|
|
1764
|
+
"execution_attempt",
|
|
1765
|
+
"historical_findings_considered",
|
|
1766
|
+
"unique_failure_constraints",
|
|
1767
|
+
"fresh_review_round",
|
|
1768
|
+
):
|
|
1769
|
+
value = _req(p, key, ph)
|
|
1770
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
1771
|
+
raise ControlResultValidationError(f"{ph}: field {key!r} must be an integer >= 0")
|
|
1772
|
+
ints[key] = value
|
|
1773
|
+
if ints["execution_attempt"] < 1:
|
|
1774
|
+
raise ControlResultValidationError("REPLAN_REEXECUTE: execution_attempt must be >= 1")
|
|
1775
|
+
if ints["fresh_review_round"] != 1:
|
|
1776
|
+
raise ControlResultValidationError("REPLAN_REEXECUTE: fresh_review_round must equal 1")
|
|
1777
|
+
if ints["unique_failure_constraints"] > ints["historical_findings_considered"]:
|
|
1778
|
+
raise ControlResultValidationError(
|
|
1779
|
+
"REPLAN_REEXECUTE: unique_failure_constraints cannot exceed "
|
|
1780
|
+
"historical_findings_considered"
|
|
1781
|
+
)
|
|
1782
|
+
if _req_str(p, "previous_pr_disposition", ph) != "superseded":
|
|
1783
|
+
raise ControlResultValidationError(
|
|
1784
|
+
"REPLAN_REEXECUTE: previous_pr_disposition must be 'superseded'"
|
|
1785
|
+
)
|
|
1786
|
+
verification = _req(p, "verification", ph)
|
|
1787
|
+
if not isinstance(verification, dict):
|
|
1788
|
+
raise ControlResultValidationError("REPLAN_REEXECUTE: verification must be an object")
|
|
1789
|
+
tests_run = verification.get("tests_run")
|
|
1790
|
+
tests_passed = verification.get("tests_passed")
|
|
1791
|
+
if not isinstance(tests_run, list) or not all(isinstance(test, str) for test in tests_run):
|
|
1792
|
+
raise ControlResultValidationError(
|
|
1793
|
+
"REPLAN_REEXECUTE: verification.tests_run must be a list"
|
|
1794
|
+
)
|
|
1795
|
+
if not isinstance(tests_passed, bool):
|
|
1796
|
+
raise ControlResultValidationError(
|
|
1797
|
+
"REPLAN_REEXECUTE: verification.tests_passed must be a boolean"
|
|
1798
|
+
)
|
|
1799
|
+
return cls(
|
|
1800
|
+
issue_url=_req_url(p, "issue_url", ph, "issue"),
|
|
1801
|
+
previous_pr_url=previous_pr,
|
|
1802
|
+
previous_branch=previous_branch,
|
|
1803
|
+
previous_head_sha=_req_sha(p, "previous_head_sha", ph),
|
|
1804
|
+
head_sha=_req_sha(p, "head_sha", ph),
|
|
1805
|
+
execution_attempt=ints["execution_attempt"],
|
|
1806
|
+
historical_findings_considered=ints["historical_findings_considered"],
|
|
1807
|
+
unique_failure_constraints=ints["unique_failure_constraints"],
|
|
1808
|
+
fresh_review_round=1,
|
|
1809
|
+
tests_run=tests_run,
|
|
1810
|
+
tests_passed=tests_passed,
|
|
1811
|
+
pr_title=validate_pr_title(_req_str(p, "pr_title", ph), phase=ph),
|
|
1812
|
+
pr_body=validate_pr_body(_req_str(p, "pr_body", ph), phase=ph),
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
# Phases that never produce a CONTROL_RESULT: INITIALIZING and
|
|
1817
|
+
# READY_FOR_MERGE are deterministic, and MERGE is executed by the controller
|
|
1818
|
+
# itself (gh pr merge), never by an agent.
|
|
1819
|
+
NON_AGENT_PHASES = frozenset(
|
|
1820
|
+
{
|
|
1821
|
+
Phase.INITIALIZING,
|
|
1822
|
+
Phase.READY_FOR_MERGE,
|
|
1823
|
+
Phase.MERGE,
|
|
1824
|
+
Phase.DONE,
|
|
1825
|
+
Phase.BLOCKED,
|
|
1826
|
+
Phase.FAILED,
|
|
1827
|
+
}
|
|
1828
|
+
)
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
# The keys every re-request accepts besides the fields it asks for (D4.7).
|
|
1832
|
+
_RE_REQUEST_ENVELOPE = ("phase", "status", "message")
|
|
1833
|
+
_RE_REQUEST_FIELDS: dict[UpdateEpicRequest, tuple[str, ...]] = {
|
|
1834
|
+
UpdateEpicRequest.SELECTION: ("next_issue_url",),
|
|
1835
|
+
UpdateEpicRequest.SELECTION_WITH_ROADMAP: ("next_issue_url", "roadmap_section"),
|
|
1836
|
+
UpdateEpicRequest.ROADMAP: ("roadmap_section",),
|
|
1837
|
+
}
|
|
1838
|
+
# The UPDATE_EPIC field names a refusal may quote: they are the controller's
|
|
1839
|
+
# own. Any other key is agent text and is only counted.
|
|
1840
|
+
_UPDATE_EPIC_FIELDS = ("progress", "roadmap_section", "next_issue_url")
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
def validate_progress_text(text: str) -> str:
|
|
1844
|
+
"""``text`` as an UPDATE_EPIC ``progress`` field, or a rejection.
|
|
1845
|
+
|
|
1846
|
+
Non-blank, at most :data:`MAX_PROGRESS_CHARS`, multi-line text with no
|
|
1847
|
+
other control character, publishable (:func:`published_text_problem`),
|
|
1848
|
+
and with no URL (:data:`_PROGRESS_URL_RE`): the controller posts it as
|
|
1849
|
+
the EPIC progress comment. The checks
|
|
1850
|
+
:meth:`UpdateEpicResult.from_payload` applies, with the same messages,
|
|
1851
|
+
so a persisted value can be re-validated under the parser's rules.
|
|
1852
|
+
"""
|
|
1853
|
+
if not isinstance(text, str):
|
|
1854
|
+
raise ControlResultValidationError("UPDATE_EPIC: field 'progress' must be a string")
|
|
1855
|
+
if not text.strip():
|
|
1856
|
+
raise ControlResultValidationError(
|
|
1857
|
+
"CONTROL_RESULT for UPDATE_EPIC missing required field 'progress'"
|
|
1858
|
+
)
|
|
1859
|
+
text = _bounded(text, "UPDATE_EPIC", "result", "progress", MAX_PROGRESS_CHARS)
|
|
1860
|
+
text = _multi_line(text, "UPDATE_EPIC", "result", "progress")
|
|
1861
|
+
text = _published_field(text, "progress", "UPDATE_EPIC")
|
|
1862
|
+
m = _PROGRESS_URL_RE.search(text)
|
|
1863
|
+
if m is not None:
|
|
1864
|
+
raise ControlResultValidationError(
|
|
1865
|
+
f"UPDATE_EPIC: field 'progress' contains a URL at index {m.start()} ('://' or "
|
|
1866
|
+
"'www.'), even inside code; the progress comment carries no URL, and the "
|
|
1867
|
+
"controller's marker names the issue and PR. Refer to them as '#n' instead, and "
|
|
1868
|
+
"re-emit the CONTROL_RESULT."
|
|
1869
|
+
)
|
|
1870
|
+
return text
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def validate_roadmap_section(text: str) -> str:
|
|
1874
|
+
"""``text`` as a present UPDATE_EPIC ``roadmap_section``, or a rejection.
|
|
1875
|
+
|
|
1876
|
+
Non-blank, at most :data:`MAX_ROADMAP_SECTION_CHARS`, multi-line text,
|
|
1877
|
+
no ``<!-- ai-`` marker (the controller writes the roadmap markers around
|
|
1878
|
+
the section itself), and publishable (:func:`published_text_problem`).
|
|
1879
|
+
The checks :meth:`UpdateEpicResult.from_payload` applies, with the same
|
|
1880
|
+
messages.
|
|
1881
|
+
"""
|
|
1882
|
+
if not isinstance(text, str):
|
|
1883
|
+
raise ControlResultValidationError(
|
|
1884
|
+
"UPDATE_EPIC: optional field 'roadmap_section' must be a string when present, "
|
|
1885
|
+
f"got {type(text).__name__}"
|
|
1886
|
+
)
|
|
1887
|
+
if not text.strip():
|
|
1888
|
+
raise ControlResultValidationError(
|
|
1889
|
+
"CONTROL_RESULT for UPDATE_EPIC missing required field 'roadmap_section'"
|
|
1890
|
+
)
|
|
1891
|
+
text = _bounded(text, "UPDATE_EPIC", "result", "roadmap_section", MAX_ROADMAP_SECTION_CHARS)
|
|
1892
|
+
text = _multi_line(text, "UPDATE_EPIC", "result", "roadmap_section")
|
|
1893
|
+
if CONTROLLER_MARKER_OPEN_RE.search(text):
|
|
1894
|
+
raise ControlResultValidationError(
|
|
1895
|
+
"UPDATE_EPIC: field 'roadmap_section' must not contain a controller "
|
|
1896
|
+
f"marker ({_CONTROLLER_MARKER_PREFIX}...): the controller writes "
|
|
1897
|
+
f"{ROADMAP_START_MARKER!r} and {ROADMAP_END_MARKER!r} around the section "
|
|
1898
|
+
"itself. Return only the section's content and re-emit the CONTROL_RESULT."
|
|
1899
|
+
)
|
|
1900
|
+
return _published_field(text, "roadmap_section", "UPDATE_EPIC")
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
def validate_next_issue_url(url: str) -> str:
|
|
1904
|
+
"""``url`` as a present UPDATE_EPIC ``next_issue_url``, or a rejection.
|
|
1905
|
+
|
|
1906
|
+
Shape and length at parse time (the ``next_issue_url`` half of #15).
|
|
1907
|
+
Which issue the URL names is the engine's to verify (repository, EPIC,
|
|
1908
|
+
finished issue, exists, OPEN); whether the string is an issue URL at all
|
|
1909
|
+
is a malformed result, corrected like any other rather than spent as a
|
|
1910
|
+
selection, and an oversized value is never quoted into
|
|
1911
|
+
``next_issue_rejections``, the re-selection prompt or the run log.
|
|
1912
|
+
"""
|
|
1913
|
+
if not isinstance(url, str):
|
|
1914
|
+
raise ControlResultValidationError("'next_issue_url' must be a string or null")
|
|
1915
|
+
return _checked_url(url, "next_issue_url", "UPDATE_EPIC", "issue")
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def _refuse_outside_re_request(p: dict, request: UpdateEpicRequest) -> None:
|
|
1919
|
+
"""Refuse a re-request result carrying a key outside its schema (D4.7)."""
|
|
1920
|
+
asked = _RE_REQUEST_FIELDS[request]
|
|
1921
|
+
allowed = (*_RE_REQUEST_ENVELOPE, *asked)
|
|
1922
|
+
extra = [key for key in p if key not in allowed]
|
|
1923
|
+
if not extra:
|
|
1924
|
+
return
|
|
1925
|
+
named = [key for key in _UPDATE_EPIC_FIELDS if key in extra]
|
|
1926
|
+
others = len(extra) - len(named)
|
|
1927
|
+
carried = [repr(key) for key in named]
|
|
1928
|
+
if others:
|
|
1929
|
+
carried.append(f"{others} other key{'s' if others > 1 else ''}")
|
|
1930
|
+
accepted = ", ".join(repr(key) for key in allowed)
|
|
1931
|
+
asked_for = " and ".join(repr(key) for key in asked)
|
|
1932
|
+
raise ControlResultValidationError(
|
|
1933
|
+
f"UPDATE_EPIC: this re-request accepts only {accepted}, and the result also "
|
|
1934
|
+
f"carries {', '.join(carried)}. The published progress comment is already done and "
|
|
1935
|
+
f"this re-request asks only for {asked_for}. Remove the other keys and re-emit the "
|
|
1936
|
+
"CONTROL_RESULT."
|
|
1937
|
+
)
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
def _next_issue_url(p: dict) -> str | None:
|
|
1941
|
+
"""The ``next_issue_url`` key, required; ``null`` or ``""`` is ``None``."""
|
|
1942
|
+
if "next_issue_url" not in p:
|
|
1943
|
+
raise ControlResultValidationError(
|
|
1944
|
+
"CONTROL_RESULT for UPDATE_EPIC missing required field "
|
|
1945
|
+
"'next_issue_url' (use null when the epic is complete)"
|
|
1946
|
+
)
|
|
1947
|
+
nxt = p["next_issue_url"]
|
|
1948
|
+
if nxt is None or nxt == "":
|
|
1949
|
+
return None
|
|
1950
|
+
return validate_next_issue_url(nxt)
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
@dataclass
|
|
1954
|
+
class UpdateEpicResult:
|
|
1955
|
+
next_issue_url: str | None
|
|
1956
|
+
# The new content of the EPIC's managed roadmap section, or ``None`` when
|
|
1957
|
+
# the agent returned none (absent, ``null`` or blank). Whether one is
|
|
1958
|
+
# required is the engine's decision (``workflow.epic_update_every``);
|
|
1959
|
+
# the parser only bounds and shapes it. It is the *content between* the
|
|
1960
|
+
# markers: the markers themselves are written by the controller.
|
|
1961
|
+
roadmap_section: str | None = None
|
|
1962
|
+
# The EPIC progress comment's text: required in a ``FULL`` result,
|
|
1963
|
+
# ``None`` in a re-request, which never carries progress (D4.7).
|
|
1964
|
+
progress: str | None = None
|
|
1965
|
+
|
|
1966
|
+
@classmethod
|
|
1967
|
+
def from_payload(
|
|
1968
|
+
cls, p: dict, request: UpdateEpicRequest = UpdateEpicRequest.FULL
|
|
1969
|
+
) -> UpdateEpicResult:
|
|
1970
|
+
"""Validate ``p`` against the schema of ``request``.
|
|
1971
|
+
|
|
1972
|
+
``FULL`` ignores unknown keys, as every phase schema does; a
|
|
1973
|
+
re-request refuses any key outside its own schema, a ``null`` one
|
|
1974
|
+
included.
|
|
1975
|
+
"""
|
|
1976
|
+
request = UpdateEpicRequest(request)
|
|
1977
|
+
if request != UpdateEpicRequest.FULL:
|
|
1978
|
+
_refuse_outside_re_request(p, request)
|
|
1979
|
+
section: str | None = _opt_str(p, "roadmap_section", "UPDATE_EPIC") or None
|
|
1980
|
+
if section is not None:
|
|
1981
|
+
section = validate_roadmap_section(section)
|
|
1982
|
+
elif request == UpdateEpicRequest.ROADMAP:
|
|
1983
|
+
raise ControlResultValidationError(
|
|
1984
|
+
"CONTROL_RESULT for UPDATE_EPIC missing required field 'roadmap_section' "
|
|
1985
|
+
"(this re-request asks only for the roadmap section)"
|
|
1986
|
+
)
|
|
1987
|
+
nxt = None if request == UpdateEpicRequest.ROADMAP else _next_issue_url(p)
|
|
1988
|
+
progress = None
|
|
1989
|
+
if request == UpdateEpicRequest.FULL:
|
|
1990
|
+
progress = validate_progress_text(_req_str(p, "progress", "UPDATE_EPIC"))
|
|
1991
|
+
return cls(next_issue_url=nxt, roadmap_section=section, progress=progress)
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
# -- LOCAL-mode typed models ----------------------------------------------
|
|
1995
|
+
# A local phase has no PR, no comment and no follow-up Issue, so it gets its
|
|
1996
|
+
# own small shapes instead of remote fields stuffed with empty strings or
|
|
1997
|
+
# fabricated URLs. Everything the controller can observe itself (HEAD, the
|
|
1998
|
+
# changed-file list, the fingerprint) is observed, not reported; the agent
|
|
1999
|
+
# reports only what it alone knows, plus the one or two claims worth
|
|
2000
|
+
# cross-checking against the controller's own observation.
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _opt_str_list(payload: dict, key: str, phase: str) -> list[str]:
|
|
2004
|
+
raw = payload.get(key, [])
|
|
2005
|
+
if raw is None:
|
|
2006
|
+
return []
|
|
2007
|
+
if not isinstance(raw, list) or not all(isinstance(x, str) for x in raw):
|
|
2008
|
+
raise ControlResultValidationError(f"{phase}: field {key!r} must be a list of strings")
|
|
2009
|
+
return [x.strip() for x in raw if x.strip()]
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
def _req_fingerprint(payload: dict, key: str, phase: str) -> str:
|
|
2013
|
+
v = _req_str(payload, key, phase)
|
|
2014
|
+
if not _FINGERPRINT_RE.match(v.lower()):
|
|
2015
|
+
raise ControlResultValidationError(
|
|
2016
|
+
f"{phase}: field {key!r} must be the 64-character workspace fingerprint the "
|
|
2017
|
+
f"controller provided, got {v!r}"
|
|
2018
|
+
)
|
|
2019
|
+
return v.lower()
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
@dataclass
|
|
2023
|
+
class LocalAnalyzeExecuteResult:
|
|
2024
|
+
"""What an implementation agent alone knows about a local run."""
|
|
2025
|
+
|
|
2026
|
+
summary: str
|
|
2027
|
+
changed_workspace: bool
|
|
2028
|
+
tests_attempted: list[str] = field(default_factory=list)
|
|
2029
|
+
|
|
2030
|
+
@classmethod
|
|
2031
|
+
def from_payload(cls, p: dict) -> LocalAnalyzeExecuteResult:
|
|
2032
|
+
ph = "ANALYZE_EXECUTE"
|
|
2033
|
+
return cls(
|
|
2034
|
+
summary=_req_str(p, "summary", ph),
|
|
2035
|
+
# Cross-checked against the controller's own before/after
|
|
2036
|
+
# fingerprint: a claim of "I changed nothing" over a modified
|
|
2037
|
+
# tree (or the reverse) is a rejected result, not a nuance.
|
|
2038
|
+
changed_workspace=_req_bool(p, "changed_workspace", ph),
|
|
2039
|
+
tests_attempted=_opt_str_list(p, "tests_attempted", ph),
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
@dataclass
|
|
2044
|
+
class LocalReviewResult:
|
|
2045
|
+
round: int
|
|
2046
|
+
reviewed_workspace_fingerprint: str
|
|
2047
|
+
needs_fix_round: bool
|
|
2048
|
+
findings: list[Finding] = field(default_factory=list)
|
|
2049
|
+
observations: list[str] = field(default_factory=list)
|
|
2050
|
+
|
|
2051
|
+
@classmethod
|
|
2052
|
+
def from_payload(cls, p: dict) -> LocalReviewResult:
|
|
2053
|
+
ph = "REVIEW"
|
|
2054
|
+
rnd = _req(p, "round", ph)
|
|
2055
|
+
if not isinstance(rnd, int) or isinstance(rnd, bool) or rnd < 1:
|
|
2056
|
+
raise ControlResultValidationError("'round' must be an int >= 1")
|
|
2057
|
+
needs_fix = _req_bool(p, "needs_fix_round", ph)
|
|
2058
|
+
findings = _parse_findings(p, rnd, needs_fix)
|
|
2059
|
+
return cls(
|
|
2060
|
+
round=rnd,
|
|
2061
|
+
# Binds the review to exactly the workspace the controller
|
|
2062
|
+
# fingerprinted before invoking the reviewer — the local
|
|
2063
|
+
# analogue of binding a remote review to the PR HEAD SHA.
|
|
2064
|
+
reviewed_workspace_fingerprint=_req_fingerprint(
|
|
2065
|
+
p, "reviewed_workspace_fingerprint", ph
|
|
2066
|
+
),
|
|
2067
|
+
needs_fix_round=needs_fix,
|
|
2068
|
+
findings=findings,
|
|
2069
|
+
observations=_opt_str_list(p, "observations", ph),
|
|
2070
|
+
)
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
@dataclass
|
|
2074
|
+
class LocalFindingResolution:
|
|
2075
|
+
finding_id: str
|
|
2076
|
+
resolution: str
|
|
2077
|
+
rationale: str = ""
|
|
2078
|
+
|
|
2079
|
+
@classmethod
|
|
2080
|
+
def from_payload(cls, raw: object, index: int) -> LocalFindingResolution:
|
|
2081
|
+
ph = "FIX"
|
|
2082
|
+
if not isinstance(raw, dict):
|
|
2083
|
+
raise ControlResultValidationError(
|
|
2084
|
+
f"{ph}: resolutions[{index}] must be an object with finding_id/resolution"
|
|
2085
|
+
)
|
|
2086
|
+
fid = _finding_id(raw, "finding_id", ph)
|
|
2087
|
+
res = _req_str(raw, "resolution", ph)
|
|
2088
|
+
if res not in LOCAL_FIX_RESOLUTIONS:
|
|
2089
|
+
raise ControlResultValidationError(
|
|
2090
|
+
f"{ph}: resolution for {fid} must be one of {LOCAL_FIX_RESOLUTIONS}, got {res!r}"
|
|
2091
|
+
+ (
|
|
2092
|
+
" — local runs have no GitHub, so a finding cannot be deferred to a "
|
|
2093
|
+
"follow-up Issue; use 'unresolved' with a rationale instead"
|
|
2094
|
+
if res == "follow_up_created"
|
|
2095
|
+
else " — LOCAL runs perform no external writes; resolve as `unresolved` "
|
|
2096
|
+
"with a rationale instead"
|
|
2097
|
+
if res == "acceptance_action"
|
|
2098
|
+
else " — LOCAL runs have no GitHub and no operator actions; resolve as "
|
|
2099
|
+
"`unresolved` with a rationale instead"
|
|
2100
|
+
if res == "operator_acceptance"
|
|
2101
|
+
else ""
|
|
2102
|
+
)
|
|
2103
|
+
)
|
|
2104
|
+
subject = f"resolution for {fid}"
|
|
2105
|
+
rationale = _multi_line(
|
|
2106
|
+
_bounded(
|
|
2107
|
+
_opt_str(raw, "rationale", ph), ph, subject, "rationale", MAX_FIX_RATIONALE_CHARS
|
|
2108
|
+
),
|
|
2109
|
+
ph,
|
|
2110
|
+
subject,
|
|
2111
|
+
"rationale",
|
|
2112
|
+
)
|
|
2113
|
+
# Both non-fix dispositions are only acceptable with real reasoning:
|
|
2114
|
+
# "won't fix" and "couldn't fix" are decisions a human has to judge.
|
|
2115
|
+
if res in ("no_change_with_rationale", "unresolved") and len(rationale) < (
|
|
2116
|
+
MIN_RATIONALE_CHARS
|
|
2117
|
+
):
|
|
2118
|
+
raise ControlResultValidationError(
|
|
2119
|
+
f"{ph}: {fid} uses {res} but the rationale is missing or too short "
|
|
2120
|
+
f"(>= {MIN_RATIONALE_CHARS} chars of actual reasoning required)"
|
|
2121
|
+
)
|
|
2122
|
+
if "follow_up_issue_url" in raw:
|
|
2123
|
+
raise ControlResultValidationError(
|
|
2124
|
+
f"{ph}: {fid} carries 'follow_up_issue_url'; local runs never create "
|
|
2125
|
+
"GitHub follow-up issues"
|
|
2126
|
+
)
|
|
2127
|
+
return cls(finding_id=fid, resolution=res, rationale=rationale)
|
|
2128
|
+
|
|
2129
|
+
def to_dict(self) -> dict:
|
|
2130
|
+
return {
|
|
2131
|
+
"finding_id": self.finding_id,
|
|
2132
|
+
"resolution": self.resolution,
|
|
2133
|
+
"rationale": self.rationale,
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
@property
|
|
2137
|
+
def is_resolved(self) -> bool:
|
|
2138
|
+
return self.resolution in ("fixed", "no_change_with_rationale")
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
@dataclass
|
|
2142
|
+
class LocalFixResult:
|
|
2143
|
+
resolutions: list[LocalFindingResolution] = field(default_factory=list)
|
|
2144
|
+
changed_workspace: bool = False
|
|
2145
|
+
|
|
2146
|
+
@classmethod
|
|
2147
|
+
def from_payload(cls, p: dict) -> LocalFixResult:
|
|
2148
|
+
ph = "FIX"
|
|
2149
|
+
resolutions = [
|
|
2150
|
+
LocalFindingResolution.from_payload(r, i) for i, r in enumerate(_raw_resolutions(p))
|
|
2151
|
+
]
|
|
2152
|
+
ids = [r.finding_id for r in resolutions]
|
|
2153
|
+
if len(set(ids)) != len(ids):
|
|
2154
|
+
raise ControlResultValidationError(f"duplicate resolution finding_ids: {ids}")
|
|
2155
|
+
# `from_payload` only runs for status "success" (see
|
|
2156
|
+
# `parse_control_result`), so a non-empty run-level blocker here is a
|
|
2157
|
+
# result that contradicts itself: the protocol already carries one,
|
|
2158
|
+
# as status "blocked" plus a message, and the engine routes that to
|
|
2159
|
+
# BLOCKED. Accepting it alongside "success" gave the field nowhere to
|
|
2160
|
+
# go -- it was parsed and dropped, so a FIX could report a blocker,
|
|
2161
|
+
# pass validation, be reviewed clean and reach DONE with the blocker
|
|
2162
|
+
# never seen by anyone. Rejecting it puts the claim back on the one
|
|
2163
|
+
# channel the controller acts on rather than quietly keeping both.
|
|
2164
|
+
blocker = p.get("blocked_reason")
|
|
2165
|
+
if isinstance(blocker, str) and blocker.strip():
|
|
2166
|
+
raise ControlResultValidationError(
|
|
2167
|
+
f"{ph}: status 'success' carries a non-empty 'blocked_reason' "
|
|
2168
|
+
f"({blocker.strip()[:200]!r}); a run-level blocker and a successful fix are "
|
|
2169
|
+
"not both true. Report the obstacle as status 'blocked' with a 'message', or "
|
|
2170
|
+
"report the finding it concerns with resolution 'unresolved' and a rationale"
|
|
2171
|
+
)
|
|
2172
|
+
if blocker is not None and not isinstance(blocker, str):
|
|
2173
|
+
raise ControlResultValidationError(f"{ph}: field 'blocked_reason' must be a string")
|
|
2174
|
+
return cls(
|
|
2175
|
+
resolutions=resolutions,
|
|
2176
|
+
changed_workspace=_req_bool(p, "changed_workspace", ph),
|
|
2177
|
+
)
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
# Phases a LOCAL run can never reach; they belong to the GitHub lifecycle.
|
|
2181
|
+
LOCAL_UNSUPPORTED_PHASES = frozenset(
|
|
2182
|
+
{Phase.REPLAN_REEXECUTE, Phase.READY_FOR_MERGE, Phase.MERGE, Phase.UPDATE_EPIC}
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
def _validate_local_phase(phase: Phase, payload: dict) -> None:
|
|
2187
|
+
if phase == Phase.ANALYZE_EXECUTE:
|
|
2188
|
+
LocalAnalyzeExecuteResult.from_payload(payload)
|
|
2189
|
+
elif phase == Phase.REVIEW:
|
|
2190
|
+
LocalReviewResult.from_payload(payload)
|
|
2191
|
+
elif phase == Phase.FIX:
|
|
2192
|
+
LocalFixResult.from_payload(payload)
|
|
2193
|
+
elif phase in LOCAL_UNSUPPORTED_PHASES:
|
|
2194
|
+
raise ControlResultValidationError(
|
|
2195
|
+
f"phase {phase.value} belongs to the REMOTE (GitHub) workflow and is never "
|
|
2196
|
+
"executed by a local run"
|
|
2197
|
+
)
|
|
2198
|
+
else:
|
|
2199
|
+
raise ControlResultValidationError(
|
|
2200
|
+
f"phase {phase.value} is executed by the controller and never accepts an "
|
|
2201
|
+
"agent CONTROL_RESULT"
|
|
2202
|
+
)
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def validate_for_phase(
|
|
2206
|
+
phase: Phase,
|
|
2207
|
+
payload: dict,
|
|
2208
|
+
mode: WorkflowMode = WorkflowMode.REMOTE,
|
|
2209
|
+
*,
|
|
2210
|
+
update_epic_request: UpdateEpicRequest = UpdateEpicRequest.FULL,
|
|
2211
|
+
) -> None:
|
|
2212
|
+
"""Enforce the per-phase required-fields schema (raises on violation).
|
|
2213
|
+
|
|
2214
|
+
``update_epic_request`` selects the UPDATE_EPIC schema; other phases
|
|
2215
|
+
ignore it.
|
|
2216
|
+
"""
|
|
2217
|
+
if mode == WorkflowMode.LOCAL:
|
|
2218
|
+
_validate_local_phase(phase, payload)
|
|
2219
|
+
return
|
|
2220
|
+
if phase == Phase.ANALYZE_EXECUTE:
|
|
2221
|
+
AnalyzeExecuteResult.from_payload(payload)
|
|
2222
|
+
elif phase == Phase.REVIEW:
|
|
2223
|
+
ReviewResult.from_payload(payload)
|
|
2224
|
+
elif phase == Phase.FIX:
|
|
2225
|
+
FixResult.from_payload(payload)
|
|
2226
|
+
elif phase == Phase.REPLAN_REEXECUTE:
|
|
2227
|
+
ReplanReexecuteResult.from_payload(payload)
|
|
2228
|
+
elif phase == Phase.UPDATE_EPIC:
|
|
2229
|
+
UpdateEpicResult.from_payload(payload, update_epic_request)
|
|
2230
|
+
elif phase in NON_AGENT_PHASES:
|
|
2231
|
+
raise ControlResultValidationError(
|
|
2232
|
+
f"phase {phase.value} is executed by the controller and never accepts an "
|
|
2233
|
+
"agent CONTROL_RESULT"
|
|
2234
|
+
)
|