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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/ownership.py ADDED
@@ -0,0 +1,240 @@
1
+ """Who may act on an issue: a standalone run or the parallel EPIC scheduler (ADR 0008 D17.2).
2
+
3
+ L0 excludes a standalone command and a scheduler *in time*; these guards
4
+ exclude them *in ownership*, so that no issue ever has two states that both
5
+ continue it.
6
+
7
+ * **Forward guard.** A standalone controller refuses an issue a scheduler
8
+ namespace owns. :func:`scheduler_ownership` is the predicate: a namespace
9
+ owns its issue when it holds any ``state/state.json``, read with #178's
10
+ lookup (:func:`~autoforge.run_namespace.load_issue_namespace`), when its
11
+ ``claim.json`` holds an open scheduler claim (#181,
12
+ :func:`~autoforge.scheduler_state.load_claim_file`), or when a live owner
13
+ holds its run lock (L2, the lock-free probe
14
+ :func:`~autoforge.locking.issue_run_lock_live`). It fails closed: a
15
+ namespace or a claim file that cannot be read (an unknown claim schema
16
+ included), or a run lock that cannot be probed, owns its issue. The
17
+ CLI applies it under L0 to the issue ``run``, ``step``, ``resume`` and
18
+ ``unblock`` would act on (:func:`refuse_scheduler_owned_issue`), and hands
19
+ a standalone engine the same predicate
20
+ (:func:`standalone_ownership_predicate`) as one more rejection fact of
21
+ ``_verify_issue_selectable``, which covers a sequential run's
22
+ ``next_issue_url``; the engine never reads scheduler files itself. A
23
+ worker engine is given no predicate, and ``unblock`` of a namespace's own
24
+ state directory exempts that namespace.
25
+ * **Reverse guard.** :func:`standalone_hold` reports the issue the
26
+ configured standalone state names as ``current_issue_url`` while its phase
27
+ is anything but ``DONE`` (``BLOCKED`` and ``FAILED`` included), so a
28
+ scheduler namespace never launders a standalone run's loop bounds or
29
+ bypasses its ``unblock``; #182's admission holds it as
30
+ ``held:standalone-state``. An unreadable standalone state is reported and
31
+ holds nothing.
32
+
33
+ Everything here only reads: nothing is created, written, quarantined or
34
+ locked, and no GitHub or git process runs.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from collections.abc import Callable
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+
43
+ from .errors import ConfigurationError, LockError, SchedulerOwnedIssueError, StateError
44
+ from .locking import issue_run_lock_live
45
+ from .redaction import redact
46
+ from .run_namespace import StateStatus, issue_namespace, load_issue_namespace
47
+ from .safefs import SafeRoot
48
+ from .scheduler_state import load_claim_file
49
+ from .state import STATE_FILENAME, load_state
50
+ from .transitions import Phase, WorkflowMode
51
+ from .validation import parse_issue_url
52
+
53
+ # A reason quotes a loader's or a probe's message: redacted, then bounded.
54
+ MAX_REASON_CHARS = 1000
55
+
56
+
57
+ def _bounded(text: str) -> str:
58
+ text = redact(text)
59
+ if len(text) <= MAX_REASON_CHARS:
60
+ return text
61
+ marker = " [... truncated]"
62
+ return text[: MAX_REASON_CHARS - len(marker)] + marker
63
+
64
+
65
+ def scheduler_ownership(coordination_root: str | Path, issue_number: int) -> str:
66
+ """Why a scheduler namespace owns issue ``issue_number``; "" when none does.
67
+
68
+ ADR 0008 D17.2's ownership predicate: the namespace holds a state
69
+ (loaded or not, #180), its claim file holds an open claim (#181), or a
70
+ live owner holds its run lock (#180). Fail-closed: a namespace whose
71
+ binding or directories cannot be read, a claim file that cannot be read
72
+ or is of an unknown schema, or a run lock that cannot be probed, owns
73
+ its issue. A claim file holding only closed claims owns nothing: the
74
+ namespace's next run starts fresh. Read-only.
75
+ """
76
+ try:
77
+ namespace = issue_namespace(coordination_root, issue_number)
78
+ except StateError as exc:
79
+ return f"no scheduler namespace can be named for it ({_bounded(str(exc))})"
80
+ where = namespace.directory
81
+ try:
82
+ lookup = load_issue_namespace(namespace.coordination_root, issue_number)
83
+ except (StateError, OSError) as exc:
84
+ return (
85
+ f"its scheduler namespace {where} cannot be read ({_bounded(str(exc))}), so "
86
+ "it may own the issue"
87
+ )
88
+ if lookup.state.status is StateStatus.LOADED:
89
+ assert lookup.state.state is not None
90
+ return (
91
+ f"its scheduler namespace {where} holds a state (phase "
92
+ f"{lookup.state.state.phase.value})"
93
+ )
94
+ if lookup.state.status is StateStatus.CORRUPT:
95
+ return (
96
+ f"its scheduler namespace {where} holds a state that cannot be read "
97
+ f"({lookup.state.reason})"
98
+ )
99
+ if lookup.claim_present:
100
+ try:
101
+ claim_file = load_claim_file(namespace)
102
+ except (StateError, OSError) as exc:
103
+ return (
104
+ f"its scheduler claim {namespace.claim_file} cannot be read "
105
+ f"({_bounded(str(exc))}), so it may hold an open claim"
106
+ )
107
+ if claim_file is not None and claim_file.current is not None:
108
+ current = claim_file.current
109
+ return (
110
+ f"its scheduler namespace {where} holds an open scheduler claim (generation "
111
+ f"{current.generation}, a {current.kind.value} admission)"
112
+ )
113
+ try:
114
+ live = issue_run_lock_live(namespace)
115
+ except (LockError, OSError) as exc:
116
+ return (
117
+ f"its run lock {namespace.run_lock_file} cannot be probed "
118
+ f"({_bounded(str(exc))}), so a worker may own the issue"
119
+ )
120
+ if live:
121
+ return f"a live scheduler worker holds its run lock {namespace.run_lock_file}"
122
+ return ""
123
+
124
+
125
+ def standalone_ownership_predicate(
126
+ coordination_root: str | Path, *, exempt_issue: int | None = None
127
+ ) -> Callable[[int], str]:
128
+ """The predicate a standalone engine is given (``ControllerEngine.issue_ownership``).
129
+
130
+ ``exempt_issue`` is the namespace whose own state directory ``unblock``
131
+ acts on; it is never refused. A worker engine is given no predicate.
132
+ """
133
+ root = Path(coordination_root)
134
+
135
+ def owner(issue_number: int) -> str:
136
+ if exempt_issue is not None and issue_number == exempt_issue:
137
+ return ""
138
+ return scheduler_ownership(root, issue_number)
139
+
140
+ return owner
141
+
142
+
143
+ def refuse_scheduler_owned_issue(
144
+ coordination_root: str | Path,
145
+ issue_url: str,
146
+ command: str,
147
+ *,
148
+ exempt_issue: int | None = None,
149
+ ) -> None:
150
+ """The CLI's forward guard: refuse ``command`` on a scheduler-owned issue.
151
+
152
+ Called under L0, before anything is written, with the issue the command
153
+ would act on (the ``run`` argument, or the loaded state's
154
+ ``current_issue_url``). Raises
155
+ :class:`~autoforge.errors.SchedulerOwnedIssueError` (exit 2) naming
156
+ ``epic archive``. An issue URL that does not parse is the caller's
157
+ validation to report, not a refusal here.
158
+ """
159
+ try:
160
+ ref = parse_issue_url(issue_url)
161
+ except ConfigurationError:
162
+ return
163
+ if exempt_issue is not None and ref.number == exempt_issue:
164
+ return
165
+ reason = scheduler_ownership(coordination_root, ref.number)
166
+ if reason:
167
+ raise SchedulerOwnedIssueError(
168
+ f"'{command}' refuses issue {ref.canonical}: {reason}. Only the parallel EPIC "
169
+ "scheduler continues an issue its namespace owns ('autoforge epic resume', "
170
+ "inspected with 'autoforge epic status'); 'autoforge epic archive <epic> "
171
+ f"{ref.number}' releases it to a standalone run. Nothing was written."
172
+ )
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class StandaloneHold:
177
+ """What the configured standalone state holds against a scheduler admission.
178
+
179
+ ``issue_number`` (with ``issue_url`` and ``phase``) is set when the state
180
+ names an issue in a phase other than ``DONE``; ``problem`` (redacted and
181
+ bounded) when the state exists but cannot be read, which holds nothing
182
+ and is reported.
183
+ """
184
+
185
+ state_file: Path
186
+ issue_number: int | None = None
187
+ issue_url: str = ""
188
+ phase: str = ""
189
+ problem: str = ""
190
+
191
+ @property
192
+ def holds(self) -> bool:
193
+ return self.issue_number is not None
194
+
195
+
196
+ def standalone_hold(state_dir: str | Path) -> StandaloneHold:
197
+ """The issue the standalone state in ``state_dir`` holds; read-only (ADR 0008 D17.2).
198
+
199
+ ``state_dir`` is the configured one (``config.state_dir``); other
200
+ ``--state-dir`` locations are unknowable, and the forward guard refuses
201
+ to continue them instead. An absent state, a ``DONE`` state, a LOCAL
202
+ state and a state naming no issue hold nothing. Nothing is created,
203
+ quarantined or written.
204
+ """
205
+ state_file = Path(state_dir) / STATE_FILENAME
206
+ try:
207
+ # Read through a held root, like every state read (load_state's
208
+ # ``root=``): the directory cannot be swapped between the checks.
209
+ root = SafeRoot.open(state_dir)
210
+ except FileNotFoundError:
211
+ return StandaloneHold(state_file)
212
+ except (StateError, OSError) as exc:
213
+ return StandaloneHold(state_file, problem=_bounded(str(exc)))
214
+ with root:
215
+ try:
216
+ if root.lstat(STATE_FILENAME) is None:
217
+ return StandaloneHold(state_file)
218
+ state = load_state(state_file, root=root)
219
+ except StateError as exc:
220
+ try:
221
+ gone = root.lstat(STATE_FILENAME) is None # removed since: nothing is there
222
+ except StateError:
223
+ gone = False
224
+ if gone:
225
+ return StandaloneHold(state_file)
226
+ return StandaloneHold(state_file, problem=_bounded(str(exc)))
227
+ if state.mode is WorkflowMode.LOCAL or state.phase is Phase.DONE:
228
+ return StandaloneHold(state_file, phase=state.phase.value)
229
+ if not state.current_issue_url:
230
+ return StandaloneHold(state_file, phase=state.phase.value)
231
+ try:
232
+ ref = parse_issue_url(state.current_issue_url)
233
+ except ConfigurationError as exc:
234
+ return StandaloneHold(state_file, phase=state.phase.value, problem=_bounded(str(exc)))
235
+ return StandaloneHold(
236
+ state_file,
237
+ issue_number=ref.number,
238
+ issue_url=ref.canonical,
239
+ phase=state.phase.value,
240
+ )