autoforge-cli 0.1.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/cli.py
ADDED
|
@@ -0,0 +1,2884 @@
|
|
|
1
|
+
"""AutoForge command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
doctor read-only environment checks (git, gh, auth, claude, opencode, repo, config,
|
|
5
|
+
default-branch required checks)
|
|
6
|
+
run create a run (Issue -> ...) and advance it until READY_FOR_MERGE/DONE/BLOCKED/FAILED
|
|
7
|
+
step execute exactly one phase step from persisted state
|
|
8
|
+
resume continue a persisted run until a stop phase / max-steps; step, resume and
|
|
9
|
+
unblock refuse a REMOTE run whose recorded configuration (profile
|
|
10
|
+
routing, agent worktree root) this invocation's differs from, and any
|
|
11
|
+
run whose pinned sandbox.mode this invocation's is weaker than, unless
|
|
12
|
+
--accept-config-change (#219, #280). Under sandbox.mode required or
|
|
13
|
+
preferred every command refuses a --config file inside a tree the
|
|
14
|
+
repository's sandboxed launches write, or a directory a provider's
|
|
15
|
+
launches are given read-write (#280)
|
|
16
|
+
unblock the operator's explicit exit from BLOCKED: the controller re-inspects
|
|
17
|
+
live GitHub state, re-enters a phase through validate_transition or
|
|
18
|
+
refuses and stays BLOCKED; the reason is recorded in state and the log.
|
|
19
|
+
--approve-action / --decline-action decide on an acceptance action
|
|
20
|
+
a FIX plan awaits (#211); --bind-evidence binds an existing comment
|
|
21
|
+
to an operator action and --decline-action (repeatable) declines
|
|
22
|
+
operator actions (#243)
|
|
23
|
+
status show the persisted run summary (--json for machine output; --action
|
|
24
|
+
prints an acceptance or operator action's exact payload)
|
|
25
|
+
local LOCAL mode: feature Markdown -> implement -> review -> fix -> DONE,
|
|
26
|
+
with no GitHub involved at any point (init / run / doctor)
|
|
27
|
+
epic the parallel EPIC scheduler (ADR 0008, #183), REMOTE only: run (with
|
|
28
|
+
--dry-run and --force), resume, status (lock-free, --json), stop
|
|
29
|
+
(lock-free, --now) and archive; every 'epic' command refuses
|
|
30
|
+
--state-dir, since each issue's state lives in its scheduler namespace,
|
|
31
|
+
and an EPIC of another repository than the checkout's 'origin'.
|
|
32
|
+
'epic _worker' is the scheduler's hidden worker entry point
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import io
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import select
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
import unicodedata
|
|
46
|
+
from collections.abc import Callable, Iterator
|
|
47
|
+
from contextlib import contextmanager
|
|
48
|
+
from typing import TextIO
|
|
49
|
+
|
|
50
|
+
from . import __version__
|
|
51
|
+
from .certificate import NOT_A_PERMISSION
|
|
52
|
+
from .certificate import describe as describe_certificate
|
|
53
|
+
from .config import MAX_PARALLEL_BOUNDS, AutoForgeConfig, load_config_file
|
|
54
|
+
from .config_fingerprint import RecordedConfiguration
|
|
55
|
+
from .doctor import CheckResult, run_doctor, run_local_doctor
|
|
56
|
+
from .effects import (
|
|
57
|
+
AUTHORIZATION_POLICY,
|
|
58
|
+
MAX_EFFECT_ATTEMPTS,
|
|
59
|
+
EffectKind,
|
|
60
|
+
EffectRecord,
|
|
61
|
+
Stage,
|
|
62
|
+
sha256_text,
|
|
63
|
+
)
|
|
64
|
+
from .engine import (
|
|
65
|
+
ControllerEngine,
|
|
66
|
+
StepOutcome,
|
|
67
|
+
StepPlan,
|
|
68
|
+
UnblockOutcome,
|
|
69
|
+
config_file_placement_problem,
|
|
70
|
+
sandbox_mode_summary,
|
|
71
|
+
)
|
|
72
|
+
from .epic_commands import (
|
|
73
|
+
DRAIN_HELP,
|
|
74
|
+
EXIT_OPEN_CLAIMS,
|
|
75
|
+
archive_issue,
|
|
76
|
+
checkout_epic,
|
|
77
|
+
claims_left_open,
|
|
78
|
+
collect_status,
|
|
79
|
+
coordination_root_of,
|
|
80
|
+
dry_run_plan,
|
|
81
|
+
epic_run_guard,
|
|
82
|
+
left_open_lines,
|
|
83
|
+
stop_scheduler,
|
|
84
|
+
)
|
|
85
|
+
from .epic_graph import GraphReader
|
|
86
|
+
from .errors import (
|
|
87
|
+
AutoForgeError,
|
|
88
|
+
ConfigurationError,
|
|
89
|
+
InteractionDataError,
|
|
90
|
+
LockError,
|
|
91
|
+
StateError,
|
|
92
|
+
StateTransitionError,
|
|
93
|
+
)
|
|
94
|
+
from .evidence import (
|
|
95
|
+
MAX_FAILURE_COUNT,
|
|
96
|
+
LocalCandidate,
|
|
97
|
+
Outcome,
|
|
98
|
+
RemoteCandidate,
|
|
99
|
+
parse_marker,
|
|
100
|
+
record_summary,
|
|
101
|
+
)
|
|
102
|
+
from .github import GitHubClient
|
|
103
|
+
from .interaction import (
|
|
104
|
+
Allow,
|
|
105
|
+
AllowScope,
|
|
106
|
+
Answer,
|
|
107
|
+
Cancel,
|
|
108
|
+
DecisionKind,
|
|
109
|
+
Deny,
|
|
110
|
+
InteractionDecision,
|
|
111
|
+
InteractionRequest,
|
|
112
|
+
Question,
|
|
113
|
+
QuestionResponse,
|
|
114
|
+
RequestKind,
|
|
115
|
+
Unavailable,
|
|
116
|
+
UnavailableReason,
|
|
117
|
+
)
|
|
118
|
+
from .local_workspace import init_feature_file
|
|
119
|
+
from .locking import ControllerLock, repository_lock_path
|
|
120
|
+
from .ownership import refuse_scheduler_owned_issue, standalone_ownership_predicate
|
|
121
|
+
from .redaction import redact, redact_argv, redact_dict, redact_obj
|
|
122
|
+
from .replan_txn import ReplanTransaction
|
|
123
|
+
from .run_namespace import namespace_issue_of_state_dir
|
|
124
|
+
from .sandbox_pin import SandboxPin
|
|
125
|
+
from .sandbox_policy import absolute_spelling
|
|
126
|
+
from .scheduler import EpicScheduler, SchedulerInputs
|
|
127
|
+
from .scheduler_stop import StopMode
|
|
128
|
+
from .sdk_policy import MAX_ANSWER_CHARS
|
|
129
|
+
from .state import AutoForgeState
|
|
130
|
+
from .transitions import TERMINAL_PHASES, Phase, WorkflowMode
|
|
131
|
+
from .validation import parse_issue_url
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _positive_int(text: str) -> int:
|
|
135
|
+
"""argparse type for ``--max-steps``: an integer >= 1.
|
|
136
|
+
|
|
137
|
+
Rejecting the value at parse time keeps ``run --max-steps 0`` from
|
|
138
|
+
writing a fresh state file and then crashing in ``engine.run()``.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
value = int(text)
|
|
142
|
+
except ValueError:
|
|
143
|
+
raise argparse.ArgumentTypeError(f"invalid int value: {text!r}") from None
|
|
144
|
+
if value < 1:
|
|
145
|
+
raise argparse.ArgumentTypeError(f"must be >= 1, got {value}")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _max_parallel(text: str) -> int:
|
|
150
|
+
"""argparse type for ``--max-parallel``: within ``scheduler.max_parallel``'s bounds."""
|
|
151
|
+
low, high = MAX_PARALLEL_BOUNDS
|
|
152
|
+
try:
|
|
153
|
+
value = int(text)
|
|
154
|
+
except ValueError:
|
|
155
|
+
raise argparse.ArgumentTypeError(f"invalid int value: {text!r}") from None
|
|
156
|
+
if not low <= value <= high:
|
|
157
|
+
raise argparse.ArgumentTypeError(
|
|
158
|
+
f"must be between {low} and {high}, got {value} (there is no unbounded mode)"
|
|
159
|
+
)
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
164
|
+
p = argparse.ArgumentParser(
|
|
165
|
+
prog="autoforge",
|
|
166
|
+
description=(
|
|
167
|
+
"AutoForge — deterministic orchestration layer for AI-driven "
|
|
168
|
+
"software development (Issue → Claude Code implementation → PR → "
|
|
169
|
+
"OpenCode review → Claude Code fix → ... → READY_FOR_MERGE)."
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
173
|
+
p.add_argument("--config", default=None, help="config file (.yaml/.yml/.toml/.json)")
|
|
174
|
+
p.add_argument(
|
|
175
|
+
"--state-dir",
|
|
176
|
+
default=None,
|
|
177
|
+
help="state directory (default: from config, else .autoforge)",
|
|
178
|
+
)
|
|
179
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
180
|
+
|
|
181
|
+
d = sub.add_parser("doctor", help="check git/gh/claude/opencode/repo/config (read-only)")
|
|
182
|
+
d.add_argument("--json", action="store_true", dest="as_json")
|
|
183
|
+
d.add_argument(
|
|
184
|
+
"--epic",
|
|
185
|
+
default=None,
|
|
186
|
+
metavar="EPIC_URL",
|
|
187
|
+
help=(
|
|
188
|
+
"also run the parallel EPIC scheduler's checks: the coordination root's "
|
|
189
|
+
"filesystem and the scheduler root's permissions, and whether gh can read this "
|
|
190
|
+
"EPIC's sub-issues and dependencies (read-only; nothing is created)"
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
r = sub.add_parser("run", help="create a run and advance it")
|
|
195
|
+
r.add_argument("--epic", required=True, help="EPIC issue URL")
|
|
196
|
+
r.add_argument("--issue", required=True, help="first issue URL")
|
|
197
|
+
r.add_argument("--dry-run", action="store_true", help="plan only; no side effects")
|
|
198
|
+
r.add_argument(
|
|
199
|
+
"--force",
|
|
200
|
+
action="store_true",
|
|
201
|
+
help=(
|
|
202
|
+
"discard an existing non-terminal run; an unreadable state entry (bad JSON, "
|
|
203
|
+
"foreign protocol, invalid UTF-8, dangling symlink, FIFO/socket/device) is "
|
|
204
|
+
"moved aside as state.json.corrupt-<timestamp> instead of being deleted "
|
|
205
|
+
"(a directory is refused and must be moved by hand)"
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
r.add_argument(
|
|
209
|
+
"--max-steps", type=_positive_int, default=50, help="steps for this invocation (>= 1)"
|
|
210
|
+
)
|
|
211
|
+
r.add_argument(
|
|
212
|
+
"--allow-merge",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="unlock MERGE execution (also needs config safety.allow_merge=true)",
|
|
215
|
+
)
|
|
216
|
+
r.add_argument(
|
|
217
|
+
"--full-prompt", action="store_true", help="print full rendered prompt in dry-run"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
s = sub.add_parser("step", help="execute exactly one step from persisted state")
|
|
221
|
+
s.add_argument("--dry-run", action="store_true")
|
|
222
|
+
s.add_argument("--allow-merge", action="store_true")
|
|
223
|
+
s.add_argument("--full-prompt", action="store_true")
|
|
224
|
+
_add_accept_config_change(s)
|
|
225
|
+
|
|
226
|
+
re_ = sub.add_parser("resume", help="continue a persisted run")
|
|
227
|
+
re_.add_argument("--dry-run", action="store_true")
|
|
228
|
+
re_.add_argument(
|
|
229
|
+
"--max-steps", type=_positive_int, default=50, help="steps for this invocation (>= 1)"
|
|
230
|
+
)
|
|
231
|
+
re_.add_argument("--allow-merge", action="store_true")
|
|
232
|
+
re_.add_argument("--full-prompt", action="store_true")
|
|
233
|
+
_add_accept_config_change(re_)
|
|
234
|
+
|
|
235
|
+
ub = sub.add_parser(
|
|
236
|
+
"unblock",
|
|
237
|
+
help=(
|
|
238
|
+
"leave BLOCKED explicitly: re-inspect GitHub, re-enter a phase or refuse; then 'resume'"
|
|
239
|
+
),
|
|
240
|
+
description=(
|
|
241
|
+
"Requires a BLOCKED run. The controller re-runs its recovery inspection "
|
|
242
|
+
"against live GitHub and either re-enters the one phase that inspection "
|
|
243
|
+
"supports (through the same transition validation as any other step) or "
|
|
244
|
+
"refuses and stays BLOCKED. No agent runs; the operator action is recorded "
|
|
245
|
+
"in state and in the run log."
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
ub.add_argument(
|
|
249
|
+
"--reason",
|
|
250
|
+
required=True,
|
|
251
|
+
help="why the operator is unblocking (recorded in state and in the run log)",
|
|
252
|
+
)
|
|
253
|
+
ub.add_argument(
|
|
254
|
+
"--dry-run",
|
|
255
|
+
action="store_true",
|
|
256
|
+
help="read GitHub and report the decision without writing state or the log",
|
|
257
|
+
)
|
|
258
|
+
_add_accept_config_change(ub)
|
|
259
|
+
action = ub.add_mutually_exclusive_group()
|
|
260
|
+
action.add_argument(
|
|
261
|
+
"--approve-action",
|
|
262
|
+
default="",
|
|
263
|
+
metavar="ACTION_ID",
|
|
264
|
+
help=(
|
|
265
|
+
"approve the acceptance action a FIX plan awaits (its id from 'autoforge status'); "
|
|
266
|
+
"'resume' then publishes exactly its payload on the run's issue"
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
action.add_argument(
|
|
270
|
+
"--decline-action",
|
|
271
|
+
action="append",
|
|
272
|
+
default=None,
|
|
273
|
+
metavar="ACTION_ID",
|
|
274
|
+
help=(
|
|
275
|
+
"decline the acceptance action (A-...) a FIX plan awaits, or one or more operator "
|
|
276
|
+
"actions (X-..., repeatable; the plan's other pending operator actions are "
|
|
277
|
+
"withdrawn and its verified ones kept): the unsent plan is dropped and FIX "
|
|
278
|
+
"launches the fixer again with the decline in its prompt"
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
action.add_argument(
|
|
282
|
+
"--bind-evidence",
|
|
283
|
+
action="append",
|
|
284
|
+
nargs=2,
|
|
285
|
+
default=None,
|
|
286
|
+
metavar=("ACTION_ID", "COMMENT_URL"),
|
|
287
|
+
help=(
|
|
288
|
+
"bind an existing comment on an operator action's target issue as its evidence "
|
|
289
|
+
"(repeatable): 'resume' verifies the comment is there and unchanged; the "
|
|
290
|
+
"controller never judges whether it is adequate"
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
st = sub.add_parser("status", help="show persisted run status")
|
|
295
|
+
shown = st.add_mutually_exclusive_group()
|
|
296
|
+
shown.add_argument("--json", action="store_true", dest="as_json")
|
|
297
|
+
shown.add_argument(
|
|
298
|
+
"--action",
|
|
299
|
+
default="",
|
|
300
|
+
metavar="ACTION_ID",
|
|
301
|
+
help=(
|
|
302
|
+
"print the exact payload of a pending acceptance (A-...) or operator (X-...) "
|
|
303
|
+
"action and nothing else (exit 1 when no such action is persisted)"
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
_add_local_parser(sub)
|
|
308
|
+
_add_epic_parser(sub)
|
|
309
|
+
return p
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _add_accept_config_change(parser: argparse.ArgumentParser) -> None:
|
|
313
|
+
parser.add_argument(
|
|
314
|
+
"--accept-config-change",
|
|
315
|
+
action="store_true",
|
|
316
|
+
help=(
|
|
317
|
+
"continue a REMOTE run although this invocation's profile routing or agent "
|
|
318
|
+
"worktree root differs from the configuration the run was recorded under, or any "
|
|
319
|
+
"run although this invocation's sandbox.mode is weaker than the one it is pinned "
|
|
320
|
+
"to: the change is written to the run log and becomes the run's configuration "
|
|
321
|
+
"(#219, #280)"
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _add_local_parser(sub) -> None:
|
|
327
|
+
"""`autoforge local ...`: the GitHub-free workflow.
|
|
328
|
+
|
|
329
|
+
``status``/``step``/``resume`` stay mode-agnostic (they read the mode from
|
|
330
|
+
persisted state), so only the commands that *differ* live here.
|
|
331
|
+
"""
|
|
332
|
+
loc = sub.add_parser(
|
|
333
|
+
"local",
|
|
334
|
+
help="local mode: drive a feature Markdown file with no GitHub involved",
|
|
335
|
+
description=(
|
|
336
|
+
"LOCAL mode: features/<slug>.md -> ANALYZE_EXECUTE -> REVIEW -> FIX -> "
|
|
337
|
+
"REVIEW -> DONE, entirely in the working tree. No gh, no PR, no push, "
|
|
338
|
+
"no merge, no commits. Use 'autoforge status/step/resume' as usual."
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
lsub = loc.add_subparsers(dest="local_command", required=True)
|
|
342
|
+
|
|
343
|
+
li = lsub.add_parser("init", help="create features/<slug>.md from the template")
|
|
344
|
+
li.add_argument("slug", help="feature slug, e.g. add-transaction-filter")
|
|
345
|
+
li.add_argument(
|
|
346
|
+
"--feature-dir",
|
|
347
|
+
default=None,
|
|
348
|
+
help="directory for the file (default: config local.feature_dir)",
|
|
349
|
+
)
|
|
350
|
+
li.add_argument(
|
|
351
|
+
"--force",
|
|
352
|
+
action="store_true",
|
|
353
|
+
help="overwrite an existing feature file (refused by default)",
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
lr = lsub.add_parser(
|
|
357
|
+
"run", help="create a local run from a feature Markdown file and advance it"
|
|
358
|
+
)
|
|
359
|
+
lr.add_argument("feature", help="path to the feature Markdown file (inside this repository)")
|
|
360
|
+
lr.add_argument("--dry-run", action="store_true", help="plan only; no side effects")
|
|
361
|
+
lr.add_argument(
|
|
362
|
+
"--force",
|
|
363
|
+
action="store_true",
|
|
364
|
+
help="discard an existing non-terminal run (see 'run --force')",
|
|
365
|
+
)
|
|
366
|
+
lr.add_argument(
|
|
367
|
+
"--max-steps", type=_positive_int, default=50, help="steps for this invocation (>= 1)"
|
|
368
|
+
)
|
|
369
|
+
lr.add_argument(
|
|
370
|
+
"--allow-dirty",
|
|
371
|
+
action="store_true",
|
|
372
|
+
help=(
|
|
373
|
+
"start even though the working tree has changes other than the feature file; "
|
|
374
|
+
"they are recorded in the run and reported to the reviewer, never treated as "
|
|
375
|
+
"part of the implementation"
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
lr.add_argument(
|
|
379
|
+
"--full-prompt", action="store_true", help="print full rendered prompt in dry-run"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
ld = lsub.add_parser(
|
|
383
|
+
"doctor", help="check config/git/agent CLIs for local mode (never touches GitHub)"
|
|
384
|
+
)
|
|
385
|
+
ld.add_argument("--json", action="store_true", dest="as_json")
|
|
386
|
+
ld.add_argument("--feature", default=None, help="also validate this feature Markdown path")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _add_epic_parser(sub) -> None:
|
|
390
|
+
"""`autoforge epic ...`: the parallel EPIC scheduler (ADR 0008, #183).
|
|
391
|
+
|
|
392
|
+
``epic _worker`` is matched in :func:`main` before this parser, so it is
|
|
393
|
+
neither a choice here nor in ``--help``.
|
|
394
|
+
"""
|
|
395
|
+
ep = sub.add_parser(
|
|
396
|
+
"epic",
|
|
397
|
+
help="parallel EPIC scheduler: run, resume, status, stop, archive (REMOTE only)",
|
|
398
|
+
description=(
|
|
399
|
+
"Run up to --max-parallel issues of one EPIC at once, each in its own scheduler "
|
|
400
|
+
"namespace and worktree, admitted in dependency order from GitHub's native "
|
|
401
|
+
"sub-issues and blocked-by links. Every worker stops at its settlement boundary "
|
|
402
|
+
"(READY_FOR_MERGE, BLOCKED, DONE, FAILED); nothing is merged. REMOTE only; "
|
|
403
|
+
"--state-dir is refused, and so is an EPIC of another repository than this "
|
|
404
|
+
"checkout's 'origin' remote, before anything is read or written."
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
esub = ep.add_subparsers(dest="epic_command", required=True)
|
|
408
|
+
|
|
409
|
+
er = esub.add_parser(
|
|
410
|
+
"run",
|
|
411
|
+
help="start a scheduler for an EPIC (refused while a claim of it is open)",
|
|
412
|
+
description=(
|
|
413
|
+
"Start a scheduler invocation: it holds the controller lock for its whole life, "
|
|
414
|
+
"admits ready issues up to --max-parallel and exits when no worker runs and "
|
|
415
|
+
"nothing is admissible. Refused while any claim of this EPIC is open: an earlier "
|
|
416
|
+
"scheduler ended without closing it, and 'epic resume' classifies it."
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
er.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
|
|
420
|
+
_add_max_parallel(er)
|
|
421
|
+
er.add_argument(
|
|
422
|
+
"--dry-run",
|
|
423
|
+
action="store_true",
|
|
424
|
+
help=(
|
|
425
|
+
"print the snapshot, the ordered verdicts and the admission plan; no lock, "
|
|
426
|
+
"namespace, claim, worktree or worker"
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
er.add_argument(
|
|
430
|
+
"--force",
|
|
431
|
+
action="store_true",
|
|
432
|
+
help=(
|
|
433
|
+
"move an unreadable scheduler.json aside (scheduler.json.corrupt-<timestamp>); "
|
|
434
|
+
"it does nothing else: it never closes a claim or moves a state"
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
eres = esub.add_parser(
|
|
439
|
+
"resume",
|
|
440
|
+
help="classify an EPIC's namespaces and claims, then continue like 'epic run'",
|
|
441
|
+
description=(
|
|
442
|
+
"Reconcile every namespace of the EPIC first (a settled one's open claim is "
|
|
443
|
+
"closed, a resumable one is admitted as a resume, an uncertain one is held), "
|
|
444
|
+
"then admit like 'epic run'. Refused (exit 2) while a worker of an earlier "
|
|
445
|
+
"invocation still runs: it keeps the controller lock until it exits."
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
eres.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
|
|
449
|
+
_add_max_parallel(eres)
|
|
450
|
+
_add_accept_config_change(eres)
|
|
451
|
+
|
|
452
|
+
est = esub.add_parser(
|
|
453
|
+
"status",
|
|
454
|
+
help="every child's category and next step (lock-free, read-only)",
|
|
455
|
+
description=(
|
|
456
|
+
"Take no lock and change nothing: the snapshot from GitHub, and each child's "
|
|
457
|
+
"namespace, claim and run lock read lock-free. Each child gets exactly one "
|
|
458
|
+
"category; a claim's worker PID is shown as a diagnostic and never signalled."
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
est.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
|
|
462
|
+
est.add_argument(
|
|
463
|
+
"--json",
|
|
464
|
+
action="store_true",
|
|
465
|
+
dest="as_json",
|
|
466
|
+
help="print one autoforge.epic-status/1 document (docs/usage.md)",
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
esp = esub.add_parser(
|
|
470
|
+
"stop",
|
|
471
|
+
help="ask the EPIC's scheduler to stop (lock-free; default: drain)",
|
|
472
|
+
description=(
|
|
473
|
+
"Publish a stop request for the invocation scheduler.json names. A drain "
|
|
474
|
+
"closes admission and lets every worker return at its next step boundary; "
|
|
475
|
+
f"--now behaves like Ctrl-C in the scheduler's terminal. Note: {DRAIN_HELP}. "
|
|
476
|
+
"With no live scheduler, surviving workers drain at their next step boundary, "
|
|
477
|
+
"and --now cannot signal them. Refused when no scheduler.json exists; nothing "
|
|
478
|
+
"is ever removed."
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
esp.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
|
|
482
|
+
esp.add_argument(
|
|
483
|
+
"--now",
|
|
484
|
+
action="store_true",
|
|
485
|
+
help="interrupt the workers, as Ctrl-C in the scheduler's terminal does",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
ea = esub.add_parser(
|
|
489
|
+
"archive",
|
|
490
|
+
help="release one settled or uncertain issue for a fresh run (never automatic)",
|
|
491
|
+
description=(
|
|
492
|
+
"Under the controller lock and the issue's run lock: close an open claim as "
|
|
493
|
+
"archived (an unusable claim file is moved aside), then move state/state.json "
|
|
494
|
+
"aside as state.json.archived-<timestamp>. The logs, the worktree, the binding "
|
|
495
|
+
"and the claim history are kept; the run's loop-bound evidence then lives only "
|
|
496
|
+
"in the archived state. Refused while the issue has a live owner. Re-running it "
|
|
497
|
+
"completes an interrupted archive."
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
ea.add_argument("epic", metavar="EPIC_URL", help="the EPIC issue URL")
|
|
501
|
+
ea.add_argument("issue", type=_positive_int, metavar="ISSUE", help="the issue number")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _add_max_parallel(parser: argparse.ArgumentParser) -> None:
|
|
505
|
+
low, high = MAX_PARALLEL_BOUNDS
|
|
506
|
+
parser.add_argument(
|
|
507
|
+
"--max-parallel",
|
|
508
|
+
type=_max_parallel,
|
|
509
|
+
default=None,
|
|
510
|
+
metavar="N",
|
|
511
|
+
help=(
|
|
512
|
+
f"workers at once, {low} to {high} (default: scheduler.max_parallel in the "
|
|
513
|
+
"configuration)"
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _load_cfg(args) -> AutoForgeConfig:
|
|
519
|
+
"""The ``--config`` file, or the built-in defaults; refused when a launch could rewrite it.
|
|
520
|
+
|
|
521
|
+
The file is parsed first, since the ``sandbox.mode`` it sets decides
|
|
522
|
+
whether the placement check applies at all. Under ``required`` or
|
|
523
|
+
``preferred``, a file inside a tree the repository's enforced launches
|
|
524
|
+
write, or a directory a provider's launches are given read-write, is
|
|
525
|
+
then a ConfigurationError (exit 2) for every command, before
|
|
526
|
+
anything acts on what it says: no phase, launch or write follows, and no
|
|
527
|
+
command it names runs (#280,
|
|
528
|
+
:func:`autoforge.engine.config_file_placement_problem`). The check is
|
|
529
|
+
given the path as the loader was, never normalized: a ``..`` after a
|
|
530
|
+
link names another file once collapsed.
|
|
531
|
+
"""
|
|
532
|
+
cfg = load_config_file(args.config)
|
|
533
|
+
problem = config_file_placement_problem(cfg, args.config or "")
|
|
534
|
+
if problem:
|
|
535
|
+
raise ConfigurationError(f"{problem}. Nothing was run, launched or written.")
|
|
536
|
+
return cfg
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main(argv: list[str] | None = None) -> int:
|
|
540
|
+
words = sys.argv[1:] if argv is None else list(argv)
|
|
541
|
+
if words[:2] == ["epic", "_worker"]:
|
|
542
|
+
# The scheduler's hidden worker entry point (ADR 0008 D9.1, #182): not
|
|
543
|
+
# a command an operator types, so it is not in the parser or --help.
|
|
544
|
+
from .worker import run_worker
|
|
545
|
+
|
|
546
|
+
return run_worker(words[2:])
|
|
547
|
+
parser = build_parser()
|
|
548
|
+
args = parser.parse_args(words)
|
|
549
|
+
try:
|
|
550
|
+
if args.command == "doctor":
|
|
551
|
+
return cmd_doctor(args)
|
|
552
|
+
if args.command == "run":
|
|
553
|
+
return cmd_run(args)
|
|
554
|
+
if args.command == "step":
|
|
555
|
+
return cmd_step(args)
|
|
556
|
+
if args.command == "resume":
|
|
557
|
+
return cmd_resume(args)
|
|
558
|
+
if args.command == "unblock":
|
|
559
|
+
return cmd_unblock(args)
|
|
560
|
+
if args.command == "status":
|
|
561
|
+
return cmd_status(args)
|
|
562
|
+
if args.command == "epic":
|
|
563
|
+
return cmd_epic(args)
|
|
564
|
+
if args.command == "local":
|
|
565
|
+
if args.local_command == "init":
|
|
566
|
+
return cmd_local_init(args)
|
|
567
|
+
if args.local_command == "run":
|
|
568
|
+
return cmd_local_run(args)
|
|
569
|
+
if args.local_command == "doctor":
|
|
570
|
+
return cmd_local_doctor(args)
|
|
571
|
+
parser.error(f"unknown local command {args.local_command}")
|
|
572
|
+
return 2
|
|
573
|
+
parser.error(f"unknown command {args.command}")
|
|
574
|
+
return 2
|
|
575
|
+
except (ConfigurationError, StateError, StateTransitionError, LockError) as exc:
|
|
576
|
+
print(f"autoforge: error: {redact(str(exc))}", file=sys.stderr)
|
|
577
|
+
return 2
|
|
578
|
+
except AutoForgeError as exc:
|
|
579
|
+
print(f"autoforge: error: {redact(str(exc))}", file=sys.stderr)
|
|
580
|
+
return 1
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# -- commands ---------------------------------------------------------------
|
|
584
|
+
def _global_flags(args) -> tuple[str, ...]:
|
|
585
|
+
"""``--config`` and ``--state-dir`` as this process was given them, and nothing else.
|
|
586
|
+
|
|
587
|
+
An agent failure's ``resume`` line repeats them (#189): the CLI has no
|
|
588
|
+
configuration discovery, so a bare ``resume`` would find no run or run on
|
|
589
|
+
the built-in defaults. They are paths, as typed; no configuration value.
|
|
590
|
+
"""
|
|
591
|
+
flags: list[str] = []
|
|
592
|
+
if args.config:
|
|
593
|
+
flags += ["--config", args.config]
|
|
594
|
+
if args.state_dir:
|
|
595
|
+
flags += ["--state-dir", args.state_dir]
|
|
596
|
+
return tuple(flags)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _engine_for(args, cfg: AutoForgeConfig | None = None) -> ControllerEngine:
|
|
600
|
+
cfg = cfg or _load_cfg(args)
|
|
601
|
+
state_dir = args.state_dir or cfg.state_dir
|
|
602
|
+
engine = ControllerEngine(config=cfg, state_dir=state_dir)
|
|
603
|
+
engine.cli_global_flags = _global_flags(args)
|
|
604
|
+
engine.config_file = absolute_spelling(args.config) if args.config else ""
|
|
605
|
+
return engine
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _check_configuration(engine: ControllerEngine, args, *words: str, dry_run: bool) -> None:
|
|
609
|
+
"""The recorded-configuration and sandbox-pin gate of ``step``, ``resume`` and ``unblock``.
|
|
610
|
+
|
|
611
|
+
Refuses (``ConfigurationDriftError``, exit 2) before any phase executes
|
|
612
|
+
when this invocation's configuration differs from the run's (#219) or
|
|
613
|
+
its ``sandbox.mode`` is weaker than the run's pin (#280), unless
|
|
614
|
+
``--accept-config-change``; prints the engine's notice on stderr.
|
|
615
|
+
"""
|
|
616
|
+
notice = engine.check_run_configuration(
|
|
617
|
+
words, accept_change=bool(getattr(args, "accept_config_change", False)), dry_run=dry_run
|
|
618
|
+
)
|
|
619
|
+
if notice:
|
|
620
|
+
print(redact(f"autoforge: {notice}"), file=sys.stderr)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _local_engine_for(args, cfg: AutoForgeConfig | None = None) -> ControllerEngine:
|
|
624
|
+
"""An engine for a LOCAL command, with its state directory resolved.
|
|
625
|
+
|
|
626
|
+
LOCAL state never lives in the reviewed working tree; see
|
|
627
|
+
:meth:`ControllerEngine.bind_local_state_dir`.
|
|
628
|
+
"""
|
|
629
|
+
engine = _engine_for(args, cfg)
|
|
630
|
+
engine.bind_local_state_dir(args.state_dir)
|
|
631
|
+
return engine
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _bind_existing_run(engine: ControllerEngine) -> None:
|
|
635
|
+
"""Resolve which run a mode-agnostic command (`resume`, `step`, `status`) means.
|
|
636
|
+
|
|
637
|
+
A REMOTE run keeps its state under `config.state_dir` (`.autoforge` by
|
|
638
|
+
default); a LOCAL run keeps it in the git directory, because the LOCAL
|
|
639
|
+
fingerprint covers the working tree. The two locations are disjoint and
|
|
640
|
+
the mode is not known until state is read, so the command looks for a
|
|
641
|
+
`state.json` in both. Exactly one match is used. Two matches is a real
|
|
642
|
+
ambiguity and is refused rather than resolved by precedence: picking one
|
|
643
|
+
would silently `resume` the wrong workflow. Zero matches leaves the
|
|
644
|
+
configured location so the error names it.
|
|
645
|
+
|
|
646
|
+
`--state-dir` is an answer to this question, so it is never second-guessed
|
|
647
|
+
(the caller does not reach here when it was given).
|
|
648
|
+
"""
|
|
649
|
+
configured = engine.paths
|
|
650
|
+
try:
|
|
651
|
+
local = engine.local_state_paths()
|
|
652
|
+
except (AutoForgeError, OSError):
|
|
653
|
+
# Not a git repository (or git is unusable): only the configured
|
|
654
|
+
# location can hold a run, and any real problem surfaces there.
|
|
655
|
+
return
|
|
656
|
+
if os.path.abspath(local.state_dir) == os.path.abspath(configured.state_dir):
|
|
657
|
+
return
|
|
658
|
+
here = os.path.lexists(configured.state_file)
|
|
659
|
+
there = os.path.lexists(local.state_file)
|
|
660
|
+
if here and there:
|
|
661
|
+
raise StateError(
|
|
662
|
+
f"two runs exist: a remote-style one at {configured.state_file} and a local "
|
|
663
|
+
f"one at {local.state_file}. Pass --state-dir to say which one this command "
|
|
664
|
+
"means; the controller will not pick for you."
|
|
665
|
+
)
|
|
666
|
+
if there:
|
|
667
|
+
engine.paths = local
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _guard_scheduler_ownership(
|
|
671
|
+
engine: ControllerEngine, state: AutoForgeState, command: str
|
|
672
|
+
) -> None:
|
|
673
|
+
"""ADR 0008 D17.2's forward guard, under L0 and before anything is written.
|
|
674
|
+
|
|
675
|
+
Refuses (``SchedulerOwnedIssueError``, exit 2) when a scheduler
|
|
676
|
+
namespace owns the issue ``command`` would act on, the run's
|
|
677
|
+
``current_issue_url``, then hands the engine the same predicate, which
|
|
678
|
+
``_verify_issue_selectable`` applies to every issue it is asked about
|
|
679
|
+
(``next_issue_url`` included). ``unblock`` of a namespace's own state
|
|
680
|
+
directory exempts that namespace. LOCAL runs name no issue and are not
|
|
681
|
+
checked.
|
|
682
|
+
"""
|
|
683
|
+
if state.mode is WorkflowMode.LOCAL:
|
|
684
|
+
return
|
|
685
|
+
root = engine.coordination_root()
|
|
686
|
+
exempt = (
|
|
687
|
+
namespace_issue_of_state_dir(engine.paths.state_dir, root) if command == "unblock" else None
|
|
688
|
+
)
|
|
689
|
+
refuse_scheduler_owned_issue(root, state.current_issue_url, command, exempt_issue=exempt)
|
|
690
|
+
engine.issue_ownership = standalone_ownership_predicate(root, exempt_issue=exempt)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _bind_standalone_run(engine: ControllerEngine, args, command: str) -> None:
|
|
694
|
+
""":func:`_bind_existing_run` for a standalone ``step`` or ``resume`` (ADR 0008 D17.3).
|
|
695
|
+
|
|
696
|
+
The state directory this invocation names (``--state-dir`` or
|
|
697
|
+
``config.state_dir``) is judged before the binding looks for a run in it
|
|
698
|
+
or in LOCAL's location: one inside the scheduler root that holds no
|
|
699
|
+
``state.json`` would otherwise give way to a LOCAL run's, and the command
|
|
700
|
+
would continue that run instead of refusing. The directory bound is
|
|
701
|
+
judged again. Both before the lock and before any state is read; a dry
|
|
702
|
+
run is judged by neither (:meth:`ControllerEngine.refuse_scheduler_state_dir`).
|
|
703
|
+
"""
|
|
704
|
+
if not args.dry_run:
|
|
705
|
+
engine.refuse_scheduler_state_dir(command)
|
|
706
|
+
if args.state_dir:
|
|
707
|
+
return
|
|
708
|
+
_bind_existing_run(engine)
|
|
709
|
+
if not args.dry_run:
|
|
710
|
+
engine.refuse_scheduler_state_dir(command)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _doctor_runner():
|
|
714
|
+
"""Command runner used by `doctor` (indirection so tests can inject a fake)."""
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def cmd_doctor(args) -> int:
|
|
719
|
+
results = run_doctor(
|
|
720
|
+
config_path=args.config,
|
|
721
|
+
state_dir=args.state_dir,
|
|
722
|
+
runner=_doctor_runner(),
|
|
723
|
+
epic_url=args.epic,
|
|
724
|
+
)
|
|
725
|
+
return _report_checks(results, args.as_json, "AutoForge doctor (read-only checks)")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def cmd_local_doctor(args) -> int:
|
|
729
|
+
"""`autoforge local doctor`: no gh, no gh auth, no 'origin' remote."""
|
|
730
|
+
results = run_local_doctor(
|
|
731
|
+
config_path=args.config,
|
|
732
|
+
state_dir=args.state_dir,
|
|
733
|
+
runner=_doctor_runner(),
|
|
734
|
+
feature_spec_path=args.feature,
|
|
735
|
+
)
|
|
736
|
+
return _report_checks(results, args.as_json, "AutoForge doctor — local mode (read-only checks)")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _report_checks(results: list[CheckResult], as_json: bool, title: str) -> int:
|
|
740
|
+
if as_json:
|
|
741
|
+
checks = [
|
|
742
|
+
{
|
|
743
|
+
"name": r.name,
|
|
744
|
+
"ok": r.ok,
|
|
745
|
+
"required": r.required,
|
|
746
|
+
"skipped": r.skipped,
|
|
747
|
+
"detail": redact(r.detail),
|
|
748
|
+
}
|
|
749
|
+
for r in results
|
|
750
|
+
]
|
|
751
|
+
print(
|
|
752
|
+
json.dumps(
|
|
753
|
+
{"ok": all(c["ok"] or not c["required"] for c in checks), "checks": checks},
|
|
754
|
+
indent=2,
|
|
755
|
+
)
|
|
756
|
+
)
|
|
757
|
+
else:
|
|
758
|
+
print(title)
|
|
759
|
+
print()
|
|
760
|
+
for r in results:
|
|
761
|
+
print(f" [{r.label}] {r.name}: {redact(r.detail)}")
|
|
762
|
+
print()
|
|
763
|
+
failed = [r for r in results if not r.ok and r.required]
|
|
764
|
+
if failed:
|
|
765
|
+
print(f"{len(failed)} required check(s) failed.", file=sys.stderr)
|
|
766
|
+
return 1
|
|
767
|
+
# Keep stdout to the JSON document alone in --json mode.
|
|
768
|
+
print("All checks passed.", file=sys.stderr if as_json else sys.stdout)
|
|
769
|
+
return 0
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _progress_line(line: str) -> None:
|
|
773
|
+
"""One live progress line (already bounded and redacted), on stderr at once."""
|
|
774
|
+
print(line, file=sys.stderr, flush=True)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
# What one line typed at the terminal resolver may hold: an answer of
|
|
778
|
+
# MAX_ANSWER_CHARS characters of up to four UTF-8 bytes each, and a little
|
|
779
|
+
# more. A longer line is read to its end and refused, never kept.
|
|
780
|
+
_MAX_TERMINAL_LINE_BYTES = 4 * MAX_ANSWER_CHARS + 64
|
|
781
|
+
# An option number as typed: ASCII digits, and few of them (a question has
|
|
782
|
+
# at most sdk_policy.MAX_OPTIONS options), so int() takes every one.
|
|
783
|
+
_OPTION_NUMBER = re.compile(r"[0-9]{1,3}")
|
|
784
|
+
# What the resolver may have written to a terminal that has not taken it yet
|
|
785
|
+
# (its output suspended, or not read): past this it reads no more keys until
|
|
786
|
+
# the terminal takes some or the deadline comes, so what waits stays bounded.
|
|
787
|
+
_MAX_UNSHOWN_BYTES = 64 * 1024
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
class _Unusable(Exception):
|
|
791
|
+
"""A line the terminal resolver cannot use: the message names the rule."""
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
class _TerminalWriter:
|
|
795
|
+
"""Writes to the terminal of ``fd`` that never block (#134).
|
|
796
|
+
|
|
797
|
+
A terminal's output can be suspended (its stop key under IXON, another
|
|
798
|
+
program's ``tcflow``) or left unread, and a blocking write then waits
|
|
799
|
+
until it resumes: past any deadline, and the resolver with it. So the
|
|
800
|
+
resolver writes to a terminal through a descriptor of its own on its
|
|
801
|
+
device, opened non-blocking: an open file description of its own, so
|
|
802
|
+
stdin, stderr and the shell's descriptors stay as they are. What the
|
|
803
|
+
terminal does not take at once waits here, and is written as it takes
|
|
804
|
+
it while the resolver waits for a key, never past the deadline
|
|
805
|
+
(:meth:`_Terminal.line`); what it has not taken when the resolver is
|
|
806
|
+
done is dropped (:meth:`close`).
|
|
807
|
+
"""
|
|
808
|
+
|
|
809
|
+
def __init__(self, fd: int) -> None:
|
|
810
|
+
self.fd = os.open(os.ttyname(fd), os.O_WRONLY | os.O_NOCTTY | os.O_CLOEXEC | os.O_NONBLOCK)
|
|
811
|
+
self.device = os.fstat(self.fd).st_rdev
|
|
812
|
+
self._unshown = bytearray()
|
|
813
|
+
|
|
814
|
+
@property
|
|
815
|
+
def unshown(self) -> int:
|
|
816
|
+
"""How many bytes the terminal has not taken yet."""
|
|
817
|
+
return len(self._unshown)
|
|
818
|
+
|
|
819
|
+
def write(self, data: bytes) -> None:
|
|
820
|
+
self._unshown += data
|
|
821
|
+
self.flush()
|
|
822
|
+
|
|
823
|
+
def flush(self) -> None:
|
|
824
|
+
"""Write what the terminal takes now."""
|
|
825
|
+
while self._unshown:
|
|
826
|
+
try:
|
|
827
|
+
written = os.write(self.fd, self._unshown)
|
|
828
|
+
except BlockingIOError:
|
|
829
|
+
return
|
|
830
|
+
if not written:
|
|
831
|
+
return
|
|
832
|
+
del self._unshown[:written]
|
|
833
|
+
|
|
834
|
+
def close(self) -> None:
|
|
835
|
+
try:
|
|
836
|
+
self.flush()
|
|
837
|
+
except OSError: # the terminal hung up: there is no one to show it to
|
|
838
|
+
pass
|
|
839
|
+
finally:
|
|
840
|
+
os.close(self.fd)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
class _LineEditing:
|
|
844
|
+
"""A terminal whose line editing the resolver took over: its keys, the
|
|
845
|
+
writer it echoes what is typed through, and the settings it reads under
|
|
846
|
+
(:func:`_line_editing`)."""
|
|
847
|
+
|
|
848
|
+
def __init__(self, fd: int, saved: list, writer: _TerminalWriter) -> None:
|
|
849
|
+
import termios
|
|
850
|
+
|
|
851
|
+
cc = saved[6]
|
|
852
|
+
|
|
853
|
+
def key(index: int) -> int | None:
|
|
854
|
+
value = cc[index]
|
|
855
|
+
code = value[0] if isinstance(value, bytes) else value
|
|
856
|
+
# 0 is Linux's _POSIX_VDISABLE, 0xff macOS's; a key past ASCII
|
|
857
|
+
# would cut into a UTF-8 character.
|
|
858
|
+
return code if 0 < code < 0x80 else None
|
|
859
|
+
|
|
860
|
+
# Backspace and delete erase whatever the terminal's erase key is:
|
|
861
|
+
# a control character is never part of a usable answer.
|
|
862
|
+
self.erase = frozenset(k for k in (key(termios.VERASE), 0x08, 0x7F) if k is not None)
|
|
863
|
+
self.word_erase = key(termios.VWERASE)
|
|
864
|
+
self.kill = key(termios.VKILL)
|
|
865
|
+
self.eof = key(termios.VEOF)
|
|
866
|
+
# Under IXON the stop and start keys are never input either.
|
|
867
|
+
flow = (key(termios.VSTOP), key(termios.VSTART)) if saved[0] & termios.IXON else ()
|
|
868
|
+
self.flow = frozenset(k for k in flow if k is not None)
|
|
869
|
+
self._fd = fd
|
|
870
|
+
self.writer = writer
|
|
871
|
+
# Canonical mode, echo and output flow control off, and a read
|
|
872
|
+
# returns once a key is typed.
|
|
873
|
+
self.raw = list(saved)
|
|
874
|
+
self.raw[0] = saved[0] & ~termios.IXON
|
|
875
|
+
self.raw[3] = saved[3] & ~(termios.ICANON | termios.ECHO)
|
|
876
|
+
self.raw[6] = list(cc)
|
|
877
|
+
self.raw[6][termios.VMIN], self.raw[6][termios.VTIME] = 1, 0
|
|
878
|
+
|
|
879
|
+
def echo(self, data: bytes) -> None:
|
|
880
|
+
self.writer.write(data)
|
|
881
|
+
|
|
882
|
+
def retake(self) -> bool:
|
|
883
|
+
"""Whether the terminal's own line editing came back on while the
|
|
884
|
+
resolver waited (a stop and continue under a shell that resets the
|
|
885
|
+
terminal, another program's ``stty``): what was just read went
|
|
886
|
+
through it and may have been cut. It is turned off again, and so is
|
|
887
|
+
output flow control when that came back on."""
|
|
888
|
+
import termios
|
|
889
|
+
|
|
890
|
+
attrs = termios.tcgetattr(self._fd)
|
|
891
|
+
if attrs[3] & (termios.ICANON | termios.ECHO) or attrs[0] & termios.IXON:
|
|
892
|
+
termios.tcsetattr(self._fd, termios.TCSANOW, self.raw)
|
|
893
|
+
return bool(attrs[3] & termios.ICANON)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
@contextmanager
|
|
897
|
+
def _line_editing(fd: int) -> Iterator[_LineEditing | None]:
|
|
898
|
+
"""Take over the line editing of the terminal ``fd`` while the resolver
|
|
899
|
+
asks (#134); ``None`` for a descriptor that is not a terminal, whose
|
|
900
|
+
lines arrive whole.
|
|
901
|
+
|
|
902
|
+
In canonical mode a terminal edits a line before a reader gets it, and
|
|
903
|
+
Linux keeps at most 4095 bytes of it: what is typed past that is dropped
|
|
904
|
+
without a word, under any bound a reader could hold the line to, and an
|
|
905
|
+
answer or guidance would reach the agent cut short. So canonical mode
|
|
906
|
+
and echo are off while the resolver reads, and it edits the line itself
|
|
907
|
+
(:class:`_Terminal`): it echoes what it keeps, on the terminal and
|
|
908
|
+
never on stderr, and takes the terminal's own erase, word-erase, kill
|
|
909
|
+
and end-of-file keys. A line read while canonical mode came back on is
|
|
910
|
+
refused (:meth:`_LineEditing.retake`).
|
|
911
|
+
|
|
912
|
+
Output flow control (IXON) is off too: under it the stop key suspends
|
|
913
|
+
the terminal's output until the start key, and the echo would wait for
|
|
914
|
+
it past the deadline. The two keys are still never input, and suspend
|
|
915
|
+
nothing; output a stop key suspended before the resolver asked comes
|
|
916
|
+
back where turning IXON off restarts it (Linux). Whatever else holds the
|
|
917
|
+
output, the resolver writes to the terminal only as it takes it
|
|
918
|
+
(:class:`_TerminalWriter`). Everything else stays the terminal's: Ctrl-C
|
|
919
|
+
still raises ``KeyboardInterrupt`` (ISIG is untouched), and the settings
|
|
920
|
+
are restored however the reading ends.
|
|
921
|
+
"""
|
|
922
|
+
try:
|
|
923
|
+
import termios
|
|
924
|
+
except ImportError: # pragma: no cover - a platform without termios
|
|
925
|
+
yield None
|
|
926
|
+
return
|
|
927
|
+
try:
|
|
928
|
+
saved = termios.tcgetattr(fd)
|
|
929
|
+
except (OSError, termios.error):
|
|
930
|
+
saved = None
|
|
931
|
+
if saved is None:
|
|
932
|
+
yield None
|
|
933
|
+
return
|
|
934
|
+
# Its own descriptor, opened to write, also echoes a terminal read
|
|
935
|
+
# through a read-only one.
|
|
936
|
+
writer = _TerminalWriter(fd)
|
|
937
|
+
try:
|
|
938
|
+
editing = _LineEditing(fd, saved, writer)
|
|
939
|
+
termios.tcsetattr(fd, termios.TCSANOW, editing.raw)
|
|
940
|
+
try:
|
|
941
|
+
yield editing
|
|
942
|
+
finally:
|
|
943
|
+
termios.tcsetattr(fd, termios.TCSANOW, saved)
|
|
944
|
+
finally:
|
|
945
|
+
writer.close()
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _in_memory(output: TextIO | None) -> bool:
|
|
949
|
+
"""Whether ``output`` is a stream without a descriptor (a StringIO),
|
|
950
|
+
whose writes never wait. ``None`` is no stream at all: Python's stderr
|
|
951
|
+
when its descriptor was closed at startup; a closed stream is none
|
|
952
|
+
either."""
|
|
953
|
+
if output is None:
|
|
954
|
+
return False
|
|
955
|
+
try:
|
|
956
|
+
output.fileno()
|
|
957
|
+
except (AttributeError, io.UnsupportedOperation):
|
|
958
|
+
return True
|
|
959
|
+
except (OSError, ValueError):
|
|
960
|
+
return False
|
|
961
|
+
return False
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _terminal_fd(output: TextIO | None) -> int | None:
|
|
965
|
+
"""The descriptor ``output`` writes to when that is a terminal."""
|
|
966
|
+
if output is None:
|
|
967
|
+
return None
|
|
968
|
+
try:
|
|
969
|
+
fd = output.fileno()
|
|
970
|
+
except (AttributeError, OSError, ValueError):
|
|
971
|
+
return None
|
|
972
|
+
return fd if os.isatty(fd) else None
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _can_show(output: TextIO | None, editing: _LineEditing | None) -> bool:
|
|
976
|
+
"""Whether the resolver has somewhere to show its text that no write
|
|
977
|
+
holds past the deadline (:func:`_shown_on`)."""
|
|
978
|
+
return editing is not None or _in_memory(output) or _terminal_fd(output) is not None
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@contextmanager
|
|
982
|
+
def _shown_on(
|
|
983
|
+
output: TextIO | None, editing: _LineEditing | None
|
|
984
|
+
) -> Iterator[_TerminalWriter | None]:
|
|
985
|
+
"""The writer the resolver shows its text through, so that no write holds
|
|
986
|
+
it past the deadline (R4-F1).
|
|
987
|
+
|
|
988
|
+
An ``output`` that is a terminal is written to as it takes it: through
|
|
989
|
+
the echo's writer when it is the terminal the resolver reads, so that
|
|
990
|
+
the two keep their order. Any other output (stderr redirected to a
|
|
991
|
+
pipe, a file or a socket: ``2>log``, ``2>&1 | tee log``; or closed) is
|
|
992
|
+
never written to, since a pipe no one reads holds a blocking write
|
|
993
|
+
until it is read: the text is shown on the terminal the resolver reads
|
|
994
|
+
instead, where the answer is typed, as ``getpass`` does. ``None`` for
|
|
995
|
+
an in-memory stream, printed to as before. Entered only when
|
|
996
|
+
:func:`_can_show`: with no terminal to show the text on instead, the
|
|
997
|
+
resolver asks nothing."""
|
|
998
|
+
if _in_memory(output):
|
|
999
|
+
yield None
|
|
1000
|
+
return
|
|
1001
|
+
fd = _terminal_fd(output)
|
|
1002
|
+
if editing is not None and (fd is None or os.fstat(fd).st_rdev == editing.writer.device):
|
|
1003
|
+
yield editing.writer
|
|
1004
|
+
return
|
|
1005
|
+
assert fd is not None # _can_show
|
|
1006
|
+
writer = _TerminalWriter(fd)
|
|
1007
|
+
try:
|
|
1008
|
+
yield writer
|
|
1009
|
+
finally:
|
|
1010
|
+
writer.close()
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _utf8_length(lead: int) -> int:
|
|
1014
|
+
"""How many bytes the UTF-8 character starting with ``lead`` has; 1 for
|
|
1015
|
+
a byte no character starts with (kept alone, so its line is refused)."""
|
|
1016
|
+
if 0xC2 <= lead <= 0xDF:
|
|
1017
|
+
return 2
|
|
1018
|
+
if 0xE0 <= lead <= 0xEF:
|
|
1019
|
+
return 3
|
|
1020
|
+
if 0xF0 <= lead <= 0xF4:
|
|
1021
|
+
return 4
|
|
1022
|
+
return 1
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _echoed(typed: bytes) -> tuple[bytes, int]:
|
|
1026
|
+
"""What the terminal is shown of one typed character, and its columns: a
|
|
1027
|
+
control character as ^X, one that is not UTF-8 or would act on the
|
|
1028
|
+
terminal as U+FFFD, a combining mark in no column of its own and a wide
|
|
1029
|
+
character in two."""
|
|
1030
|
+
try:
|
|
1031
|
+
ch = typed.decode("utf-8")
|
|
1032
|
+
except UnicodeDecodeError:
|
|
1033
|
+
return "\ufffd".encode(), 1
|
|
1034
|
+
if ord(ch) < 0x20:
|
|
1035
|
+
return b"^" + bytes((ord(ch) + 0x40,)), 2
|
|
1036
|
+
category = unicodedata.category(ch)
|
|
1037
|
+
if category in ("Cc", "Cf", "Zl", "Zp"):
|
|
1038
|
+
return "\ufffd".encode(), 1
|
|
1039
|
+
if category in ("Mn", "Me"):
|
|
1040
|
+
return typed, 0
|
|
1041
|
+
return typed, 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
class _Terminal:
|
|
1045
|
+
"""The operator's terminal while the resolver asks: lines read from
|
|
1046
|
+
``fd`` until ``deadline`` (a string, or :class:`Unavailable`), and the
|
|
1047
|
+
resolver's text shown on ``output`` (:meth:`show`).
|
|
1048
|
+
|
|
1049
|
+
At a terminal (``editing``) each key is taken as it is typed and the
|
|
1050
|
+
line is edited here (:func:`_line_editing`); any other descriptor
|
|
1051
|
+
delivers whole lines. A line past :data:`_MAX_TERMINAL_LINE_BYTES` is
|
|
1052
|
+
refused whole when it ends: at a terminal what is typed past the bound
|
|
1053
|
+
is neither kept nor echoed (the bell rings once), and the kill key
|
|
1054
|
+
starts the line again.
|
|
1055
|
+
|
|
1056
|
+
What the resolver writes to a terminal, its echo and, unless ``output``
|
|
1057
|
+
is an in-memory stream (``shown``, :func:`_shown_on`), its text, goes
|
|
1058
|
+
through a :class:`_TerminalWriter`: what the terminal has not taken is
|
|
1059
|
+
written while a line is awaited, and a terminal whose output is
|
|
1060
|
+
suspended never holds the resolver past the deadline. While more than
|
|
1061
|
+
:data:`_MAX_UNSHOWN_BYTES` wait, no key is read.
|
|
1062
|
+
"""
|
|
1063
|
+
|
|
1064
|
+
def __init__(
|
|
1065
|
+
self,
|
|
1066
|
+
fd: int,
|
|
1067
|
+
clock: Callable[[], float],
|
|
1068
|
+
deadline: float,
|
|
1069
|
+
editing: _LineEditing | None = None,
|
|
1070
|
+
output: TextIO | None = None,
|
|
1071
|
+
shown: _TerminalWriter | None = None,
|
|
1072
|
+
) -> None:
|
|
1073
|
+
self._fd = fd
|
|
1074
|
+
self._clock = clock
|
|
1075
|
+
self._deadline = deadline
|
|
1076
|
+
self._editing = editing
|
|
1077
|
+
self._output = output
|
|
1078
|
+
self._shown = shown
|
|
1079
|
+
self._writers: list[_TerminalWriter] = []
|
|
1080
|
+
for writer in (editing.writer if editing is not None else None, shown):
|
|
1081
|
+
if writer is not None and writer not in self._writers:
|
|
1082
|
+
self._writers.append(writer)
|
|
1083
|
+
self._pending = b""
|
|
1084
|
+
self._overlong = False
|
|
1085
|
+
# The line being typed at a terminal: each character's bytes and
|
|
1086
|
+
# columns, and the first bytes of one not yet whole.
|
|
1087
|
+
self._typed: list[tuple[bytes, int]] = []
|
|
1088
|
+
self._size = 0
|
|
1089
|
+
self._partial = b""
|
|
1090
|
+
|
|
1091
|
+
def show(self, text: str, end: str = "\n") -> None:
|
|
1092
|
+
if self._shown is None: # an in-memory stream (_shown_on)
|
|
1093
|
+
print(text, end=end, file=self._output, flush=True)
|
|
1094
|
+
return
|
|
1095
|
+
encoding = getattr(self._output, "encoding", None) or "utf-8"
|
|
1096
|
+
self._shown.write((text + end).encode(encoding, "backslashreplace"))
|
|
1097
|
+
|
|
1098
|
+
def line(self) -> str | Unavailable:
|
|
1099
|
+
while (raw := self._take()) is None:
|
|
1100
|
+
remaining = self._deadline - self._clock()
|
|
1101
|
+
if remaining <= 0:
|
|
1102
|
+
return Unavailable(UnavailableReason.EXPIRED)
|
|
1103
|
+
unshown = [writer for writer in self._writers if writer.unshown]
|
|
1104
|
+
reading = [self._fd] if sum(w.unshown for w in unshown) < _MAX_UNSHOWN_BYTES else []
|
|
1105
|
+
readable, writable, _ = select.select(reading, [w.fd for w in unshown], [], remaining)
|
|
1106
|
+
for writer in unshown:
|
|
1107
|
+
if writer.fd in writable:
|
|
1108
|
+
writer.flush()
|
|
1109
|
+
if not readable:
|
|
1110
|
+
if writable:
|
|
1111
|
+
continue
|
|
1112
|
+
return Unavailable(UnavailableReason.EXPIRED)
|
|
1113
|
+
chunk = os.read(self._fd, 4096)
|
|
1114
|
+
if not chunk:
|
|
1115
|
+
return Unavailable(UnavailableReason.END_OF_INPUT)
|
|
1116
|
+
if self._editing is not None and self._editing.retake():
|
|
1117
|
+
self._pending, self._partial, self._overlong = b"", b"", False
|
|
1118
|
+
self._typed, self._size = [], 0
|
|
1119
|
+
raise _Unusable("the terminal's line editing came back on while the line was typed")
|
|
1120
|
+
self._pending += chunk
|
|
1121
|
+
if isinstance(raw, Unavailable):
|
|
1122
|
+
return raw
|
|
1123
|
+
overlong, self._overlong = self._overlong, False
|
|
1124
|
+
if overlong:
|
|
1125
|
+
raise _Unusable("the line is too long")
|
|
1126
|
+
try:
|
|
1127
|
+
return raw.decode("utf-8").rstrip("\r")
|
|
1128
|
+
except UnicodeDecodeError:
|
|
1129
|
+
raise _Unusable("the line is not UTF-8") from None
|
|
1130
|
+
|
|
1131
|
+
def seconds_left(self) -> int:
|
|
1132
|
+
return max(0, int(self._deadline - self._clock()))
|
|
1133
|
+
|
|
1134
|
+
def _take(self) -> bytes | Unavailable | None:
|
|
1135
|
+
"""The next line of what was read, or ``None`` until one has ended."""
|
|
1136
|
+
if self._editing is not None:
|
|
1137
|
+
return self._edit(self._editing)
|
|
1138
|
+
raw, newline, rest = self._pending.partition(b"\n")
|
|
1139
|
+
if not newline:
|
|
1140
|
+
if len(self._pending) > _MAX_TERMINAL_LINE_BYTES:
|
|
1141
|
+
self._pending, self._overlong = b"", True
|
|
1142
|
+
return None
|
|
1143
|
+
self._pending = rest
|
|
1144
|
+
if len(raw) > _MAX_TERMINAL_LINE_BYTES:
|
|
1145
|
+
self._overlong = True
|
|
1146
|
+
return raw
|
|
1147
|
+
|
|
1148
|
+
def _edit(self, keys: _LineEditing) -> bytes | Unavailable | None:
|
|
1149
|
+
pending, self._pending = self._pending, b""
|
|
1150
|
+
for at, byte in enumerate(pending):
|
|
1151
|
+
if byte in keys.flow: # the stop and start keys, taken as under IXON
|
|
1152
|
+
continue
|
|
1153
|
+
if self._partial and 0x80 <= byte <= 0xBF:
|
|
1154
|
+
self._partial += bytes((byte,))
|
|
1155
|
+
if len(self._partial) == _utf8_length(self._partial[0]):
|
|
1156
|
+
self._keep(self._partial, keys)
|
|
1157
|
+
self._partial = b""
|
|
1158
|
+
continue
|
|
1159
|
+
if self._partial: # a character cut short: kept, so the line is refused
|
|
1160
|
+
self._keep(self._partial, keys)
|
|
1161
|
+
self._partial = b""
|
|
1162
|
+
if byte >= 0x80:
|
|
1163
|
+
if _utf8_length(byte) == 1:
|
|
1164
|
+
self._keep(bytes((byte,)), keys)
|
|
1165
|
+
else:
|
|
1166
|
+
self._partial = bytes((byte,))
|
|
1167
|
+
elif byte in b"\r\n":
|
|
1168
|
+
keys.echo(b"\n")
|
|
1169
|
+
self._pending = pending[at + 1 :]
|
|
1170
|
+
line = b"".join(typed for typed, _ in self._typed)
|
|
1171
|
+
self._typed, self._size = [], 0
|
|
1172
|
+
return line
|
|
1173
|
+
elif byte in keys.erase:
|
|
1174
|
+
self._erase(1, keys)
|
|
1175
|
+
elif byte == keys.word_erase:
|
|
1176
|
+
while self._typed and self._typed[-1][0].decode("utf-8", "replace").isspace():
|
|
1177
|
+
self._erase(1, keys)
|
|
1178
|
+
while self._typed and not self._typed[-1][0].decode("utf-8", "replace").isspace():
|
|
1179
|
+
self._erase(1, keys)
|
|
1180
|
+
elif byte == keys.kill:
|
|
1181
|
+
self._erase(len(self._typed), keys)
|
|
1182
|
+
self._overlong = False
|
|
1183
|
+
elif byte == keys.eof:
|
|
1184
|
+
# As in canonical mode: the end of input on an empty line,
|
|
1185
|
+
# and nothing within one.
|
|
1186
|
+
if not self._typed and not self._overlong:
|
|
1187
|
+
return Unavailable(UnavailableReason.END_OF_INPUT)
|
|
1188
|
+
else:
|
|
1189
|
+
self._keep(bytes((byte,)), keys)
|
|
1190
|
+
return None
|
|
1191
|
+
|
|
1192
|
+
def _keep(self, typed: bytes, keys: _LineEditing) -> None:
|
|
1193
|
+
if self._overlong or self._size + len(typed) > _MAX_TERMINAL_LINE_BYTES:
|
|
1194
|
+
if not self._overlong:
|
|
1195
|
+
keys.echo(b"\a")
|
|
1196
|
+
self._overlong = True
|
|
1197
|
+
return
|
|
1198
|
+
shown, columns = _echoed(typed)
|
|
1199
|
+
keys.echo(shown)
|
|
1200
|
+
self._typed.append((typed, columns))
|
|
1201
|
+
self._size += len(typed)
|
|
1202
|
+
|
|
1203
|
+
def _erase(self, count: int, keys: _LineEditing) -> None:
|
|
1204
|
+
for _ in range(min(count, len(self._typed))):
|
|
1205
|
+
typed, columns = self._typed.pop()
|
|
1206
|
+
self._size -= len(typed)
|
|
1207
|
+
keys.echo(b"\b" * columns + b" " * columns + b"\b" * columns)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _discard_typeahead(fd: int) -> None:
|
|
1211
|
+
"""Drop what was typed before the request was shown, so a key pressed
|
|
1212
|
+
earlier never answers it. Nothing to drop on a descriptor that is not a
|
|
1213
|
+
terminal."""
|
|
1214
|
+
try:
|
|
1215
|
+
import termios
|
|
1216
|
+
except ImportError: # pragma: no cover - a platform without termios
|
|
1217
|
+
return
|
|
1218
|
+
try:
|
|
1219
|
+
termios.tcflush(fd, termios.TCIFLUSH)
|
|
1220
|
+
except (OSError, termios.error):
|
|
1221
|
+
pass
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
class TerminalResolver:
|
|
1225
|
+
"""Asks the operator at this terminal (ADR 0007 D5.1, D7.4, D7.7; #134).
|
|
1226
|
+
|
|
1227
|
+
The engine's resolver for a real ``run``, ``local run``, ``step`` and
|
|
1228
|
+
``resume``. It reads stdin and shows the request on stderr, where the
|
|
1229
|
+
live progress is, when stderr is a terminal; stdout stays the step
|
|
1230
|
+
outcomes'. A redirected stderr (a pipe, a file) or none at all is never
|
|
1231
|
+
written to: the request is shown on the terminal it reads instead
|
|
1232
|
+
(:func:`_shown_on`). Before it asks it checks that stdin is a terminal
|
|
1233
|
+
(D7.4) and drops anything typed ahead. While it asks it edits the line
|
|
1234
|
+
itself, since the terminal's own line editing would drop what is typed
|
|
1235
|
+
past its buffer without a word (:func:`_line_editing`): an answer
|
|
1236
|
+
reaches the agent whole or is refused. Each line is read until the
|
|
1237
|
+
deadline the broker gives: its expiry is ``expired``, the end of the
|
|
1238
|
+
input ``end_of_input``, and the broker decides what either means.
|
|
1239
|
+
No write holds it past that deadline: the stop key suspends nothing
|
|
1240
|
+
while it reads, it writes to a terminal only as the terminal takes it
|
|
1241
|
+
(:class:`_TerminalWriter`), so a terminal whose output is suspended or
|
|
1242
|
+
unread neither holds a decision back nor keeps the wait open, and it
|
|
1243
|
+
writes to no pipe, which a reader that stopped reading would hold.
|
|
1244
|
+
Ctrl-C raises ``KeyboardInterrupt`` as anywhere else: the step is
|
|
1245
|
+
interrupted and nothing is decided (D7.7). An input it cannot use is
|
|
1246
|
+
explained, by rule and never by value, and asked again within the same
|
|
1247
|
+
deadline. Only what the request offers is offered; a modified input
|
|
1248
|
+
never is, here.
|
|
1249
|
+
|
|
1250
|
+
Everything shown is the request's own rendering, already bounded,
|
|
1251
|
+
cleaned and redacted (:mod:`autoforge.interaction`).
|
|
1252
|
+
"""
|
|
1253
|
+
|
|
1254
|
+
def __init__(
|
|
1255
|
+
self,
|
|
1256
|
+
*,
|
|
1257
|
+
input_fd: int | None = None,
|
|
1258
|
+
output: TextIO | None = None,
|
|
1259
|
+
clock: Callable[[], float] = time.monotonic,
|
|
1260
|
+
is_terminal: Callable[[int], bool] = os.isatty,
|
|
1261
|
+
) -> None:
|
|
1262
|
+
self._input_fd = input_fd
|
|
1263
|
+
self._output = output
|
|
1264
|
+
self._clock = clock
|
|
1265
|
+
self._is_terminal = is_terminal
|
|
1266
|
+
|
|
1267
|
+
def resolve(
|
|
1268
|
+
self, request: InteractionRequest, deadline: float
|
|
1269
|
+
) -> InteractionDecision | Unavailable:
|
|
1270
|
+
fd = self._stdin()
|
|
1271
|
+
if fd is None or not self._is_terminal(fd):
|
|
1272
|
+
return Unavailable(UnavailableReason.NO_TERMINAL)
|
|
1273
|
+
_discard_typeahead(fd)
|
|
1274
|
+
output = self._output or sys.stderr
|
|
1275
|
+
with _line_editing(fd) as editing:
|
|
1276
|
+
if not _can_show(output, editing):
|
|
1277
|
+
return Unavailable(UnavailableReason.NO_TERMINAL)
|
|
1278
|
+
with _shown_on(output, editing) as shown:
|
|
1279
|
+
terminal = _Terminal(fd, self._clock, deadline, editing, output, shown)
|
|
1280
|
+
return self._resolve(request, terminal)
|
|
1281
|
+
|
|
1282
|
+
# -- internals -------------------------------------------------------------
|
|
1283
|
+
def _resolve(
|
|
1284
|
+
self, request: InteractionRequest, terminal: _Terminal
|
|
1285
|
+
) -> InteractionDecision | Unavailable:
|
|
1286
|
+
source = f", subagent {request.agent}" if request.agent is not None else ""
|
|
1287
|
+
if request.kind is RequestKind.PERMISSION:
|
|
1288
|
+
terminal.show(
|
|
1289
|
+
f"autoforge: the agent asks permission to use {request.tool_name} "
|
|
1290
|
+
f"(request {request.request_id}{source}); answer within "
|
|
1291
|
+
f"{terminal.seconds_left()}s, Ctrl-C stops the step"
|
|
1292
|
+
)
|
|
1293
|
+
return self._permission(request, terminal)
|
|
1294
|
+
terminal.show(
|
|
1295
|
+
f"autoforge: the agent asks {len(request.questions)} question(s) "
|
|
1296
|
+
f"(request {request.request_id}{source}); answer within "
|
|
1297
|
+
f"{terminal.seconds_left()}s, Ctrl-C stops the step"
|
|
1298
|
+
)
|
|
1299
|
+
responses: list[QuestionResponse] = []
|
|
1300
|
+
for n, question in enumerate(request.questions, 1):
|
|
1301
|
+
response = self._question(request, question, n, terminal)
|
|
1302
|
+
if not isinstance(response, QuestionResponse):
|
|
1303
|
+
return response
|
|
1304
|
+
responses.append(response)
|
|
1305
|
+
return Answer(tuple(responses))
|
|
1306
|
+
|
|
1307
|
+
def _stdin(self) -> int | None:
|
|
1308
|
+
if self._input_fd is not None:
|
|
1309
|
+
return self._input_fd
|
|
1310
|
+
try:
|
|
1311
|
+
return sys.stdin.fileno() if sys.stdin is not None else None
|
|
1312
|
+
except (OSError, ValueError):
|
|
1313
|
+
return None
|
|
1314
|
+
|
|
1315
|
+
def _ask(self, terminal: _Terminal, prompt: str) -> str | Unavailable:
|
|
1316
|
+
"""One usable line for ``prompt``, or :class:`Unavailable`."""
|
|
1317
|
+
while True:
|
|
1318
|
+
terminal.show(prompt, end="")
|
|
1319
|
+
try:
|
|
1320
|
+
line = terminal.line()
|
|
1321
|
+
except _Unusable as exc:
|
|
1322
|
+
terminal.show(f" not used: {exc}; try again")
|
|
1323
|
+
continue
|
|
1324
|
+
if isinstance(line, Unavailable):
|
|
1325
|
+
terminal.show("")
|
|
1326
|
+
return line
|
|
1327
|
+
if line.strip():
|
|
1328
|
+
return line.strip()
|
|
1329
|
+
|
|
1330
|
+
def _question(
|
|
1331
|
+
self,
|
|
1332
|
+
request: InteractionRequest,
|
|
1333
|
+
question: Question,
|
|
1334
|
+
n: int,
|
|
1335
|
+
terminal: _Terminal,
|
|
1336
|
+
) -> QuestionResponse | Cancel | Unavailable:
|
|
1337
|
+
own = question.custom and DecisionKind.CUSTOM_ANSWER in request.offers
|
|
1338
|
+
cancel = DecisionKind.CANCEL in request.offers
|
|
1339
|
+
title = f" ({n}/{len(request.questions)})"
|
|
1340
|
+
terminal.show(f"{title} {question.header}" if question.header else title)
|
|
1341
|
+
terminal.show(f" {question.text}")
|
|
1342
|
+
for number, option in enumerate(question.options, 1):
|
|
1343
|
+
described = f" - {option.description}" if option.description else ""
|
|
1344
|
+
terminal.show(f" {number}. {option.shown}{described}")
|
|
1345
|
+
if own:
|
|
1346
|
+
terminal.show(" 0. something else, in your own words")
|
|
1347
|
+
several = "numbers separated by commas" if question.multiple else "a number"
|
|
1348
|
+
prompt = f" choose {several}{', or c to cancel' if cancel else ''}: "
|
|
1349
|
+
while True:
|
|
1350
|
+
line = self._ask(terminal, prompt)
|
|
1351
|
+
if isinstance(line, Unavailable):
|
|
1352
|
+
return line
|
|
1353
|
+
if line.lower() == "c" and cancel:
|
|
1354
|
+
return Cancel()
|
|
1355
|
+
picked = self._picks(line, question, own)
|
|
1356
|
+
if isinstance(picked, str):
|
|
1357
|
+
terminal.show(f" not used: {picked}; try again")
|
|
1358
|
+
continue
|
|
1359
|
+
custom: str | None = None
|
|
1360
|
+
if 0 in picked:
|
|
1361
|
+
text = self._ask(terminal, " your answer: ")
|
|
1362
|
+
if isinstance(text, Unavailable):
|
|
1363
|
+
return text
|
|
1364
|
+
custom = text
|
|
1365
|
+
labels = tuple(question.options[number - 1].label for number in picked if number)
|
|
1366
|
+
try:
|
|
1367
|
+
return QuestionResponse(choices=labels, custom_text=custom)
|
|
1368
|
+
except InteractionDataError as exc:
|
|
1369
|
+
terminal.show(f" not used: {exc}; try again")
|
|
1370
|
+
|
|
1371
|
+
@staticmethod
|
|
1372
|
+
def _picks(line: str, question: Question, own: bool) -> list[int] | str:
|
|
1373
|
+
"""The option numbers ``line`` picks, or why it picks none."""
|
|
1374
|
+
words = [word.strip() for word in line.split(",")]
|
|
1375
|
+
if not all(_OPTION_NUMBER.fullmatch(word) for word in words):
|
|
1376
|
+
return "expected option numbers"
|
|
1377
|
+
picked = [int(word) for word in words]
|
|
1378
|
+
lowest = 0 if own else 1
|
|
1379
|
+
if any(not lowest <= number <= len(question.options) for number in picked):
|
|
1380
|
+
return f"choose from {lowest} to {len(question.options)}"
|
|
1381
|
+
if len(set(picked)) != len(picked):
|
|
1382
|
+
return "an option is chosen twice"
|
|
1383
|
+
if len(picked) > 1 and not question.multiple:
|
|
1384
|
+
return "this question takes one answer"
|
|
1385
|
+
return picked
|
|
1386
|
+
|
|
1387
|
+
def _permission(
|
|
1388
|
+
self, request: InteractionRequest, terminal: _Terminal
|
|
1389
|
+
) -> Allow | Deny | Cancel | Unavailable:
|
|
1390
|
+
offers = request.offers
|
|
1391
|
+
if request.summary:
|
|
1392
|
+
terminal.show(f" {request.summary}")
|
|
1393
|
+
terminal.show(f" input sha256 {request.input_sha256}")
|
|
1394
|
+
choices: dict[str, str] = {}
|
|
1395
|
+
if DecisionKind.ALLOW_ONCE in offers:
|
|
1396
|
+
choices["y"] = "allow this call once"
|
|
1397
|
+
if DecisionKind.ALLOW_SESSION in offers:
|
|
1398
|
+
choices["s"] = "allow it for the rest of this session"
|
|
1399
|
+
if DecisionKind.DENY in offers or DecisionKind.DENY_GUIDANCE in offers:
|
|
1400
|
+
choices["n"] = (
|
|
1401
|
+
"deny it (n <text>: and tell the agent what to do instead)"
|
|
1402
|
+
if DecisionKind.DENY_GUIDANCE in offers
|
|
1403
|
+
else "deny it"
|
|
1404
|
+
)
|
|
1405
|
+
if DecisionKind.CANCEL in offers:
|
|
1406
|
+
choices["c"] = "cancel: decline to decide"
|
|
1407
|
+
for key, text in choices.items():
|
|
1408
|
+
terminal.show(f" {key} {text}")
|
|
1409
|
+
while True:
|
|
1410
|
+
line = self._ask(terminal, f" choose {'/'.join(choices)}: ")
|
|
1411
|
+
if isinstance(line, Unavailable):
|
|
1412
|
+
return line
|
|
1413
|
+
key, _, rest = line.partition(" ")
|
|
1414
|
+
key, rest = key.lower(), rest.strip()
|
|
1415
|
+
if key not in choices or (rest and key != "n"):
|
|
1416
|
+
terminal.show(f" not used: expected one of {', '.join(choices)}; try again")
|
|
1417
|
+
continue
|
|
1418
|
+
if key == "y":
|
|
1419
|
+
return Allow(AllowScope.ONCE)
|
|
1420
|
+
if key == "s":
|
|
1421
|
+
return Allow(AllowScope.SESSION)
|
|
1422
|
+
if key == "c":
|
|
1423
|
+
return Cancel()
|
|
1424
|
+
if not rest and DecisionKind.DENY not in offers:
|
|
1425
|
+
terminal.show(" not used: say what the agent should do instead (n <text>)")
|
|
1426
|
+
continue
|
|
1427
|
+
if rest and DecisionKind.DENY_GUIDANCE not in offers:
|
|
1428
|
+
terminal.show(" not used: this provider takes no guidance with a denial (n)")
|
|
1429
|
+
continue
|
|
1430
|
+
try:
|
|
1431
|
+
return Deny(rest or None)
|
|
1432
|
+
except InteractionDataError as exc:
|
|
1433
|
+
terminal.show(f" not used: {exc}; try again")
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _attach_terminal(engine: ControllerEngine) -> None:
|
|
1437
|
+
"""Stream a real run's agent progress to stderr (#192), and ask the
|
|
1438
|
+
operator at this terminal when an agent asks for a decision (ADR 0007
|
|
1439
|
+
D5.1, #134; only a profile whose ``interaction_policy`` is ``prompt``
|
|
1440
|
+
ever reaches it).
|
|
1441
|
+
|
|
1442
|
+
Only the non-dry paths call this: a dry run launches nothing. stdout is
|
|
1443
|
+
left to the step outcomes, so nothing about progress or a question
|
|
1444
|
+
reaches output a script reads.
|
|
1445
|
+
"""
|
|
1446
|
+
engine.progress_output = _progress_line
|
|
1447
|
+
engine.interaction_resolver = TerminalResolver()
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _print_outcome_now(o: StepOutcome) -> None:
|
|
1451
|
+
"""Print one step's outcome as soon as the step finished, not after the loop."""
|
|
1452
|
+
print_step_outcome(o)
|
|
1453
|
+
sys.stdout.flush()
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def _finish(
|
|
1457
|
+
engine: ControllerEngine,
|
|
1458
|
+
outcomes: list[StepOutcome],
|
|
1459
|
+
allow_merge: bool,
|
|
1460
|
+
*,
|
|
1461
|
+
printed: bool = False,
|
|
1462
|
+
) -> int:
|
|
1463
|
+
"""Print the outcomes (unless ``printed`` already, step by step) and the run's end."""
|
|
1464
|
+
if not printed:
|
|
1465
|
+
for o in outcomes:
|
|
1466
|
+
print_step_outcome(o)
|
|
1467
|
+
state = engine.state
|
|
1468
|
+
assert state is not None
|
|
1469
|
+
if state.phase == Phase.READY_FOR_MERGE:
|
|
1470
|
+
print_ready_banner(
|
|
1471
|
+
state,
|
|
1472
|
+
gate_open=engine.merge_gate_open(allow_merge),
|
|
1473
|
+
certificate=_review_certificate_check(engine, state),
|
|
1474
|
+
)
|
|
1475
|
+
return 0
|
|
1476
|
+
if state.phase in (Phase.BLOCKED, Phase.FAILED):
|
|
1477
|
+
# The whole line is state-derived (`run_id` is a validated path
|
|
1478
|
+
# component, not a checked-for-secrets value), so the line crosses
|
|
1479
|
+
# the redaction boundary assembled, not field by field.
|
|
1480
|
+
print(redact(f"\nrun {state.run_id} is {state.phase.value}: {state.block_reason or '-'}"))
|
|
1481
|
+
return 1
|
|
1482
|
+
return 0
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _existing_run_guard(
|
|
1486
|
+
engine: ControllerEngine, force: bool, command: str = "run"
|
|
1487
|
+
) -> tuple[int | None, bool]:
|
|
1488
|
+
"""Decide whether a fresh run may overwrite the run at ``engine.paths``.
|
|
1489
|
+
|
|
1490
|
+
Returns ``(exit_code_or_None, corrupt)``. Must be called with the
|
|
1491
|
+
controller lock held: a verdict taken before the lock could go stale.
|
|
1492
|
+
The inspection goes through the engine's held state root -- the same
|
|
1493
|
+
capability the first save and every later write will use -- so the
|
|
1494
|
+
directory that is inspected is the directory that is written.
|
|
1495
|
+
|
|
1496
|
+
``command`` is the subcommand the operator actually typed, so the advice
|
|
1497
|
+
names a command that can be copied (`run --force`, `local run --force`)
|
|
1498
|
+
rather than a bare flag.
|
|
1499
|
+
"""
|
|
1500
|
+
paths = engine.paths
|
|
1501
|
+
try:
|
|
1502
|
+
existing = engine.existing_run()
|
|
1503
|
+
except StateError as exc:
|
|
1504
|
+
# Unreadable / foreign-protocol state is fatal: a fresh run must
|
|
1505
|
+
# never silently replace it (merge counters etc. would be lost).
|
|
1506
|
+
if not force:
|
|
1507
|
+
# The error quotes the field it could not read (an unknown phase
|
|
1508
|
+
# is echoed verbatim), so the refusal is state-derived output
|
|
1509
|
+
# like the terminal lines and crosses the boundary assembled.
|
|
1510
|
+
print(
|
|
1511
|
+
redact(
|
|
1512
|
+
f"autoforge: error: {exc}\n"
|
|
1513
|
+
"autoforge: error: refusing to start a new run over an unreadable "
|
|
1514
|
+
f"state file — repair it, or use '{command} --force' to move it "
|
|
1515
|
+
f"aside as {paths.state_file.name}.corrupt-<timestamp> and start over"
|
|
1516
|
+
),
|
|
1517
|
+
file=sys.stderr,
|
|
1518
|
+
)
|
|
1519
|
+
return 2, False
|
|
1520
|
+
return None, True
|
|
1521
|
+
if existing is None:
|
|
1522
|
+
return None, False
|
|
1523
|
+
if existing.phase not in TERMINAL_PHASES and not force:
|
|
1524
|
+
print(
|
|
1525
|
+
redact(
|
|
1526
|
+
f"autoforge: error: existing run {existing.run_id} "
|
|
1527
|
+
f"in phase {existing.phase.value} — use 'resume' to continue "
|
|
1528
|
+
f"or '{command} --force' to discard it"
|
|
1529
|
+
),
|
|
1530
|
+
file=sys.stderr,
|
|
1531
|
+
)
|
|
1532
|
+
return 2, False
|
|
1533
|
+
operator = _operator_records(existing) if existing.phase in _KEEPING_PHASES else []
|
|
1534
|
+
if operator and not force:
|
|
1535
|
+
# #243: a BLOCKED or FAILED run keeps its FIX plan, and an operator
|
|
1536
|
+
# action of it may already be on GitHub; replacing the run silently
|
|
1537
|
+
# would forget what the operator was asked to post.
|
|
1538
|
+
named = "; ".join(
|
|
1539
|
+
f"{r.action_id} on {r.target['issue_url']}"
|
|
1540
|
+
+ (f" ({(r.observed or {}).get('url')})" if r.stage is Stage.OBSERVED else "")
|
|
1541
|
+
for r in operator
|
|
1542
|
+
)
|
|
1543
|
+
print(
|
|
1544
|
+
redact(
|
|
1545
|
+
f"autoforge: error: existing run {existing.run_id} in phase "
|
|
1546
|
+
f"{existing.phase.value} holds operator actions ({named}) — see 'status', "
|
|
1547
|
+
f"or use '{command} --force' to discard the run (the comments stay on GitHub)"
|
|
1548
|
+
),
|
|
1549
|
+
file=sys.stderr,
|
|
1550
|
+
)
|
|
1551
|
+
return 2, False
|
|
1552
|
+
return None, False
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def cmd_run(args) -> int:
|
|
1556
|
+
cfg = _load_cfg(args)
|
|
1557
|
+
engine = _engine_for(args, cfg)
|
|
1558
|
+
engine.validate_config()
|
|
1559
|
+
|
|
1560
|
+
if args.dry_run:
|
|
1561
|
+
# Fully side-effect-free: in-memory state, plan printed, nothing written.
|
|
1562
|
+
engine.new_run(args.epic, args.issue)
|
|
1563
|
+
outcomes = engine.run(max_steps=args.max_steps, dry_run=True, allow_merge=args.allow_merge)
|
|
1564
|
+
for o in outcomes:
|
|
1565
|
+
assert o.plan is not None
|
|
1566
|
+
print_plan(o.plan, full_prompt=args.full_prompt)
|
|
1567
|
+
return 0
|
|
1568
|
+
|
|
1569
|
+
# A state directory inside the scheduler root is a namespace's, never a
|
|
1570
|
+
# standalone run's (ADR 0008 D17.3); refused before the lock is taken.
|
|
1571
|
+
engine.refuse_scheduler_state_dir("run")
|
|
1572
|
+
# Inspect, decide, quarantine, write the first state AND execute under
|
|
1573
|
+
# one continuous lock: a verdict taken before the lock could go stale
|
|
1574
|
+
# (another controller may have repaired, replaced or created state.json
|
|
1575
|
+
# in the meantime) and would then quarantine or overwrite a perfectly
|
|
1576
|
+
# valid run; releasing the lock between the first save and the execution
|
|
1577
|
+
# would let a second controller take over the repository and both would
|
|
1578
|
+
# then run agents and persist state over each other.
|
|
1579
|
+
with engine.locked():
|
|
1580
|
+
rc, corrupt = _existing_run_guard(engine, args.force)
|
|
1581
|
+
if rc is not None:
|
|
1582
|
+
return rc
|
|
1583
|
+
engine.new_run(args.epic, args.issue)
|
|
1584
|
+
assert engine.state is not None
|
|
1585
|
+
# A scheduler-owned issue is refused before anything is written
|
|
1586
|
+
# (ADR 0008 D17.2): no quarantine, no state, no run log.
|
|
1587
|
+
_guard_scheduler_ownership(engine, engine.state, "run")
|
|
1588
|
+
# What later commands of the run are checked against (#219).
|
|
1589
|
+
engine.record_run_configuration()
|
|
1590
|
+
if corrupt:
|
|
1591
|
+
moved = engine.quarantine_state()
|
|
1592
|
+
print(f"autoforge: moved unreadable state file aside: {moved}", file=sys.stderr)
|
|
1593
|
+
engine.save()
|
|
1594
|
+
_attach_terminal(engine)
|
|
1595
|
+
outcomes = engine.run(
|
|
1596
|
+
max_steps=args.max_steps,
|
|
1597
|
+
dry_run=False,
|
|
1598
|
+
allow_merge=args.allow_merge,
|
|
1599
|
+
on_outcome=_print_outcome_now,
|
|
1600
|
+
)
|
|
1601
|
+
return _finish(engine, outcomes, args.allow_merge, printed=True)
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def cmd_local_init(args) -> int:
|
|
1605
|
+
"""Create features/<slug>.md under the repository lock. No --force, no overwrite.
|
|
1606
|
+
|
|
1607
|
+
The controller lock covers this the same way it covers `local run`. A
|
|
1608
|
+
feature specification is not a scratch file: a run freezes its SHA-256 and
|
|
1609
|
+
re-checks it around every phase, so `local init --force` racing an active
|
|
1610
|
+
run could rewrite the specification while the controller was hashing it,
|
|
1611
|
+
rendering it into a prompt or verifying it — which is precisely the
|
|
1612
|
+
"an agent cannot rewrite its own acceptance criteria" guarantee, undone
|
|
1613
|
+
from the operator's side. One controller at a time owns the repository,
|
|
1614
|
+
and creating this file is a controller write like any other.
|
|
1615
|
+
"""
|
|
1616
|
+
cfg = _load_cfg(args)
|
|
1617
|
+
engine = _local_engine_for(args, cfg)
|
|
1618
|
+
with engine.locked():
|
|
1619
|
+
path = init_feature_file(
|
|
1620
|
+
engine.workspace(),
|
|
1621
|
+
args.slug,
|
|
1622
|
+
feature_dir=args.feature_dir or cfg.local.feature_dir,
|
|
1623
|
+
overwrite=args.force,
|
|
1624
|
+
)
|
|
1625
|
+
rel = os.path.relpath(path, os.getcwd())
|
|
1626
|
+
print(f"Created {rel}")
|
|
1627
|
+
print()
|
|
1628
|
+
print("Next:")
|
|
1629
|
+
print(f" 1. Describe the feature in {rel} (problem, requirements, acceptance criteria).")
|
|
1630
|
+
print(f" 2. autoforge local run {rel}")
|
|
1631
|
+
print()
|
|
1632
|
+
print("The specification is frozen (SHA-256) when the run starts: edit it before, not during.")
|
|
1633
|
+
return 0
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def cmd_local_run(args) -> int:
|
|
1637
|
+
"""Create a LOCAL run from a feature Markdown file and advance it.
|
|
1638
|
+
|
|
1639
|
+
Mirrors ``cmd_run`` minus everything GitHub: no EPIC, no issue, no merge
|
|
1640
|
+
gate. The engine never constructs a GitHubClient for a LOCAL run.
|
|
1641
|
+
"""
|
|
1642
|
+
cfg = _load_cfg(args)
|
|
1643
|
+
engine = _local_engine_for(args, cfg)
|
|
1644
|
+
|
|
1645
|
+
if args.dry_run:
|
|
1646
|
+
# Fully side-effect-free: in-memory state, plan printed, nothing
|
|
1647
|
+
# written, no agent invoked and no validation command executed.
|
|
1648
|
+
engine.new_local_run(args.feature, allow_dirty=args.allow_dirty)
|
|
1649
|
+
engine.validate_config()
|
|
1650
|
+
outcomes = engine.run(max_steps=args.max_steps, dry_run=True)
|
|
1651
|
+
for o in outcomes:
|
|
1652
|
+
assert o.plan is not None
|
|
1653
|
+
print_plan(o.plan, full_prompt=args.full_prompt)
|
|
1654
|
+
return 0
|
|
1655
|
+
|
|
1656
|
+
engine.refuse_scheduler_state_dir("local run")
|
|
1657
|
+
# One continuous lock over inspect -> decide -> first save -> execute,
|
|
1658
|
+
# for the same reasons as `cmd_run`.
|
|
1659
|
+
with engine.locked():
|
|
1660
|
+
rc, corrupt = _existing_run_guard(engine, args.force, "local run")
|
|
1661
|
+
if rc is not None:
|
|
1662
|
+
return rc
|
|
1663
|
+
engine.new_local_run(args.feature, allow_dirty=args.allow_dirty)
|
|
1664
|
+
engine.validate_config()
|
|
1665
|
+
assert engine.state is not None
|
|
1666
|
+
# The sandbox mode later commands of the run are held to (#280).
|
|
1667
|
+
engine.record_run_configuration()
|
|
1668
|
+
if corrupt:
|
|
1669
|
+
moved = engine.quarantine_state()
|
|
1670
|
+
print(f"autoforge: moved unreadable state file aside: {moved}", file=sys.stderr)
|
|
1671
|
+
engine.save()
|
|
1672
|
+
_attach_terminal(engine)
|
|
1673
|
+
outcomes = engine.run(
|
|
1674
|
+
max_steps=args.max_steps, dry_run=False, on_outcome=_print_outcome_now
|
|
1675
|
+
)
|
|
1676
|
+
return _finish(engine, outcomes, allow_merge=False, printed=True)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def cmd_step(args) -> int:
|
|
1680
|
+
engine = _engine_for(args)
|
|
1681
|
+
_bind_standalone_run(engine, args, "step")
|
|
1682
|
+
if args.dry_run:
|
|
1683
|
+
# Read-only: no lock, nothing written.
|
|
1684
|
+
state = engine.load()
|
|
1685
|
+
if state.phase not in TERMINAL_PHASES:
|
|
1686
|
+
_check_configuration(engine, args, "step", dry_run=True)
|
|
1687
|
+
outcome = engine.step(dry_run=True, allow_merge=args.allow_merge)
|
|
1688
|
+
if outcome.plan is not None:
|
|
1689
|
+
print_plan(outcome.plan, full_prompt=args.full_prompt)
|
|
1690
|
+
return 0
|
|
1691
|
+
# Load and execute under one lock: a snapshot loaded before the lock
|
|
1692
|
+
# could already have been replaced by another controller.
|
|
1693
|
+
with engine.locked():
|
|
1694
|
+
state = engine.load()
|
|
1695
|
+
_guard_scheduler_ownership(engine, state, "step")
|
|
1696
|
+
if state.phase not in TERMINAL_PHASES:
|
|
1697
|
+
_check_configuration(engine, args, "step", dry_run=False)
|
|
1698
|
+
_attach_terminal(engine)
|
|
1699
|
+
outcome = engine.step(dry_run=False, allow_merge=args.allow_merge)
|
|
1700
|
+
return _finish(engine, [outcome], args.allow_merge)
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def cmd_resume(args) -> int:
|
|
1704
|
+
engine = _engine_for(args)
|
|
1705
|
+
_bind_standalone_run(engine, args, "resume")
|
|
1706
|
+
if args.dry_run:
|
|
1707
|
+
# Read-only: no lock, nothing written.
|
|
1708
|
+
state = engine.load()
|
|
1709
|
+
rc = _resume_holding_state(engine, state, args.allow_merge)
|
|
1710
|
+
if rc is not None:
|
|
1711
|
+
return rc
|
|
1712
|
+
_check_configuration(engine, args, "resume", dry_run=True)
|
|
1713
|
+
outcomes = engine.run(max_steps=args.max_steps, dry_run=True, allow_merge=args.allow_merge)
|
|
1714
|
+
for o in outcomes:
|
|
1715
|
+
assert o.plan is not None
|
|
1716
|
+
print_plan(o.plan, full_prompt=args.full_prompt)
|
|
1717
|
+
return 0
|
|
1718
|
+
# Load, decide and execute under one lock: a snapshot loaded before the
|
|
1719
|
+
# lock could already have been replaced by another controller.
|
|
1720
|
+
with engine.locked():
|
|
1721
|
+
state = engine.load()
|
|
1722
|
+
_guard_scheduler_ownership(engine, state, "resume")
|
|
1723
|
+
rc = _resume_holding_state(engine, state, args.allow_merge)
|
|
1724
|
+
if rc is not None:
|
|
1725
|
+
return rc
|
|
1726
|
+
_check_configuration(engine, args, "resume", dry_run=False)
|
|
1727
|
+
_attach_terminal(engine)
|
|
1728
|
+
outcomes = engine.run(
|
|
1729
|
+
max_steps=args.max_steps,
|
|
1730
|
+
dry_run=False,
|
|
1731
|
+
allow_merge=args.allow_merge,
|
|
1732
|
+
on_outcome=_print_outcome_now,
|
|
1733
|
+
)
|
|
1734
|
+
return _finish(engine, outcomes, args.allow_merge, printed=True)
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def cmd_unblock(args) -> int:
|
|
1738
|
+
"""``autoforge unblock --reason ...``: exit 0 when a phase was re-entered.
|
|
1739
|
+
|
|
1740
|
+
The decision is the engine's (:meth:`ControllerEngine.unblock`): a
|
|
1741
|
+
refusal leaves the run BLOCKED, prints why, and exits 1; a run that is
|
|
1742
|
+
not BLOCKED is a StateTransitionError (exit 2). The run is not resumed
|
|
1743
|
+
here: the operator reviews the outcome and runs ``resume``.
|
|
1744
|
+
"""
|
|
1745
|
+
engine = _engine_for(args)
|
|
1746
|
+
if not args.state_dir:
|
|
1747
|
+
_bind_existing_run(engine)
|
|
1748
|
+
approve = str(getattr(args, "approve_action", "") or "")
|
|
1749
|
+
raw_decline = getattr(args, "decline_action", None) or []
|
|
1750
|
+
decline = [raw_decline] if isinstance(raw_decline, str) else [str(d) for d in raw_decline]
|
|
1751
|
+
bind = [(str(a), str(u)) for a, u in getattr(args, "bind_evidence", None) or []]
|
|
1752
|
+
# The words a configuration refusal prints for this command (#219).
|
|
1753
|
+
words = ("unblock", "--reason", "<why>")
|
|
1754
|
+
if args.dry_run:
|
|
1755
|
+
# Reads GitHub; writes nothing (no state, no run log, no lock).
|
|
1756
|
+
state = engine.load()
|
|
1757
|
+
if state.phase == Phase.BLOCKED:
|
|
1758
|
+
_check_configuration(engine, args, *words, dry_run=True)
|
|
1759
|
+
outcome = engine.unblock(
|
|
1760
|
+
args.reason,
|
|
1761
|
+
dry_run=True,
|
|
1762
|
+
approve_action=approve,
|
|
1763
|
+
decline_action=decline,
|
|
1764
|
+
bind_evidence=bind,
|
|
1765
|
+
)
|
|
1766
|
+
print_unblock_outcome(outcome)
|
|
1767
|
+
return 0
|
|
1768
|
+
# Load, decide and write under one lock, like `step`. An accepted
|
|
1769
|
+
# configuration change is its own recorded write, made before the
|
|
1770
|
+
# decision: a refused unblock leaves it in place.
|
|
1771
|
+
with engine.locked():
|
|
1772
|
+
state = engine.load()
|
|
1773
|
+
_guard_scheduler_ownership(engine, state, "unblock")
|
|
1774
|
+
if state.phase == Phase.BLOCKED:
|
|
1775
|
+
_check_configuration(engine, args, *words, dry_run=False)
|
|
1776
|
+
outcome = engine.unblock(
|
|
1777
|
+
args.reason, approve_action=approve, decline_action=decline, bind_evidence=bind
|
|
1778
|
+
)
|
|
1779
|
+
print_unblock_outcome(outcome)
|
|
1780
|
+
if not outcome.unblocked:
|
|
1781
|
+
return 1
|
|
1782
|
+
if engine.state_dir_in_scheduler_root():
|
|
1783
|
+
# A scheduler namespace's run (ADR 0008 D17.3): a standalone
|
|
1784
|
+
# 'resume' would refuse this state directory.
|
|
1785
|
+
print(
|
|
1786
|
+
"continue with the scheduler: 'autoforge epic resume' "
|
|
1787
|
+
"(a standalone 'resume' refuses this state directory)"
|
|
1788
|
+
)
|
|
1789
|
+
return 0
|
|
1790
|
+
# With the flags this command was given, as a block reason's 'resume'
|
|
1791
|
+
# line does (#189): a bare 'resume' would find no run or other defaults.
|
|
1792
|
+
print(redact(f"continue with: {engine.resume_command()}"))
|
|
1793
|
+
return 0
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def print_unblock_outcome(o: UnblockOutcome) -> None:
|
|
1797
|
+
# State-derived and operator-supplied text; redacted whole like a step outcome.
|
|
1798
|
+
print(redact(f"[{o.run_id}] {o.message}"))
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def _resume_holding_state(
|
|
1802
|
+
engine: ControllerEngine, state: AutoForgeState, allow_merge: bool
|
|
1803
|
+
) -> int | None:
|
|
1804
|
+
"""Exit code when ``resume`` has nothing to execute; None when it does."""
|
|
1805
|
+
if state.phase == Phase.READY_FOR_MERGE and not engine.merge_gate_open(allow_merge):
|
|
1806
|
+
# Holding state: nothing runs unless the merge gate is open. With the
|
|
1807
|
+
# gate open, engine.run() performs the controller-side pre-merge
|
|
1808
|
+
# verification instead (bounded re-checks of inconclusive GitHub data
|
|
1809
|
+
# happen there, one attempt per resume), the review certificate's
|
|
1810
|
+
# live check included (#175); the banner says only whether it
|
|
1811
|
+
# applies against state and the configuration in force.
|
|
1812
|
+
print_ready_banner(state, certificate=_review_certificate_check(engine, state))
|
|
1813
|
+
return 0
|
|
1814
|
+
if state.phase in TERMINAL_PHASES:
|
|
1815
|
+
if state.phase == Phase.DONE:
|
|
1816
|
+
print(redact(f"workflow already DONE (run {state.run_id}) — nothing to do"))
|
|
1817
|
+
return 0
|
|
1818
|
+
how = (
|
|
1819
|
+
"and run 'autoforge unblock --reason ...' to have the controller re-inspect "
|
|
1820
|
+
"GitHub and re-enter a phase, or start a new run"
|
|
1821
|
+
if state.phase == Phase.BLOCKED and state.mode != WorkflowMode.LOCAL
|
|
1822
|
+
else "and start a new run"
|
|
1823
|
+
)
|
|
1824
|
+
print(
|
|
1825
|
+
redact(
|
|
1826
|
+
f"run {state.run_id} is in terminal phase {state.phase.value}: "
|
|
1827
|
+
f"{state.block_reason or '-'} — inspect {engine.paths.logs_dir}/ {how}"
|
|
1828
|
+
)
|
|
1829
|
+
)
|
|
1830
|
+
return 1
|
|
1831
|
+
return None
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _load_replan_journal(state: AutoForgeState) -> ReplanTransaction | None:
|
|
1835
|
+
"""Read the persisted replan journal the way the engine reads it.
|
|
1836
|
+
|
|
1837
|
+
``None`` when the run has no journal. A journal whose fields cannot be
|
|
1838
|
+
read comes back ``REJECTED`` with ``journal_defects`` naming them, for
|
|
1839
|
+
status to describe rather than die on. The one shape ``from_dict``
|
|
1840
|
+
refuses outright -- a journal that is not a JSON object -- never reaches
|
|
1841
|
+
here: ``AutoForgeState.from_dict`` rejects it, so ``load()`` fails both
|
|
1842
|
+
output modes at the state boundary (exit 2) before anything is printed.
|
|
1843
|
+
"""
|
|
1844
|
+
if not state.replan_transaction:
|
|
1845
|
+
return None
|
|
1846
|
+
return ReplanTransaction.from_dict(state.replan_transaction)
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _replan_journal_summary(txn: ReplanTransaction | None) -> dict | None:
|
|
1850
|
+
"""The derived ``replan_journal`` field of ``status --json``.
|
|
1851
|
+
|
|
1852
|
+
``status --json`` prints ``replan_transaction`` as persisted, so the
|
|
1853
|
+
forensic evidence of a corrupt journal is never rewritten -- which leaves
|
|
1854
|
+
a consumer no way to tell a usable journal from a corrupt one without
|
|
1855
|
+
reimplementing the validation. This sibling field answers that from the
|
|
1856
|
+
same ``ReplanTransaction`` the human ``status`` prints its ``stage`` and
|
|
1857
|
+
``journal`` lines from, so the two outputs cannot disagree. It is never
|
|
1858
|
+
merged into the raw journal.
|
|
1859
|
+
"""
|
|
1860
|
+
if txn is None:
|
|
1861
|
+
return None
|
|
1862
|
+
return {
|
|
1863
|
+
"readable": not txn.journal_defects,
|
|
1864
|
+
"stage": txn.stage.value,
|
|
1865
|
+
"defects": list(txn.journal_defects),
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
# -- epic commands (ADR 0008, #183) ----------------------------------------------
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def _epic_graph(cfg: AutoForgeConfig) -> GraphReader:
|
|
1873
|
+
"""The GitHub reads the ``epic`` commands make (indirection so tests can inject a fake)."""
|
|
1874
|
+
return GitHubClient(gh_command=cfg.github.command, timeout_seconds=cfg.github.timeout_seconds)
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
def _epic_scheduler_options() -> dict:
|
|
1878
|
+
"""Extra :class:`SchedulerInputs` for ``epic run``/``resume`` (a test's injection point)."""
|
|
1879
|
+
return {}
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _epic_flags(args) -> tuple[str, ...]:
|
|
1883
|
+
"""What every command an ``epic`` command prints repeats: ``--config``, as typed."""
|
|
1884
|
+
return ("--config", args.config) if args.config else ()
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def _say(text: str) -> None:
|
|
1888
|
+
print(redact(f"autoforge epic: {text}"), file=sys.stderr, flush=True)
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
def cmd_epic(args) -> int:
|
|
1892
|
+
if args.state_dir:
|
|
1893
|
+
raise ConfigurationError(
|
|
1894
|
+
"'epic' commands refuse --state-dir: each issue's state lives in its own "
|
|
1895
|
+
"scheduler namespace, which 'autoforge epic status <epic>' names"
|
|
1896
|
+
)
|
|
1897
|
+
# Before anything is read or written here: an EPIC of another repository
|
|
1898
|
+
# than this checkout's would bind its issues in this checkout's namespaces,
|
|
1899
|
+
# or stop or archive this checkout's EPIC of the same number.
|
|
1900
|
+
checkout_epic(os.getcwd(), args.epic)
|
|
1901
|
+
if args.epic_command == "run":
|
|
1902
|
+
return cmd_epic_run(args, resume=False)
|
|
1903
|
+
if args.epic_command == "resume":
|
|
1904
|
+
return cmd_epic_run(args, resume=True)
|
|
1905
|
+
if args.epic_command == "status":
|
|
1906
|
+
return cmd_epic_status(args)
|
|
1907
|
+
if args.epic_command == "stop":
|
|
1908
|
+
return cmd_epic_stop(args)
|
|
1909
|
+
if args.epic_command == "archive":
|
|
1910
|
+
return cmd_epic_archive(args)
|
|
1911
|
+
raise ConfigurationError(f"unknown epic command {args.epic_command}")
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
def cmd_epic_run(args, *, resume: bool) -> int:
|
|
1915
|
+
"""``epic run`` and ``epic resume``: one scheduler invocation (#182) and its exit status.
|
|
1916
|
+
|
|
1917
|
+
The scheduler makes no run/resume split; ``epic run``'s refusal of open
|
|
1918
|
+
claims and its ``--force`` run inside it, holding L0, before anything is
|
|
1919
|
+
written. A run that ends with nothing admissible while claims of the
|
|
1920
|
+
EPIC are still open exits :data:`~autoforge.epic_commands.EXIT_OPEN_CLAIMS`.
|
|
1921
|
+
"""
|
|
1922
|
+
cfg = _load_cfg(args)
|
|
1923
|
+
flags = _epic_flags(args)
|
|
1924
|
+
epic_url = parse_issue_url(args.epic).canonical
|
|
1925
|
+
graph = _epic_graph(cfg)
|
|
1926
|
+
repository = os.getcwd()
|
|
1927
|
+
standalone = os.path.abspath(cfg.state_dir)
|
|
1928
|
+
if not resume and args.dry_run:
|
|
1929
|
+
plan = dry_run_plan(
|
|
1930
|
+
graph,
|
|
1931
|
+
repository_root=repository,
|
|
1932
|
+
epic_url=epic_url,
|
|
1933
|
+
max_parallel=args.max_parallel or cfg.scheduler.max_parallel,
|
|
1934
|
+
standalone_state_dir=standalone,
|
|
1935
|
+
force=args.force,
|
|
1936
|
+
flags=flags,
|
|
1937
|
+
)
|
|
1938
|
+
for line in plan.lines:
|
|
1939
|
+
print(line)
|
|
1940
|
+
return plan.exit_code
|
|
1941
|
+
inputs = SchedulerInputs(
|
|
1942
|
+
repository=repository,
|
|
1943
|
+
epic_url=epic_url,
|
|
1944
|
+
config=cfg,
|
|
1945
|
+
graph=graph,
|
|
1946
|
+
config_path=absolute_spelling(args.config) if args.config else "",
|
|
1947
|
+
max_parallel=args.max_parallel,
|
|
1948
|
+
accept_config_change=resume and args.accept_config_change,
|
|
1949
|
+
standalone_state_dir=standalone,
|
|
1950
|
+
**_epic_scheduler_options(),
|
|
1951
|
+
)
|
|
1952
|
+
scheduler = EpicScheduler(inputs)
|
|
1953
|
+
if not resume:
|
|
1954
|
+
inputs.before_start = epic_run_guard(
|
|
1955
|
+
force=args.force, flags=flags, epic_url=epic_url, say=scheduler.say
|
|
1956
|
+
)
|
|
1957
|
+
code = scheduler.run()
|
|
1958
|
+
if code != 0:
|
|
1959
|
+
return code
|
|
1960
|
+
left = claims_left_open(scheduler.coordination_root, scheduler.epic_ref)
|
|
1961
|
+
if not left:
|
|
1962
|
+
return code
|
|
1963
|
+
for line in left_open_lines(left, flags, epic_url):
|
|
1964
|
+
scheduler.say(line)
|
|
1965
|
+
return EXIT_OPEN_CLAIMS
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
def cmd_epic_status(args) -> int:
|
|
1969
|
+
cfg = _load_cfg(args)
|
|
1970
|
+
report = collect_status(
|
|
1971
|
+
_epic_graph(cfg),
|
|
1972
|
+
repository_root=os.getcwd(),
|
|
1973
|
+
epic_url=args.epic,
|
|
1974
|
+
standalone_state_dir=os.path.abspath(cfg.state_dir),
|
|
1975
|
+
flags=_epic_flags(args),
|
|
1976
|
+
)
|
|
1977
|
+
if args.as_json:
|
|
1978
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
1979
|
+
else:
|
|
1980
|
+
for line in report.lines():
|
|
1981
|
+
print(line)
|
|
1982
|
+
return report.exit_code
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def _epic_repository_id(args) -> Callable[[str], int]:
|
|
1986
|
+
"""GitHub's numeric id of a repository, as ``epic stop`` and ``epic archive`` read it.
|
|
1987
|
+
|
|
1988
|
+
Called once a ``scheduler.json`` or a binding is found to compare with,
|
|
1989
|
+
so the configuration is loaded then.
|
|
1990
|
+
"""
|
|
1991
|
+
|
|
1992
|
+
def read(repository: str) -> int:
|
|
1993
|
+
return _epic_graph(_load_cfg(args)).get_repository_id(repository)
|
|
1994
|
+
|
|
1995
|
+
return read
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
def cmd_epic_stop(args) -> int:
|
|
1999
|
+
"""``epic stop``: lock-free; GitHub is read for the repository id ``scheduler.json`` records."""
|
|
2000
|
+
epic = parse_issue_url(args.epic)
|
|
2001
|
+
mode = StopMode.NOW if args.now else StopMode.DRAIN
|
|
2002
|
+
outcome = stop_scheduler(
|
|
2003
|
+
coordination_root_of(os.getcwd()), epic, mode, _epic_repository_id(args)
|
|
2004
|
+
)
|
|
2005
|
+
for line in outcome.lines:
|
|
2006
|
+
print(line)
|
|
2007
|
+
return outcome.exit_code
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def cmd_epic_archive(args) -> int:
|
|
2011
|
+
"""``epic archive``: under L0 (a held one is exit 2); GitHub is read for the binding's id."""
|
|
2012
|
+
epic = parse_issue_url(args.epic)
|
|
2013
|
+
lock_path = repository_lock_path(os.getcwd())
|
|
2014
|
+
with ControllerLock(lock_path):
|
|
2015
|
+
report = archive_issue(
|
|
2016
|
+
lock_path.parent.parent, epic.canonical, args.issue, _epic_repository_id(args)
|
|
2017
|
+
)
|
|
2018
|
+
for line in report.lines():
|
|
2019
|
+
print(line)
|
|
2020
|
+
return 0
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def cmd_status(args) -> int:
|
|
2024
|
+
engine = _engine_for(args)
|
|
2025
|
+
if not args.state_dir:
|
|
2026
|
+
_bind_existing_run(engine)
|
|
2027
|
+
state = engine.load() # StateError when missing/corrupt
|
|
2028
|
+
if getattr(args, "action", ""):
|
|
2029
|
+
return _print_action_payload(state, args.action)
|
|
2030
|
+
# One load of the journal feeds every output mode, so what `--json`
|
|
2031
|
+
# derives and what the human summary prints cannot disagree.
|
|
2032
|
+
txn = _load_replan_journal(state)
|
|
2033
|
+
# Everything status shows is state-derived, and state carries text the
|
|
2034
|
+
# agents controlled (`block_reason`, findings, a hand-edited journal), so
|
|
2035
|
+
# both output modes cross the same redaction boundary as the run log.
|
|
2036
|
+
certificate = _review_certificate_check(engine, state)
|
|
2037
|
+
if args.as_json:
|
|
2038
|
+
# to_dict() already carries "mode" plus the local fields, so the JSON
|
|
2039
|
+
# shape stays additive for remote consumers; `replan_journal` and
|
|
2040
|
+
# `acceptance_actions` are derived beside the raw fields, never
|
|
2041
|
+
# merged into them.
|
|
2042
|
+
document = {
|
|
2043
|
+
**state.to_dict(),
|
|
2044
|
+
"replan_journal": _replan_journal_summary(txn),
|
|
2045
|
+
"acceptance_actions": _acceptance_actions(state),
|
|
2046
|
+
"operator_actions": _operator_actions(state),
|
|
2047
|
+
"review_certificate_status": _review_certificate_status(state, certificate),
|
|
2048
|
+
"outer_sandbox": {
|
|
2049
|
+
"mode": engine.config.sandbox.mode,
|
|
2050
|
+
"summary": sandbox_mode_summary(
|
|
2051
|
+
engine.config.sandbox, state.mode == WorkflowMode.LOCAL
|
|
2052
|
+
),
|
|
2053
|
+
},
|
|
2054
|
+
}
|
|
2055
|
+
print(json.dumps(redact_dict(document), indent=2, sort_keys=True))
|
|
2056
|
+
return 0
|
|
2057
|
+
evidence = _render_outer_sandbox(state, engine.config) + _render_validation_evidence(
|
|
2058
|
+
state, engine.config, certificate
|
|
2059
|
+
)
|
|
2060
|
+
if state.mode == WorkflowMode.LOCAL:
|
|
2061
|
+
print(redact(_render_local_status(state, txn, evidence)))
|
|
2062
|
+
return 0
|
|
2063
|
+
if engine.state_dir_in_scheduler_root() and not engine.continue_command:
|
|
2064
|
+
# A scheduler namespace's run (ADR 0008 D17.3) is continued by the
|
|
2065
|
+
# scheduler, as a worker names it: a standalone 'resume' refuses it.
|
|
2066
|
+
config = ("--config", args.config) if args.config else ()
|
|
2067
|
+
engine.continue_command = (*config, "epic", "resume", state.epic_url)
|
|
2068
|
+
actions = _render_acceptance_actions(state, engine) + _render_operator_actions(state, engine)
|
|
2069
|
+
print(redact(_render_status(state, txn, actions, evidence)))
|
|
2070
|
+
return 0
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
# How many records of the current candidate ``status`` lists, newest last.
|
|
2074
|
+
_STATUS_RECORDS = 16
|
|
2075
|
+
_STATUS_TAIL_CHARS = 200
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
def _render_outer_sandbox(state: AutoForgeState, config: AutoForgeConfig) -> list[str]:
|
|
2079
|
+
"""``sandbox.mode`` for ``status`` (ADR 0005 D12.4): configuration only, no probe.
|
|
2080
|
+
|
|
2081
|
+
A mode that may fall back says so; a ``disabled`` run reads "logical
|
|
2082
|
+
isolation only". Whether this host can enforce is ``doctor``'s to show.
|
|
2083
|
+
"""
|
|
2084
|
+
local = state.mode == WorkflowMode.LOCAL
|
|
2085
|
+
summary = sandbox_mode_summary(config.sandbox, local)
|
|
2086
|
+
return ["", "Outer sandbox:", f" {summary}", f" {_sandbox_pin_line(state, config, local)}"]
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
def _sandbox_pin_line(state: AutoForgeState, config: AutoForgeConfig, local: bool) -> str:
|
|
2090
|
+
"""The run's sandbox pin (#280) for ``status``, and whether this configuration keeps it."""
|
|
2091
|
+
if not state.sandbox_pin:
|
|
2092
|
+
return (
|
|
2093
|
+
"pinned: none (the state file predates #280); the next step, resume or unblock "
|
|
2094
|
+
"pins the sandbox.mode it runs under"
|
|
2095
|
+
)
|
|
2096
|
+
pin = SandboxPin.from_dict(state.sandbox_pin)
|
|
2097
|
+
line = f"pinned: {pin.describe()}"
|
|
2098
|
+
if pin.direction(config.sandbox.mode, local=local) < 0:
|
|
2099
|
+
return (
|
|
2100
|
+
f"{line}; this configuration's sandbox.mode, {config.sandbox.mode}, is weaker, so "
|
|
2101
|
+
"step, resume and unblock refuse it unless --accept-config-change"
|
|
2102
|
+
)
|
|
2103
|
+
return f"{line}; step, resume and unblock refuse a weaker sandbox.mode"
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _review_certificate_check(
|
|
2107
|
+
engine: ControllerEngine, state: AutoForgeState
|
|
2108
|
+
) -> tuple[bool | None, str] | None:
|
|
2109
|
+
"""Whether the held review certificate applies now (ADR 0006 D12.3), for ``status``.
|
|
2110
|
+
|
|
2111
|
+
``None`` when none is held; otherwise ``(applicable, reason)``, with
|
|
2112
|
+
``applicable`` ``None`` and the reason saying why when the check could
|
|
2113
|
+
not run (a LOCAL contract that no longer matches this invocation, say).
|
|
2114
|
+
Reads the configuration in force, never GitHub: the live comparison is
|
|
2115
|
+
the merge gate's.
|
|
2116
|
+
"""
|
|
2117
|
+
if not state.review_certificate:
|
|
2118
|
+
return None
|
|
2119
|
+
try:
|
|
2120
|
+
problem = engine.review_certificate_problem()
|
|
2121
|
+
except AutoForgeError as exc:
|
|
2122
|
+
return None, f"not checked: {exc}"
|
|
2123
|
+
return not problem, problem
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def _review_certificate_status(
|
|
2127
|
+
state: AutoForgeState, check: tuple[bool | None, str] | None
|
|
2128
|
+
) -> dict | None:
|
|
2129
|
+
"""The derived ``review_certificate_status`` of ``status --json`` (#174); ``None`` for none.
|
|
2130
|
+
|
|
2131
|
+
Beside the raw ``review_certificate``, never merged into it: the fields
|
|
2132
|
+
a consumer reads (kind, candidate, round, set and policy digests,
|
|
2133
|
+
``issued_at``), its applicability against state and the configuration
|
|
2134
|
+
in force with the reason when it does not apply, and the fixed note that
|
|
2135
|
+
it is no merge permission.
|
|
2136
|
+
"""
|
|
2137
|
+
certificate = state.review_certificate
|
|
2138
|
+
if not certificate or check is None:
|
|
2139
|
+
return None
|
|
2140
|
+
applicable, problem = check
|
|
2141
|
+
return {
|
|
2142
|
+
"kind": certificate["kind"],
|
|
2143
|
+
"candidate": certificate["candidate"],
|
|
2144
|
+
"review_round": certificate["review_round"],
|
|
2145
|
+
"validation_set_digest": certificate["validation_set_digest"],
|
|
2146
|
+
"policy_digest": certificate["policy_digest"],
|
|
2147
|
+
"issued_at": certificate["issued_at"],
|
|
2148
|
+
"applicable": applicable,
|
|
2149
|
+
"problem": problem,
|
|
2150
|
+
"note": NOT_A_PERMISSION,
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
def _render_validation_evidence(
|
|
2155
|
+
state: AutoForgeState,
|
|
2156
|
+
config: AutoForgeConfig,
|
|
2157
|
+
certificate: tuple[bool | None, str] | None = None,
|
|
2158
|
+
) -> list[str]:
|
|
2159
|
+
"""Candidate validation evidence for ``status`` (ADR 0006 D8.6; the caller redacts).
|
|
2160
|
+
|
|
2161
|
+
The evidence policy (the configured set's size and the mode records are
|
|
2162
|
+
made under, and under ``disabled`` that validators run unsandboxed), a
|
|
2163
|
+
pending attempt marker, the override watermark, a producer's validation
|
|
2164
|
+
failure counter, whether a binding is held, the review certificate (what
|
|
2165
|
+
it names, whether it applies, as ``certificate`` says, and that it is no
|
|
2166
|
+
merge permission), and the records of the current candidate: the REMOTE
|
|
2167
|
+
current HEAD's or the LOCAL bound workspace's, else the newest record's.
|
|
2168
|
+
Read-only: a marker is reported, never converted.
|
|
2169
|
+
"""
|
|
2170
|
+
local = state.mode == WorkflowMode.LOCAL
|
|
2171
|
+
if local:
|
|
2172
|
+
key = "local.validation_commands (the run contract)"
|
|
2173
|
+
count = len(state.local_contract().validation_commands)
|
|
2174
|
+
else:
|
|
2175
|
+
key = "validation.commands"
|
|
2176
|
+
count = len(config.validation.commands)
|
|
2177
|
+
mode = config.sandbox.mode
|
|
2178
|
+
policy = f"{count} definition(s) in {key}; records are made under sandbox.mode {mode}"
|
|
2179
|
+
if not config.sandbox.enabled:
|
|
2180
|
+
policy += (
|
|
2181
|
+
", so validators run unsandboxed as the operator's UID and their records say "
|
|
2182
|
+
"enforced: false"
|
|
2183
|
+
)
|
|
2184
|
+
elif local and mode == "preferred":
|
|
2185
|
+
policy += (
|
|
2186
|
+
", so on a host that cannot enforce the outer sandbox validators run unsandboxed "
|
|
2187
|
+
"as the operator's UID and their records say enforced: false"
|
|
2188
|
+
)
|
|
2189
|
+
lines = ["", "Validation evidence:", f" Policy: {policy}"]
|
|
2190
|
+
records = state.validation_evidence()
|
|
2191
|
+
if state.validation_attempt:
|
|
2192
|
+
marker = parse_marker(state.validation_attempt)
|
|
2193
|
+
lines.append(
|
|
2194
|
+
f" Pending attempt: definition [{marker.index}] {' '.join(marker.argv)!r} for "
|
|
2195
|
+
f"{marker.candidate.describe()}, marked {state.validation_attempt['marked_at']} "
|
|
2196
|
+
"(the next step or resume records it INTERRUPTED and runs it again)"
|
|
2197
|
+
)
|
|
2198
|
+
if state.validation_override_seq is not None:
|
|
2199
|
+
lines.append(f" Override watermark: seq {state.validation_override_seq}")
|
|
2200
|
+
counter = state.producer_validation_failure
|
|
2201
|
+
if counter:
|
|
2202
|
+
lines.append(
|
|
2203
|
+
f" {counter['phase']} validation failures: {counter['count']} of "
|
|
2204
|
+
f"{MAX_FAILURE_COUNT} (last: record #{counter['last_record_seq']}, commit "
|
|
2205
|
+
f"{counter['candidate_commit'][:12]})"
|
|
2206
|
+
)
|
|
2207
|
+
if state.review_evidence:
|
|
2208
|
+
lines.append(
|
|
2209
|
+
f" REVIEW evidence: round {state.review_evidence['review_round']}, "
|
|
2210
|
+
f"{len(state.review_evidence['records'])} record(s)"
|
|
2211
|
+
)
|
|
2212
|
+
if state.review_certificate:
|
|
2213
|
+
lines.append(f" Review certificate: {describe_certificate(state.review_certificate)}")
|
|
2214
|
+
if certificate is None:
|
|
2215
|
+
applies = "not checked"
|
|
2216
|
+
else:
|
|
2217
|
+
applicable, problem = certificate
|
|
2218
|
+
applies = "yes" if applicable else problem if applicable is None else f"no: {problem}"
|
|
2219
|
+
lines.append(f" Applicable: {applies}")
|
|
2220
|
+
lines.append(f" Note: {NOT_A_PERMISSION}")
|
|
2221
|
+
if not records:
|
|
2222
|
+
lines.append(" Records: none")
|
|
2223
|
+
return lines
|
|
2224
|
+
current = records[-1].candidate
|
|
2225
|
+
for view in reversed(records):
|
|
2226
|
+
candidate = view.candidate
|
|
2227
|
+
if (
|
|
2228
|
+
isinstance(candidate, RemoteCandidate) and candidate.commit == state.current_head_sha
|
|
2229
|
+
) or (
|
|
2230
|
+
isinstance(candidate, LocalCandidate)
|
|
2231
|
+
and candidate.fingerprint == state.workspace_fingerprint
|
|
2232
|
+
):
|
|
2233
|
+
current = candidate
|
|
2234
|
+
break
|
|
2235
|
+
mine = [r for r in records if r.candidate.identity == current.identity]
|
|
2236
|
+
shown = mine[-_STATUS_RECORDS:]
|
|
2237
|
+
lines.append(
|
|
2238
|
+
f" Records for {current.describe()}: {len(mine)} of {len(records)} retained"
|
|
2239
|
+
+ (f" (the newest {len(shown)} shown)" if len(shown) < len(mine) else "")
|
|
2240
|
+
)
|
|
2241
|
+
for view in shown:
|
|
2242
|
+
lines.append(f" {record_summary(view)}")
|
|
2243
|
+
if view.outcome in (Outcome.FAIL, Outcome.TIMEOUT):
|
|
2244
|
+
output = view.raw["output"]
|
|
2245
|
+
tail = " ".join((output["stderr_tail"] or output["stdout_tail"]).split())
|
|
2246
|
+
if tail:
|
|
2247
|
+
lines.append(f" output tail: {tail[-_STATUS_TAIL_CHARS:]}")
|
|
2248
|
+
return lines
|
|
2249
|
+
|
|
2250
|
+
|
|
2251
|
+
def _acceptance_records(state: AutoForgeState) -> list[EffectRecord]:
|
|
2252
|
+
"""The persisted K9 records of the current plan (#211); none for a LOCAL run."""
|
|
2253
|
+
if state.mode == WorkflowMode.LOCAL:
|
|
2254
|
+
return []
|
|
2255
|
+
return [r for r in state.phase_effects().records if r.kind is EffectKind.ACCEPTANCE_COMMENT]
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def _print_action_payload(state: AutoForgeState, action_id: str) -> int:
|
|
2259
|
+
"""``status --action``: the exact payload bytes of a persisted action (#211, #243).
|
|
2260
|
+
|
|
2261
|
+
Exactly the body the controller would publish (and an operator may post
|
|
2262
|
+
instead), with nothing before or after it; it is redaction-invariant by
|
|
2263
|
+
the record's validation, so nothing is rewritten here. An id no
|
|
2264
|
+
persisted record has (a declined or stale action keeps no payload), or a
|
|
2265
|
+
LOCAL run, exits 1 and prints nothing.
|
|
2266
|
+
"""
|
|
2267
|
+
for record in (*_acceptance_records(state), *_operator_records(state)):
|
|
2268
|
+
if record.action_id == action_id.strip():
|
|
2269
|
+
sys.stdout.write(record.body)
|
|
2270
|
+
sys.stdout.flush()
|
|
2271
|
+
return 0
|
|
2272
|
+
return 1
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
def _acceptance_label(record: EffectRecord) -> tuple[str, str]:
|
|
2276
|
+
"""``(state, label)`` of a persisted K9 record, as ``status`` names it (#211)."""
|
|
2277
|
+
if record.stage is Stage.OBSERVED:
|
|
2278
|
+
return "verified", f"verified: {(record.observed or {}).get('url', '-')}"
|
|
2279
|
+
if record.stage is Stage.CONFLICT:
|
|
2280
|
+
return "verification_failed", f"verification failed: {record.reason}"
|
|
2281
|
+
if record.stage is Stage.ATTEMPTED:
|
|
2282
|
+
return (
|
|
2283
|
+
"published_unverified",
|
|
2284
|
+
f"published, not yet verified ({record.attempts}/{MAX_EFFECT_ATTEMPTS})",
|
|
2285
|
+
)
|
|
2286
|
+
if not record.authorized:
|
|
2287
|
+
return "awaiting_approval", "awaiting approval"
|
|
2288
|
+
if record.precondition["authorization"] == AUTHORIZATION_POLICY:
|
|
2289
|
+
return "authorized_by_policy", "authorized by policy, not yet published"
|
|
2290
|
+
return "approved", "approved, not yet published"
|
|
2291
|
+
|
|
2292
|
+
|
|
2293
|
+
def _acceptance_actions(state: AutoForgeState) -> list[dict]:
|
|
2294
|
+
"""The derived ``acceptance_actions`` of ``status --json`` (#211).
|
|
2295
|
+
|
|
2296
|
+
One entry per persisted K9 record of the current plan, then one per
|
|
2297
|
+
``acceptance_outcomes`` entry (declined, stale or verified actions whose
|
|
2298
|
+
plan is gone), each with its ``state``: awaiting_approval, approved,
|
|
2299
|
+
authorized_by_policy, published_unverified, verified,
|
|
2300
|
+
verification_failed, declined or stale. ``verified`` comes only from an
|
|
2301
|
+
observed record or a recorded outcome, never from an acknowledgement.
|
|
2302
|
+
"""
|
|
2303
|
+
from .claims import ACCEPTANCE, scan
|
|
2304
|
+
|
|
2305
|
+
out: list[dict] = []
|
|
2306
|
+
for record in _acceptance_records(state):
|
|
2307
|
+
claim = scan(ACCEPTANCE, record.marker).claims[0]
|
|
2308
|
+
status, label = _acceptance_label(record)
|
|
2309
|
+
out.append(
|
|
2310
|
+
{
|
|
2311
|
+
"action_id": record.action_id,
|
|
2312
|
+
"kind": "issue_comment",
|
|
2313
|
+
"target": record.target["issue_url"],
|
|
2314
|
+
"pr": record.owner.pr_url,
|
|
2315
|
+
"round": claim.round,
|
|
2316
|
+
"finding_id": claim.finding_id,
|
|
2317
|
+
"authorization": record.precondition["authorization"],
|
|
2318
|
+
"state": status,
|
|
2319
|
+
"label": label,
|
|
2320
|
+
"payload_chars": len(record.body),
|
|
2321
|
+
"payload_sha256": sha256_text(record.body),
|
|
2322
|
+
"comment_url": str((record.observed or {}).get("url", "")),
|
|
2323
|
+
}
|
|
2324
|
+
)
|
|
2325
|
+
for outcome in state.acceptance_outcomes:
|
|
2326
|
+
out.append(
|
|
2327
|
+
{
|
|
2328
|
+
"action_id": outcome["action_id"],
|
|
2329
|
+
"kind": "issue_comment",
|
|
2330
|
+
"target": state.current_issue_url,
|
|
2331
|
+
"pr": state.current_pr_url,
|
|
2332
|
+
"round": outcome["round"],
|
|
2333
|
+
"finding_id": outcome["finding_id"],
|
|
2334
|
+
"state": outcome["outcome"],
|
|
2335
|
+
"label": (
|
|
2336
|
+
f"verified: {outcome['comment_url']}"
|
|
2337
|
+
if outcome["outcome"] == "verified"
|
|
2338
|
+
else outcome["outcome"]
|
|
2339
|
+
),
|
|
2340
|
+
"comment_url": outcome["comment_url"],
|
|
2341
|
+
"reason": outcome["reason"],
|
|
2342
|
+
"at": outcome["at"],
|
|
2343
|
+
}
|
|
2344
|
+
)
|
|
2345
|
+
return out
|
|
2346
|
+
|
|
2347
|
+
|
|
2348
|
+
def _render_acceptance_actions(state: AutoForgeState, engine: ControllerEngine) -> list[str]:
|
|
2349
|
+
"""The human ``Acceptance action`` blocks of ``status`` (#211); none without one."""
|
|
2350
|
+
from .claims import ACCEPTANCE, scan
|
|
2351
|
+
|
|
2352
|
+
lines: list[str] = []
|
|
2353
|
+
records = state.phase_effects().records if state.mode != WorkflowMode.LOCAL else ()
|
|
2354
|
+
push = next((r for r in records if r.kind is EffectKind.PUSH), None)
|
|
2355
|
+
for record in _acceptance_records(state):
|
|
2356
|
+
claim = scan(ACCEPTANCE, record.marker).claims[0]
|
|
2357
|
+
action = record.action_id
|
|
2358
|
+
_, label = _acceptance_label(record)
|
|
2359
|
+
title = next(
|
|
2360
|
+
(
|
|
2361
|
+
str(f.get("title", ""))
|
|
2362
|
+
for f in state.open_findings
|
|
2363
|
+
if f.get("id") == claim.finding_id
|
|
2364
|
+
),
|
|
2365
|
+
"",
|
|
2366
|
+
)
|
|
2367
|
+
if record.precondition["authorization"] == AUTHORIZATION_POLICY:
|
|
2368
|
+
authorization = "authorized by policy (safety.acceptance_actions: auto)"
|
|
2369
|
+
else:
|
|
2370
|
+
authorization = (
|
|
2371
|
+
"operator approval required (safety.acceptance_actions: "
|
|
2372
|
+
f"{engine.config.safety.acceptance_actions})"
|
|
2373
|
+
)
|
|
2374
|
+
lines += [
|
|
2375
|
+
f"Acceptance action {action} (FIX, review round {claim.round}, finding "
|
|
2376
|
+
f"{claim.finding_id})",
|
|
2377
|
+
" kind: issue_comment",
|
|
2378
|
+
f" target: {record.target['issue_url']}",
|
|
2379
|
+
f" PR: {record.owner.pr_url}",
|
|
2380
|
+
f" finding: {claim.finding_id} {title}".rstrip(),
|
|
2381
|
+
f" authorization: {authorization}",
|
|
2382
|
+
f" state: {label}",
|
|
2383
|
+
f" payload: {len(record.body)} chars, sha256 "
|
|
2384
|
+
f"{sha256_text(record.body)} ({engine.cli_command('status', '--action', action)})",
|
|
2385
|
+
]
|
|
2386
|
+
if record.stage is Stage.OBSERVED:
|
|
2387
|
+
continue
|
|
2388
|
+
if push is not None and push.stage is not Stage.OBSERVED:
|
|
2389
|
+
branch = str(push.target["ref"]).removeprefix("refs/heads/")
|
|
2390
|
+
lines += [
|
|
2391
|
+
f" held back: push of {str(push.payload['sha'])[:12]} to {branch}, after "
|
|
2392
|
+
"the action is verified",
|
|
2393
|
+
f" worktree: {engine.agent_worktree_description()} holds the unpushed "
|
|
2394
|
+
"candidate; do not reset it",
|
|
2395
|
+
]
|
|
2396
|
+
if record.stage is Stage.INTENDED and not record.authorized:
|
|
2397
|
+
why = '"..."'
|
|
2398
|
+
lines += [
|
|
2399
|
+
f" next: approve: "
|
|
2400
|
+
f"{engine.cli_command('unblock', '--approve-action', action, '--reason')} {why}"
|
|
2401
|
+
f" ; {engine.resume_command()}",
|
|
2402
|
+
" or post exactly that payload yourself, then: "
|
|
2403
|
+
f"{engine.cli_command('unblock', '--reason')} {why} ; {engine.resume_command()}",
|
|
2404
|
+
" or decline: "
|
|
2405
|
+
f"{engine.cli_command('unblock', '--decline-action', action, '--reason')} {why}"
|
|
2406
|
+
" (FIX runs again)",
|
|
2407
|
+
]
|
|
2408
|
+
elif record.stage is Stage.CONFLICT:
|
|
2409
|
+
lines.append(
|
|
2410
|
+
" next: repair or remove the comment(s) named above, then "
|
|
2411
|
+
f'{engine.cli_command("unblock", "--reason")} "..."'
|
|
2412
|
+
)
|
|
2413
|
+
else:
|
|
2414
|
+
lines.append(f" next: {engine.resume_command()}")
|
|
2415
|
+
for outcome in state.acceptance_outcomes if state.mode != WorkflowMode.LOCAL else ():
|
|
2416
|
+
label = (
|
|
2417
|
+
f"verified: {outcome['comment_url']}"
|
|
2418
|
+
if outcome["outcome"] == "verified"
|
|
2419
|
+
else outcome["outcome"]
|
|
2420
|
+
)
|
|
2421
|
+
reason = f" ({outcome['reason']})" if outcome["reason"] else ""
|
|
2422
|
+
lines.append(
|
|
2423
|
+
f"Acceptance action {outcome['action_id']} (review round {outcome['round']}, finding "
|
|
2424
|
+
f"{outcome['finding_id']}): {label}{reason}"
|
|
2425
|
+
)
|
|
2426
|
+
return lines
|
|
2427
|
+
|
|
2428
|
+
|
|
2429
|
+
# The terminal phases that keep a FIX plan for the operator (D2.4): a fresh
|
|
2430
|
+
# run over one holding operator actions is refused without --force (#243).
|
|
2431
|
+
_KEEPING_PHASES = (Phase.BLOCKED, Phase.FAILED)
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def _operator_records(state: AutoForgeState) -> list[EffectRecord]:
|
|
2435
|
+
"""The persisted K10 records of the current plan (#243); none for a LOCAL run."""
|
|
2436
|
+
if state.mode == WorkflowMode.LOCAL:
|
|
2437
|
+
return []
|
|
2438
|
+
return [r for r in state.phase_effects().records if r.kind is EffectKind.OPERATOR_COMMENT]
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
def _operator_label(record: EffectRecord) -> tuple[str, str]:
|
|
2442
|
+
"""``(state, label)`` of a persisted K10 record, as ``status`` names it (#243).
|
|
2443
|
+
|
|
2444
|
+
"pending" means not yet verified, never "not posted": ``status`` reads
|
|
2445
|
+
nothing from GitHub. ``verification_failed`` is a conflict, or what
|
|
2446
|
+
FIX's last verification of a pending action found wrong (a changed or
|
|
2447
|
+
deleted bound comment, a target that cannot be read or no longer
|
|
2448
|
+
qualifies), kept on the record as its reason (PR #254 R3-F3).
|
|
2449
|
+
"""
|
|
2450
|
+
if record.stage is Stage.OBSERVED:
|
|
2451
|
+
observed = record.observed or {}
|
|
2452
|
+
how = (
|
|
2453
|
+
"exact"
|
|
2454
|
+
if observed.get("evidence") == "exact"
|
|
2455
|
+
else "operator-bound; adequacy not verified by the controller"
|
|
2456
|
+
)
|
|
2457
|
+
return "verified", f"verified ({how}): {observed.get('url', '-')}"
|
|
2458
|
+
if record.stage is Stage.CONFLICT:
|
|
2459
|
+
return "verification_failed", f"verification failed: {record.reason}"
|
|
2460
|
+
if record.reason:
|
|
2461
|
+
return (
|
|
2462
|
+
"verification_failed",
|
|
2463
|
+
f"verification failed at FIX's last check: {record.reason}",
|
|
2464
|
+
)
|
|
2465
|
+
if record.binding is not None:
|
|
2466
|
+
return (
|
|
2467
|
+
"evidence_bound",
|
|
2468
|
+
f"evidence bound, not yet verified: {record.binding['comment_url']}",
|
|
2469
|
+
)
|
|
2470
|
+
return "pending", "pending (not yet verified)"
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
def _operator_actions(state: AutoForgeState) -> list[dict]:
|
|
2474
|
+
"""The derived ``operator_actions`` of ``status --json`` (#243).
|
|
2475
|
+
|
|
2476
|
+
One entry per persisted K10 record of the current plan, then one per
|
|
2477
|
+
``operator_comment_outcomes`` entry, each with its ``state``: pending,
|
|
2478
|
+
evidence_bound, verified, verification_failed, declined, withdrawn or
|
|
2479
|
+
stale. ``verified`` comes only from an observed record or a recorded
|
|
2480
|
+
outcome, never from an assertion.
|
|
2481
|
+
"""
|
|
2482
|
+
from .claims import OPERATOR_COMMENT, scan
|
|
2483
|
+
|
|
2484
|
+
out: list[dict] = []
|
|
2485
|
+
for record in _operator_records(state):
|
|
2486
|
+
claim = scan(OPERATOR_COMMENT, record.marker).claims[0]
|
|
2487
|
+
status, _ = _operator_label(record)
|
|
2488
|
+
observed = record.observed or {}
|
|
2489
|
+
out.append(
|
|
2490
|
+
{
|
|
2491
|
+
"action_id": record.action_id,
|
|
2492
|
+
"kind": "related_issue_comment",
|
|
2493
|
+
"performer": "operator",
|
|
2494
|
+
"target": record.target["issue_url"],
|
|
2495
|
+
"pr": record.owner.pr_url,
|
|
2496
|
+
"round": claim.round,
|
|
2497
|
+
"finding_id": claim.finding_id,
|
|
2498
|
+
"allowed_by": list(record.precondition["allowed_by"]),
|
|
2499
|
+
"state": status,
|
|
2500
|
+
"evidence": str(observed.get("evidence", "")),
|
|
2501
|
+
"binding": record.binding,
|
|
2502
|
+
"payload_chars": len(record.body),
|
|
2503
|
+
"payload_sha256": sha256_text(record.body),
|
|
2504
|
+
"comment_url": str(observed.get("url", "")),
|
|
2505
|
+
"reason": record.reason,
|
|
2506
|
+
}
|
|
2507
|
+
)
|
|
2508
|
+
for outcome in state.operator_comment_outcomes if state.mode != WorkflowMode.LOCAL else ():
|
|
2509
|
+
out.append(
|
|
2510
|
+
{
|
|
2511
|
+
"action_id": outcome["action_id"],
|
|
2512
|
+
"kind": "related_issue_comment",
|
|
2513
|
+
"performer": "operator",
|
|
2514
|
+
"target": outcome["target"],
|
|
2515
|
+
"pr": state.current_pr_url,
|
|
2516
|
+
"round": outcome["round"],
|
|
2517
|
+
"finding_id": outcome["finding_id"],
|
|
2518
|
+
"allowed_by": [],
|
|
2519
|
+
"state": outcome["outcome"],
|
|
2520
|
+
"evidence": outcome["evidence"],
|
|
2521
|
+
"binding": None,
|
|
2522
|
+
"payload_chars": None,
|
|
2523
|
+
"payload_sha256": outcome["payload_sha256"],
|
|
2524
|
+
"comment_url": outcome["comment_url"],
|
|
2525
|
+
"reason": outcome["reason"],
|
|
2526
|
+
}
|
|
2527
|
+
)
|
|
2528
|
+
return out
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def _render_operator_actions(state: AutoForgeState, engine: ControllerEngine) -> list[str]:
|
|
2532
|
+
"""The human ``Operator actions`` blocks of ``status`` (#243); none without one."""
|
|
2533
|
+
from .claims import OPERATOR_COMMENT, scan
|
|
2534
|
+
|
|
2535
|
+
records = _operator_records(state)
|
|
2536
|
+
lines: list[str] = []
|
|
2537
|
+
why = '"..."'
|
|
2538
|
+
findings: dict[str, list[EffectRecord]] = {}
|
|
2539
|
+
for record in records:
|
|
2540
|
+
fid = scan(OPERATOR_COMMENT, record.marker).claims[0].finding_id
|
|
2541
|
+
findings.setdefault(fid, []).append(record)
|
|
2542
|
+
allowed_text = {"issue_body": "referenced by the run's issue body", "epic": "the run's EPIC"}
|
|
2543
|
+
for fid, mine in findings.items():
|
|
2544
|
+
claim = scan(OPERATOR_COMMENT, mine[0].marker).claims[0]
|
|
2545
|
+
title = next(
|
|
2546
|
+
(str(f.get("title", "")) for f in state.open_findings if f.get("id") == fid), ""
|
|
2547
|
+
)
|
|
2548
|
+
lines.append(
|
|
2549
|
+
f"Operator actions (FIX, review round {claim.round}, finding {fid} {title})".rstrip()
|
|
2550
|
+
)
|
|
2551
|
+
for record in mine:
|
|
2552
|
+
action = record.action_id
|
|
2553
|
+
_, label = _operator_label(record)
|
|
2554
|
+
lines.append(f" {action} comment on {record.target['issue_url']} {label}")
|
|
2555
|
+
if record.stage is Stage.OBSERVED:
|
|
2556
|
+
continue
|
|
2557
|
+
allowed = "; ".join(
|
|
2558
|
+
allowed_text.get(str(b), str(b)) for b in record.precondition["allowed_by"]
|
|
2559
|
+
)
|
|
2560
|
+
lines += [
|
|
2561
|
+
f" allowed: {allowed} (issue body sha256 "
|
|
2562
|
+
f"{record.precondition['issue_body_sha256']})",
|
|
2563
|
+
f" payload: {len(record.body)} chars, sha256 {sha256_text(record.body)}",
|
|
2564
|
+
" evidence: exactly one comment on the target with this action's marker "
|
|
2565
|
+
"and exactly these bytes, or an existing comment you bind",
|
|
2566
|
+
]
|
|
2567
|
+
pending = [r for r in records if r.stage is not Stage.OBSERVED]
|
|
2568
|
+
if pending:
|
|
2569
|
+
# The rest of the plan follows the K10 prefix: the push and the
|
|
2570
|
+
# follow-up writes are found in the whole saved plan (PR #254 R2-F2).
|
|
2571
|
+
plan = state.phase_effects().records
|
|
2572
|
+
push = next((r for r in plan if r.kind is EffectKind.PUSH), None)
|
|
2573
|
+
if push is not None and push.stage is not Stage.OBSERVED:
|
|
2574
|
+
lines += [
|
|
2575
|
+
f" held back: push of {str(push.payload['sha'])[:12]}, after every operator "
|
|
2576
|
+
"action is verified",
|
|
2577
|
+
f" worktree: {engine.agent_worktree_description()} holds the unpushed "
|
|
2578
|
+
"candidate; do not reset it",
|
|
2579
|
+
]
|
|
2580
|
+
else:
|
|
2581
|
+
lines.append(" held back: nothing was committed, so nothing is pushed")
|
|
2582
|
+
others = sum(
|
|
2583
|
+
1 for r in plan if r.kind in (EffectKind.FOLLOW_UP_ISSUE, EffectKind.FOLLOW_UP_APPEND)
|
|
2584
|
+
)
|
|
2585
|
+
if others:
|
|
2586
|
+
lines.append(f" {others} follow-up write(s) are held back as well")
|
|
2587
|
+
first = pending[0]
|
|
2588
|
+
action = first.action_id
|
|
2589
|
+
kept = [r.action_id for r in records if r.stage is Stage.OBSERVED]
|
|
2590
|
+
declines = [a for r in pending for a in ("--decline-action", r.action_id)]
|
|
2591
|
+
lines += [
|
|
2592
|
+
f" next: read it: {engine.cli_command('status', '--action', action)}",
|
|
2593
|
+
f" post it: {engine.cli_command('status', '--action', action)} | "
|
|
2594
|
+
f"gh issue comment {first.target['issue_url']} --body-file -",
|
|
2595
|
+
" or bind: "
|
|
2596
|
+
f"{engine.cli_command('unblock', '--bind-evidence', action)} <comment URL> "
|
|
2597
|
+
f"--reason {why}",
|
|
2598
|
+
f" then: {engine.cli_command('unblock', '--reason')} {why} && "
|
|
2599
|
+
f"{engine.resume_command()}",
|
|
2600
|
+
f" withdraw: {engine.cli_command('unblock', *declines, '--reason')} {why}"
|
|
2601
|
+
+ (f" (keeps {', '.join(kept)} verified)" if kept else ""),
|
|
2602
|
+
]
|
|
2603
|
+
for outcome in state.operator_comment_outcomes if state.mode != WorkflowMode.LOCAL else ():
|
|
2604
|
+
label = (
|
|
2605
|
+
f"verified ({outcome['evidence']}): {outcome['comment_url']}"
|
|
2606
|
+
if outcome["outcome"] == "verified"
|
|
2607
|
+
else outcome["outcome"]
|
|
2608
|
+
)
|
|
2609
|
+
reason = f" ({outcome['reason']})" if outcome["reason"] else ""
|
|
2610
|
+
lines.append(
|
|
2611
|
+
f"Operator action {outcome['action_id']} (review round {outcome['round']}, finding "
|
|
2612
|
+
f"{outcome['finding_id']}, comment on {outcome['target']}): {label}{reason}"
|
|
2613
|
+
)
|
|
2614
|
+
return lines
|
|
2615
|
+
|
|
2616
|
+
|
|
2617
|
+
def _render_replan_journal(txn: ReplanTransaction | None) -> list[str]:
|
|
2618
|
+
"""The human lines for a persisted replan journal; none without one.
|
|
2619
|
+
|
|
2620
|
+
Shared by both modes so a journal a state carries is described the same
|
|
2621
|
+
way wherever it is found -- a LOCAL run never writes one, but a state
|
|
2622
|
+
file that has one anyway is shown, not silently skipped, and the human
|
|
2623
|
+
output then agrees with ``replan_journal`` in ``--json``.
|
|
2624
|
+
"""
|
|
2625
|
+
if txn is None:
|
|
2626
|
+
return []
|
|
2627
|
+
lines = [
|
|
2628
|
+
f"Replan transaction: {txn.transaction_id or '(not yet created)'}",
|
|
2629
|
+
f" stage: {txn.stage.value}",
|
|
2630
|
+
]
|
|
2631
|
+
if txn.journal_defects:
|
|
2632
|
+
lines.append(f" journal: CORRUPT ({'; '.join(txn.journal_defects)})")
|
|
2633
|
+
lines += [
|
|
2634
|
+
f" source PR: {txn.source_pr_url or '-'}",
|
|
2635
|
+
f" replacement: {txn.replacement_pr_url or '-'}",
|
|
2636
|
+
# A structure from state, redacted before it is serialized (#281).
|
|
2637
|
+
f" escalation: {json.dumps(redact_obj(txn.escalation), sort_keys=True)}",
|
|
2638
|
+
]
|
|
2639
|
+
if txn.rejection_reason:
|
|
2640
|
+
lines.append(f" rejected: {txn.rejection_reason}")
|
|
2641
|
+
return lines
|
|
2642
|
+
|
|
2643
|
+
|
|
2644
|
+
def _render_status(
|
|
2645
|
+
state: AutoForgeState,
|
|
2646
|
+
txn: ReplanTransaction | None,
|
|
2647
|
+
acceptance: list[str] | None = None,
|
|
2648
|
+
evidence: list[str] | None = None,
|
|
2649
|
+
) -> str:
|
|
2650
|
+
"""Human status for a GitHub run (unredacted; the caller redacts)."""
|
|
2651
|
+
|
|
2652
|
+
def _num(url: str) -> str:
|
|
2653
|
+
return url.rstrip("/").rsplit("/", 1)[-1] if url else "-"
|
|
2654
|
+
|
|
2655
|
+
lines = [
|
|
2656
|
+
"AutoForge",
|
|
2657
|
+
"",
|
|
2658
|
+
f"Run: {state.run_id}",
|
|
2659
|
+
f"Repository: {state.repository}",
|
|
2660
|
+
f"EPIC: #{_num(state.epic_url)} {state.epic_url}",
|
|
2661
|
+
f"Issue: #{_num(state.current_issue_url)} {state.current_issue_url or '-'}",
|
|
2662
|
+
f"PR: #{_num(state.current_pr_url)} {state.current_pr_url or '-'}",
|
|
2663
|
+
f"Branch: {state.current_branch or '-'}",
|
|
2664
|
+
f"Config: {_recorded_configuration(state)}",
|
|
2665
|
+
"",
|
|
2666
|
+
f"Phase: {state.phase.value}",
|
|
2667
|
+
f"Review rounds completed: {state.review_round}",
|
|
2668
|
+
f"Execution attempt: {state.execution_attempt}",
|
|
2669
|
+
f"Replan count: {state.escalation_count}",
|
|
2670
|
+
f"Current HEAD: {state.current_head_sha or '-'}",
|
|
2671
|
+
f"Current base: {state.current_base_ref or '-'}",
|
|
2672
|
+
f"Current merge base: {state.current_merge_base_sha or '-'}",
|
|
2673
|
+
f"Reviewed PR: {state.reviewed_pr_url or '-'}",
|
|
2674
|
+
f"Reviewed HEAD: {state.reviewed_head_sha or '-'}",
|
|
2675
|
+
f"Reviewed base: {state.reviewed_base_ref or '-'}",
|
|
2676
|
+
f"Reviewed merge base: {state.reviewed_merge_base_sha or '-'}",
|
|
2677
|
+
f"Last review: {state.last_review_result or '-'}",
|
|
2678
|
+
f"Review comment: {state.last_review_comment_url or '-'}",
|
|
2679
|
+
f"Open findings: {len(state.open_findings)}",
|
|
2680
|
+
f"Prior findings to re-check: {len(state.prior_findings)}",
|
|
2681
|
+
]
|
|
2682
|
+
if state.superseded_prs:
|
|
2683
|
+
lines.append("Superseded PRs:")
|
|
2684
|
+
for item in state.superseded_prs:
|
|
2685
|
+
lines.append(f" {item.get('pr_url', '-')} -> {item.get('replacement_pr_url', '-')}")
|
|
2686
|
+
lines += _render_replan_journal(txn)
|
|
2687
|
+
if state.block_reason:
|
|
2688
|
+
lines.append(f"Reason: {state.block_reason}")
|
|
2689
|
+
lines += acceptance or []
|
|
2690
|
+
if state.unblock_history:
|
|
2691
|
+
last = state.unblock_history[-1]
|
|
2692
|
+
action = f" [{last['action']}]" if last.get("action") else ""
|
|
2693
|
+
lines.append(
|
|
2694
|
+
f"Unblocked: {len(state.unblock_history)} time(s); last at {last.get('at', '-')} "
|
|
2695
|
+
f"-> {last.get('phase', '-')}{action}: {last.get('reason', '-')}"
|
|
2696
|
+
)
|
|
2697
|
+
lines += evidence or []
|
|
2698
|
+
lines += [
|
|
2699
|
+
"",
|
|
2700
|
+
f"Steps executed: {state.step_count}",
|
|
2701
|
+
f"Merged since EPIC update: {state.merged_since_epic_update}",
|
|
2702
|
+
"",
|
|
2703
|
+
f"Created: {state.created_at}",
|
|
2704
|
+
f"Updated: {state.updated_at}",
|
|
2705
|
+
]
|
|
2706
|
+
return "\n".join(lines)
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
def _recorded_configuration(state: AutoForgeState) -> str:
|
|
2710
|
+
"""Where a REMOTE run's recorded configuration came from, and when (#219)."""
|
|
2711
|
+
if not state.config_fingerprint:
|
|
2712
|
+
return "not recorded (state from before #219; the next step, resume or unblock records it)"
|
|
2713
|
+
recorded = RecordedConfiguration.from_dict(state.config_fingerprint)
|
|
2714
|
+
return f"{recorded.origin()} ({recorded.source} {recorded.recorded_at})"
|
|
2715
|
+
|
|
2716
|
+
|
|
2717
|
+
def _render_local_status(
|
|
2718
|
+
state: AutoForgeState, txn: ReplanTransaction | None, evidence: list[str] | None = None
|
|
2719
|
+
) -> str:
|
|
2720
|
+
"""Human status for a LOCAL run (unredacted; the caller redacts).
|
|
2721
|
+
|
|
2722
|
+
Deliberately omits Issue/PR/branch/merge fields: a local run has none, and
|
|
2723
|
+
printing empty ones would suggest the GitHub lifecycle is merely stalled.
|
|
2724
|
+
A replan journal is shown only when the state carries one.
|
|
2725
|
+
"""
|
|
2726
|
+
fix_budget = state.local_fix_rounds
|
|
2727
|
+
|
|
2728
|
+
lines = [
|
|
2729
|
+
"AutoForge (local mode)",
|
|
2730
|
+
"",
|
|
2731
|
+
f"Run: {state.run_id}",
|
|
2732
|
+
f"Mode: {state.mode.value} (no GitHub: no issue, no PR, no push, no merge)",
|
|
2733
|
+
f"Feature: {state.feature_spec_path or '-'}",
|
|
2734
|
+
f"Frozen SHA: {state.feature_spec_sha256 or '-'}",
|
|
2735
|
+
"",
|
|
2736
|
+
f"Phase: {state.phase.value}",
|
|
2737
|
+
f"Review rounds completed: {state.review_round}",
|
|
2738
|
+
f"Fix rounds completed: {fix_budget}",
|
|
2739
|
+
f"Last review: {state.last_review_result or '-'}",
|
|
2740
|
+
f"Open findings: {len(state.open_findings)}",
|
|
2741
|
+
]
|
|
2742
|
+
for finding in state.open_findings:
|
|
2743
|
+
lines.append(
|
|
2744
|
+
f" {finding.get('id', '?')} [{finding.get('severity', '?')}] "
|
|
2745
|
+
f"{str(finding.get('title', '')).strip()[:80]}"
|
|
2746
|
+
)
|
|
2747
|
+
lines += [
|
|
2748
|
+
"",
|
|
2749
|
+
f"Base HEAD: {state.base_head_sha or '(unborn)'}",
|
|
2750
|
+
f"Workspace fingerprint: {state.workspace_fingerprint or '-'}",
|
|
2751
|
+
f"Reviewed fingerprint: {state.reviewed_workspace_fingerprint or '-'}",
|
|
2752
|
+
]
|
|
2753
|
+
if state.baseline_dirty_paths:
|
|
2754
|
+
lines.append("Pre-existing working-tree changes at run creation (--allow-dirty):")
|
|
2755
|
+
for path in state.baseline_dirty_paths[:20]:
|
|
2756
|
+
lines.append(f" {path}")
|
|
2757
|
+
if len(state.baseline_dirty_paths) > 20:
|
|
2758
|
+
lines.append(f" ... and {len(state.baseline_dirty_paths) - 20} more")
|
|
2759
|
+
lines += _render_replan_journal(txn)
|
|
2760
|
+
if state.block_reason:
|
|
2761
|
+
lines.append(f"Reason: {state.block_reason}")
|
|
2762
|
+
lines += evidence or []
|
|
2763
|
+
lines += [
|
|
2764
|
+
"",
|
|
2765
|
+
f"Steps executed: {state.step_count}",
|
|
2766
|
+
"",
|
|
2767
|
+
f"Created: {state.created_at}",
|
|
2768
|
+
f"Updated: {state.updated_at}",
|
|
2769
|
+
]
|
|
2770
|
+
return "\n".join(lines)
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
# -- pretty printing ----------------------------------------------------------
|
|
2774
|
+
def print_ready_banner(
|
|
2775
|
+
state: AutoForgeState,
|
|
2776
|
+
gate_open: bool = False,
|
|
2777
|
+
certificate: tuple[bool | None, str] | None = None,
|
|
2778
|
+
) -> None:
|
|
2779
|
+
"""The READY_FOR_MERGE banner; ``certificate`` is :func:`_review_certificate_check`'s answer.
|
|
2780
|
+
|
|
2781
|
+
Whether the review certificate applies is read from state and the
|
|
2782
|
+
configuration in force, never GitHub: the merge gate compares it with
|
|
2783
|
+
the live PR itself (#175), and refuses to merge without one.
|
|
2784
|
+
"""
|
|
2785
|
+
if not state.review_certificate:
|
|
2786
|
+
held = "none held; the merge gate would refuse to merge"
|
|
2787
|
+
elif certificate is None or certificate[0] is None:
|
|
2788
|
+
held = certificate[1] if certificate is not None else "not checked"
|
|
2789
|
+
elif certificate[0]:
|
|
2790
|
+
held = (
|
|
2791
|
+
f"applies (round {state.review_certificate.get('review_round')}, against state and "
|
|
2792
|
+
"the configuration in force; the merge gate checks it against GitHub too)"
|
|
2793
|
+
)
|
|
2794
|
+
else:
|
|
2795
|
+
held = f"does not apply ({certificate[1]}); the merge gate would refuse to merge"
|
|
2796
|
+
lines = [
|
|
2797
|
+
"",
|
|
2798
|
+
"=" * 72,
|
|
2799
|
+
"AutoForge workflow reached READY_FOR_MERGE.",
|
|
2800
|
+
f" Issue: {state.current_issue_url}",
|
|
2801
|
+
f" PR: {state.current_pr_url}",
|
|
2802
|
+
f" Review round: {state.review_round}",
|
|
2803
|
+
f" Reviewed HEAD: {state.reviewed_head_sha}",
|
|
2804
|
+
f" Reviewed base: {state.reviewed_base_ref or '-'}",
|
|
2805
|
+
f" Reviewed merge base: {state.reviewed_merge_base_sha or '-'}",
|
|
2806
|
+
f" Review: {state.last_review_comment_url or '-'}",
|
|
2807
|
+
f" Review certificate: {held}",
|
|
2808
|
+
]
|
|
2809
|
+
if gate_open:
|
|
2810
|
+
lines += [
|
|
2811
|
+
"Merge gate is open but the step budget (--max-steps) ran out before MERGE.",
|
|
2812
|
+
"'resume --allow-merge' continues with the controller-side pre-merge verification.",
|
|
2813
|
+
]
|
|
2814
|
+
else:
|
|
2815
|
+
lines += [
|
|
2816
|
+
"Automatic merge is disabled in this milestone.",
|
|
2817
|
+
"A human must review and merge the PR.",
|
|
2818
|
+
]
|
|
2819
|
+
lines.append("=" * 72)
|
|
2820
|
+
# State-derived like the status output: the same redaction boundary.
|
|
2821
|
+
print(redact("\n".join(lines)))
|
|
2822
|
+
|
|
2823
|
+
|
|
2824
|
+
def print_plan(plan: StepPlan, full_prompt: bool = False) -> None:
|
|
2825
|
+
print(f"Phase: {plan.phase}")
|
|
2826
|
+
print(
|
|
2827
|
+
f"Profile: {plan.profile_name} "
|
|
2828
|
+
f"(provider={plan.provider}, model={plan.model}, effort={plan.effort})"
|
|
2829
|
+
)
|
|
2830
|
+
if plan.review_round and plan.phase in ("REVIEW", "FIX"):
|
|
2831
|
+
print(f"Review round: {plan.review_round}")
|
|
2832
|
+
if plan.template:
|
|
2833
|
+
print(f"Template: {plan.template}")
|
|
2834
|
+
if plan.command:
|
|
2835
|
+
# The prompt is previewed below, so the argument that carries it is
|
|
2836
|
+
# shown as a placeholder. Only that argument: a provider that sends
|
|
2837
|
+
# the prompt on stdin has none, and its last argument is a flag value.
|
|
2838
|
+
shown = [
|
|
2839
|
+
"<prompt>" if plan.prompt_full and arg == plan.prompt_full else arg
|
|
2840
|
+
for arg in plan.command
|
|
2841
|
+
]
|
|
2842
|
+
print(f"Command: {' '.join(redact_argv(shown))}")
|
|
2843
|
+
if plan.limits is not None:
|
|
2844
|
+
print(f"Limits: {plan.limits.describe()}")
|
|
2845
|
+
if plan.loop_detection is not None:
|
|
2846
|
+
print(f"Loops: {plan.loop_detection.describe()}")
|
|
2847
|
+
print(f"Routing: {plan.routing}")
|
|
2848
|
+
print(f"Expected next: {plan.expected_next or '-'}")
|
|
2849
|
+
if plan.legal_next:
|
|
2850
|
+
print(f"Legal next: {', '.join(plan.legal_next)}")
|
|
2851
|
+
# A note can quote state (a recorded refusal, an effect's target, the
|
|
2852
|
+
# replan escalation), so it crosses the boundary the variables do.
|
|
2853
|
+
for note in plan.notes:
|
|
2854
|
+
print(f"Note: {redact(note)}")
|
|
2855
|
+
if plan.variables:
|
|
2856
|
+
print("Variables:")
|
|
2857
|
+
for k, v in sorted(plan.variables.items()):
|
|
2858
|
+
text = redact(v).replace("\n", " ")
|
|
2859
|
+
print(f" {k} = {text[:120]}")
|
|
2860
|
+
if plan.prompt_length:
|
|
2861
|
+
print(f"Prompt: {plan.prompt_length} chars")
|
|
2862
|
+
print("--- prompt preview ---")
|
|
2863
|
+
print(redact(plan.prompt_preview if not full_prompt else plan.prompt_full))
|
|
2864
|
+
print("--- end preview ---")
|
|
2865
|
+
print()
|
|
2866
|
+
|
|
2867
|
+
|
|
2868
|
+
def print_step_outcome(o: StepOutcome) -> None:
|
|
2869
|
+
# Every field here came out of state or an agent result, `run_id`
|
|
2870
|
+
# included, so the first line is redacted whole rather than per field.
|
|
2871
|
+
# Both lines reach the terminal and a scheduler worker's persisted log.
|
|
2872
|
+
print(redact(f"[{o.run_id}] {o.previous_phase} -> {o.next_phase}: {o.message}"))
|
|
2873
|
+
if o.plan is not None and o.plan.command:
|
|
2874
|
+
print(f" profile={o.plan.profile_name} model={o.plan.model}")
|
|
2875
|
+
if o.result is not None:
|
|
2876
|
+
# The agent's result is redacted string by string before it is
|
|
2877
|
+
# serialized, never as its JSON text (#281): the text escapes what
|
|
2878
|
+
# stands before a value (`\n`, `\"`, `\u00e9`), and a redaction of
|
|
2879
|
+
# the escaped text is only as good as the patterns' escape rules.
|
|
2880
|
+
print(f" result={json.dumps(redact_obj(o.result), sort_keys=True)}")
|
|
2881
|
+
|
|
2882
|
+
|
|
2883
|
+
if __name__ == "__main__":
|
|
2884
|
+
raise SystemExit(main())
|