chipgraph 0.1.0__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 (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1358 @@
1
+ """Fan-out: independent branches in separate workspaces, merged in id order (DESIGN 5.3).
2
+
3
+ ```
4
+ run_fanout(branches)
5
+ F2 write sets disjoint, none writes a shared file (else FanoutError, nothing starts)
6
+ sweep workspaces left by a killed fan-out of this repo
7
+ per branch, at most `max_parallel` at once:
8
+ workspace at the base commit (outside the user's tree), declared inputs copied in
9
+ run the branch -> commit in its workspace -> diff against the seeded state
10
+ a change outside the write set rejects the branch
11
+ F7 a branch that failed can be re-run alone (`previous=`): done branches are kept
12
+ F4 compare the branches' keyed assumptions; a mismatch stops with `needs_human`
13
+ F3 apply each branch's patch onto one integration workspace, in ascending id order;
14
+ a file changed twice, or a patch that does not apply, is a conflict (an error)
15
+ F5 run the join checks on the integration workspace; a failure creates no branch
16
+ commit the merged files, create the branch `chipgraph/<run_id>/<name>`
17
+ always: remove every workspace and the run directory
18
+ ```
19
+
20
+ `run_candidates` is the same run for N candidates of one piece of work (best-of-N,
21
+ `select.py`): the same write set and inputs, so F2 and F4 do not apply between them;
22
+ every workspace is kept until all have finished, a chooser picks one, and only its patch
23
+ goes through F3 and F5 onto the result branch.
24
+
25
+ The user's working tree is never written: the base is a commit (HEAD), declared inputs
26
+ are *copied* out of the user's tree, and the only thing a fan-out adds to the repo is the
27
+ result branch (plus the VCS's own workspace bookkeeping, pruned at the end).
28
+
29
+ Workspaces live in a per-run directory, ``<tmp>/chipgraph-fanout-<run_id>-<random>/``,
30
+ where ``<tmp>`` is ``<state>/tmp/fanout`` when the state directory is outside the repo,
31
+ else the system temp directory. The run directory holds a registry file (owner pid,
32
+ host, workspaces) so `sweep_stale` can remove what a killed process left behind.
33
+
34
+ The engine talks to version control through the narrow `FanoutVcs` port only; it never
35
+ runs a subprocess itself. Generic: no project, tool or file format is named here.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import asyncio
41
+ import contextlib
42
+ import hashlib
43
+ import os
44
+ import re
45
+ import shutil
46
+ import socket
47
+ import tempfile
48
+ import time
49
+ import unicodedata
50
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence
51
+ from dataclasses import dataclass, field
52
+ from datetime import UTC, datetime
53
+ from pathlib import Path
54
+ from typing import Any, Literal, Protocol, runtime_checkable
55
+
56
+ from pydantic import BaseModel, ConfigDict, Field
57
+
58
+ from chipgraph.core.config.models import FanoutCfg
59
+ from chipgraph.core.contracts import AgentResult, CheckResult, Issue
60
+ from chipgraph.core.engine.scheduler import ExecContext
61
+ from chipgraph.core.state.layout import StateLayout, new_run_id
62
+
63
+ TMP_PREFIX = "chipgraph-fanout-"
64
+ """Prefix of every fan-out run directory."""
65
+
66
+ REGISTRY_FILE = "fanout.json"
67
+ """The registry file in a run directory: owner pid, host, repo and workspaces."""
68
+
69
+ JOIN_WORKSPACE = "join"
70
+ """Name of the integration workspace inside a run directory."""
71
+
72
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
73
+ _RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
74
+
75
+
76
+ class FanoutError(Exception):
77
+ """A fan-out that must not start: overlapping write sets (F2), bad paths or ids."""
78
+
79
+
80
+ # --- the VCS port -----------------------------------------------------------------------
81
+
82
+ FileStatus = Literal["added", "modified", "deleted", "type_changed"]
83
+
84
+
85
+ class FileChange(BaseModel):
86
+ """One file a branch changed, relative to the state it started from."""
87
+
88
+ model_config = ConfigDict(extra="forbid", frozen=True)
89
+
90
+ path: str = Field(description="Repo-relative POSIX path.")
91
+ status: FileStatus = Field(description="What happened to the file.")
92
+ old_mode: str | None = Field(default=None, description="Mode before, e.g. '100644'.")
93
+ new_mode: str | None = Field(default=None, description="Mode after, e.g. '100755'.")
94
+
95
+
96
+ @runtime_checkable
97
+ class FanoutVcs(Protocol):
98
+ """The version control operations a fan-out needs (e.g. worktrees, commits, patches).
99
+
100
+ Separate from the frozen `VcsAdapter`: an adapter implements both. Every method is
101
+ synchronous; none may write the working tree, index or HEAD of the repo at `root`.
102
+ """
103
+
104
+ def head(self, root: Path) -> str:
105
+ """The commit checked out at `root` (the base of a fan-out)."""
106
+ ...
107
+
108
+ def create_branch_workspace(self, root: Path, base: str, name: str, dest: Path) -> Path:
109
+ """A disposable workspace at commit `base`, at `dest / name`; return its path."""
110
+ ...
111
+
112
+ def remove(self, path: Path) -> None:
113
+ """Remove a workspace; a no-op if it is already gone."""
114
+ ...
115
+
116
+ def prune(self, root: Path) -> None:
117
+ """Forget workspaces of the repo at `root` whose directory is gone."""
118
+ ...
119
+
120
+ def list_workspaces(self, root: Path) -> tuple[Path, ...]:
121
+ """Every workspace of the repo at `root`, except the main one."""
122
+ ...
123
+
124
+ def commit_all(
125
+ self,
126
+ path: Path,
127
+ message: str,
128
+ paths: Sequence[str] | None = None,
129
+ *,
130
+ include_ignored: Sequence[str] = (),
131
+ ) -> str:
132
+ """Commit the workspace at `path` and return the commit (HEAD if nothing changed).
133
+
134
+ `paths=None` commits every change that is not ignored, plus `include_ignored`;
135
+ otherwise exactly `paths` (added, changed or deleted). No hooks run.
136
+ """
137
+ ...
138
+
139
+ def diff(self, path: Path, since: str, until: str) -> tuple[FileChange, ...]:
140
+ """The files changed between two commits, sorted by path (no rename detection)."""
141
+ ...
142
+
143
+ def patch(self, path: Path, since: str, until: str, paths: Sequence[str]) -> bytes:
144
+ """A self-contained (binary-safe) patch of `paths` between two commits."""
145
+ ...
146
+
147
+ def apply_patch(self, path: Path, patch: bytes) -> str | None:
148
+ """Apply `patch` to the files of the workspace at `path`, all or nothing.
149
+
150
+ Returns `None` when it applied, else why not (a conflict). Never auto-resolves.
151
+ """
152
+ ...
153
+
154
+ def tree_hash(self, path: Path, commit: str) -> str:
155
+ """The tree id of `commit`."""
156
+ ...
157
+
158
+ def create_branch(self, root: Path, name: str, commit: str) -> None:
159
+ """Create branch `name` at `commit` in the repo at `root`; fails if it exists."""
160
+ ...
161
+
162
+
163
+ # --- branches and their results ---------------------------------------------------------
164
+
165
+ FanoutEmit = Callable[[str, dict[str, Any]], None]
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class BranchContext:
170
+ """What a branch callable gets: its own workspace and what it may write there."""
171
+
172
+ branch_id: str
173
+ run_id: str
174
+ path: Path
175
+ """The workspace root (absolute); valid only while the branch runs."""
176
+ write_set: tuple[str, ...]
177
+ inputs: tuple[str, ...]
178
+ emit: FanoutEmit
179
+ """Journal one event for this branch (`event`, payload)."""
180
+
181
+ def file(self, rel: str) -> Path:
182
+ """The workspace path of repo-relative `rel`."""
183
+ return self.path / validate_path(rel)
184
+
185
+
186
+ class BranchOutcome(BaseModel):
187
+ """What a branch callable returns."""
188
+
189
+ model_config = ConfigDict(extra="forbid", frozen=True)
190
+
191
+ ok: bool = Field(description="Whether the branch did its work.")
192
+ message: str = Field(default="", description="Why not, or a short summary.")
193
+ assumptions: tuple[str, ...] = Field(default=(), description="Assumptions made (F4).")
194
+ assumption_keys: dict[str, str] = Field(
195
+ default_factory=dict,
196
+ description="Assumption key -> assumed value, compared across branches (F4).",
197
+ )
198
+ agent: AgentResult | None = Field(default=None, description="The agent's result, if any.")
199
+
200
+ @classmethod
201
+ def from_agent(
202
+ cls,
203
+ result: AgentResult,
204
+ *,
205
+ assumption_keys: Mapping[str, str] | None = None,
206
+ message: str = "",
207
+ ) -> BranchOutcome:
208
+ """An outcome from an `AgentResult`; keys default to its `key=value` assumptions."""
209
+ keys = (
210
+ dict(assumption_keys)
211
+ if assumption_keys is not None
212
+ else keyed_assumptions(result.assumptions)
213
+ )
214
+ return cls(
215
+ ok=result.status == "done",
216
+ message=message or ("" if result.status == "done" else f"agent {result.status}"),
217
+ assumptions=result.assumptions,
218
+ assumption_keys=keys,
219
+ agent=result,
220
+ )
221
+
222
+
223
+ BranchFn = Callable[[BranchContext], Awaitable[BranchOutcome]]
224
+ JoinCheck = Callable[[Path], Awaitable[CheckResult]]
225
+ """A join check (F5): runs on the integration workspace root, returns a `CheckResult`."""
226
+ CandidateChooser = Callable[[tuple["BranchResult", ...], Mapping[str, Path]], Awaitable[str | None]]
227
+ """Picks the one candidate to merge (`run_candidates`): it gets every candidate's result,
228
+ in id order, and each candidate's workspace (still on disk); `None` merges nothing."""
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class FanoutBranch:
233
+ """One branch: an id (merge order), the work, the files it reads and may write."""
234
+
235
+ id: str
236
+ run: BranchFn
237
+ write_set: tuple[str, ...]
238
+ inputs: tuple[str, ...] = ()
239
+
240
+ def __post_init__(self) -> None:
241
+ if not isinstance(self.id, str) or not self.id.strip() or "\0" in self.id:
242
+ raise FanoutError(f"invalid branch id {self.id!r}")
243
+ object.__setattr__(self, "write_set", tuple(validate_path(p) for p in self.write_set))
244
+ object.__setattr__(self, "inputs", tuple(validate_path(p) for p in self.inputs))
245
+ if len(set(self.write_set)) != len(self.write_set):
246
+ raise FanoutError(f"branch {self.id!r} lists a write path twice")
247
+
248
+
249
+ BranchStatus = Literal["done", "failed", "rejected", "timeout", "error"]
250
+
251
+
252
+ class BranchResult(BaseModel):
253
+ """The result of one branch; `patch` is kept so the merge (and a re-run) can use it."""
254
+
255
+ model_config = ConfigDict(
256
+ extra="forbid", frozen=True, ser_json_bytes="base64", val_json_bytes="base64"
257
+ )
258
+
259
+ id: str
260
+ status: BranchStatus
261
+ message: str = ""
262
+ write_set: tuple[str, ...] = ()
263
+ inputs: tuple[str, ...] = ()
264
+ inputs_hash: str = Field(description="Hash of the seeded inputs, as read from the user's tree.")
265
+ files: tuple[FileChange, ...] = Field(default=(), description="Files the branch changed.")
266
+ outside_writes: tuple[str, ...] = Field(
267
+ default=(), description="Changed files outside the write set (the branch is rejected)."
268
+ )
269
+ assumptions: tuple[str, ...] = ()
270
+ assumption_keys: dict[str, str] = Field(default_factory=dict)
271
+ agent: AgentResult | None = None
272
+ patch: bytes = Field(default=b"", repr=False, description="The branch's changes.")
273
+ duration_s: float = Field(default=0.0, ge=0)
274
+ reused: bool = Field(default=False, description="Kept from a previous run (F7).")
275
+
276
+ @property
277
+ def ok(self) -> bool:
278
+ """True when the branch finished and stayed inside its write set."""
279
+ return self.status == "done"
280
+
281
+
282
+ class Conflict(BaseModel):
283
+ """A merge conflict (F3): never resolved automatically."""
284
+
285
+ model_config = ConfigDict(extra="forbid", frozen=True)
286
+
287
+ path: str | None = Field(default=None, description="The file, when known.")
288
+ branches: tuple[str, ...] = Field(description="The branches involved.")
289
+ message: str
290
+
291
+
292
+ class AssumptionStatement(BaseModel):
293
+ """What one branch assumed for a key."""
294
+
295
+ model_config = ConfigDict(extra="forbid", frozen=True)
296
+
297
+ branch: str
298
+ value: str
299
+
300
+
301
+ class AssumptionMismatch(BaseModel):
302
+ """Branches that assumed different values for the same key (F4)."""
303
+
304
+ model_config = ConfigDict(extra="forbid", frozen=True)
305
+
306
+ key: str
307
+ statements: tuple[AssumptionStatement, ...]
308
+
309
+ def question(self) -> str:
310
+ """The question a person answers."""
311
+ said = "; ".join(f"branch {s.branch!r} assumes {s.value!r}" for s in self.statements)
312
+ return f"The branches disagree on {self.key}: {said}. Which is right?"
313
+
314
+
315
+ FanoutStatus = Literal["done", "failed", "needs_human"]
316
+
317
+
318
+ class FanoutResult(BaseModel):
319
+ """The outcome of a fan-out. Paths are repo-relative; nothing names the machine."""
320
+
321
+ model_config = ConfigDict(
322
+ extra="forbid", frozen=True, ser_json_bytes="base64", val_json_bytes="base64"
323
+ )
324
+
325
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
326
+ run_id: str
327
+ name: str
328
+ status: FanoutStatus
329
+ message: str = ""
330
+ base: str = Field(description="The commit every workspace started from.")
331
+ branches: tuple[BranchResult, ...] = Field(description="Per branch, in ascending id order.")
332
+ files: tuple[str, ...] = Field(default=(), description="Merged files, sorted.")
333
+ branch: str | None = Field(default=None, description="The result branch, when created.")
334
+ commit: str | None = Field(default=None, description="The merged commit.")
335
+ tree: str | None = Field(default=None, description="The merged commit's tree id.")
336
+ conflicts: tuple[Conflict, ...] = ()
337
+ mismatches: tuple[AssumptionMismatch, ...] = ()
338
+ open_questions: tuple[str, ...] = ()
339
+ join_checks: tuple[CheckResult, ...] = ()
340
+ chosen: str | None = Field(
341
+ default=None, description="Candidates only: the one candidate that was merged."
342
+ )
343
+ duration_s: float = Field(default=0.0, ge=0)
344
+
345
+ @property
346
+ def ok(self) -> bool:
347
+ """True when every branch merged and the join checks passed."""
348
+ return self.status == "done"
349
+
350
+ def branch_result(self, branch_id: str) -> BranchResult | None:
351
+ """The result of `branch_id`, if it was part of this fan-out."""
352
+ return next((b for b in self.branches if b.id == branch_id), None)
353
+
354
+
355
+ @dataclass
356
+ class FanoutSession:
357
+ """What `open_fanout` yields: the result, and the integrated tree while it lives."""
358
+
359
+ result: FanoutResult
360
+ integration: Path | None
361
+ """The integration workspace (absolute); removed when the context exits."""
362
+
363
+
364
+ # --- pure pieces ------------------------------------------------------------------------
365
+
366
+
367
+ def validate_path(path: str) -> str:
368
+ """`path` if it is a normalised repo-relative POSIX path outside `.git`, else raise."""
369
+ if not isinstance(path, str) or not path:
370
+ raise FanoutError(f"invalid path {path!r}: empty")
371
+ if "\0" in path or "\\" in path:
372
+ raise FanoutError(f"invalid path {path!r}: NUL or backslash")
373
+ if path.startswith("/") or re.match(r"^[A-Za-z]:", path):
374
+ raise FanoutError(f"invalid path {path!r}: absolute")
375
+ for part in path.split("/"):
376
+ if part in ("", ".", ".."):
377
+ raise FanoutError(f"invalid path {path!r}: not a normalised relative path")
378
+ if part.casefold() == ".git":
379
+ raise FanoutError(f"invalid path {path!r}: under .git")
380
+ return path
381
+
382
+
383
+ def check_write_sets(write_sets: Mapping[str, Iterable[str]], shared: Iterable[str] = ()) -> None:
384
+ """F2: raise `FanoutError` if two write sets overlap or one includes a shared file.
385
+
386
+ Paths are compared case-insensitively too, since two names that differ only in case
387
+ are the same file on a case-insensitive file system.
388
+ """
389
+ shared_keys = {validate_path(p).casefold(): p for p in shared}
390
+ owners: dict[str, tuple[str, str]] = {}
391
+ for branch_id in sorted(write_sets):
392
+ for path in write_sets[branch_id]:
393
+ key = validate_path(path).casefold()
394
+ if key in shared_keys:
395
+ raise FanoutError(
396
+ f"branch {branch_id!r} writes {path!r}, a shared file: only a gen rule "
397
+ "or the one integration task may write it (F2)"
398
+ )
399
+ other = owners.get(key)
400
+ if other is not None and other[0] != branch_id:
401
+ raise FanoutError(
402
+ f"branches {other[0]!r} and {branch_id!r} both write {path!r} (F2)"
403
+ )
404
+ owners[key] = (branch_id, path)
405
+
406
+
407
+ _KEYED_RE = re.compile(r"^\s*([^\s=]+)\s*=\s*(\S.*?)\s*$")
408
+
409
+
410
+ def keyed_assumptions(statements: Iterable[str]) -> dict[str, str]:
411
+ """The `key=value` statements among `statements`, as key -> value (later wins)."""
412
+ keys: dict[str, str] = {}
413
+ for text in statements:
414
+ match = _KEYED_RE.match(text)
415
+ if match is not None:
416
+ keys[match.group(1)] = match.group(2)
417
+ return keys
418
+
419
+
420
+ def normalize_assumption(text: str) -> str:
421
+ """Case, whitespace and punctuation folded away; a decimal point between digits kept."""
422
+ folded = unicodedata.normalize("NFKC", text).casefold()
423
+ folded = re.sub(r"(?<=\d)\.(?=\d)", "\x00", folded)
424
+ folded = re.sub(r"[^\w\x00]+", " ", folded).replace("\x00", ".")
425
+ return " ".join(folded.split())
426
+
427
+
428
+ def _normalize_key(key: str) -> str:
429
+ return " ".join(unicodedata.normalize("NFKC", key).casefold().split())
430
+
431
+
432
+ def compare_assumptions(
433
+ per_branch: Mapping[str, Mapping[str, str]],
434
+ ) -> tuple[AssumptionMismatch, ...]:
435
+ """F4: the keys two or more branches assumed different values for (no model needed).
436
+
437
+ A key only one branch mentions is fine. Keys and values are compared after
438
+ `normalize_assumption`; the statements are reported as the branches wrote them.
439
+ """
440
+ by_key: dict[str, list[tuple[str, str, str]]] = {}
441
+ for branch_id in sorted(per_branch):
442
+ for key, value in sorted(per_branch[branch_id].items()):
443
+ by_key.setdefault(_normalize_key(key), []).append((branch_id, key, value))
444
+ mismatches: list[AssumptionMismatch] = []
445
+ for norm in sorted(by_key):
446
+ entries = by_key[norm]
447
+ if len({normalize_assumption(value) for _, _, value in entries}) > 1:
448
+ mismatches.append(
449
+ AssumptionMismatch(
450
+ key=entries[0][1],
451
+ statements=tuple(AssumptionStatement(branch=b, value=v) for b, _, v in entries),
452
+ )
453
+ )
454
+ return tuple(mismatches)
455
+
456
+
457
+ # --- files: seeding and hashing (read-only on the user's tree) --------------------------
458
+
459
+
460
+ def _within(path: Path, base: Path) -> bool:
461
+ return path.resolve().is_relative_to(base.resolve())
462
+
463
+
464
+ def _input_files(root: Path, rel: str) -> list[str] | None:
465
+ """The repo-relative files `rel` stands for in `root` (a file or a directory).
466
+
467
+ `None` when `rel` does not exist. Symlinks count as files and are never followed.
468
+ """
469
+ src = root / rel
470
+ if not _within(src.parent, root):
471
+ raise FanoutError(f"input {rel!r} resolves outside the repo")
472
+ if src.is_symlink() or src.is_file():
473
+ return [rel]
474
+ if not src.is_dir():
475
+ return None
476
+ found: list[str] = []
477
+ for dirpath, dirnames, filenames in os.walk(src):
478
+ here = Path(dirpath)
479
+ links = [d for d in dirnames if (here / d).is_symlink()]
480
+ dirnames[:] = sorted(d for d in dirnames if d != ".git" and d not in links)
481
+ found.extend((here / name).relative_to(root).as_posix() for name in (*filenames, *links))
482
+ return sorted(found)
483
+
484
+
485
+ def hash_inputs(root: Path, inputs: Sequence[str]) -> str:
486
+ """A hash of the inputs' paths, modes and contents in `root` (missing ones count)."""
487
+ digest = hashlib.sha256()
488
+ for rel in sorted(set(inputs)):
489
+ files = _input_files(root, rel)
490
+ if files is None:
491
+ digest.update(f"{rel}\0missing\0".encode())
492
+ continue
493
+ for item in files:
494
+ path = root / item
495
+ if path.is_symlink():
496
+ content = os.readlink(path).encode()
497
+ kind = "link"
498
+ else:
499
+ content = path.read_bytes()
500
+ kind = "exec" if os.access(path, os.X_OK) else "file"
501
+ digest.update(f"{item}\0{kind}\0".encode())
502
+ digest.update(hashlib.sha256(content).digest())
503
+ return digest.hexdigest()
504
+
505
+
506
+ def _clear(path: Path) -> None:
507
+ if path.is_symlink() or path.is_file():
508
+ path.unlink()
509
+ elif path.is_dir():
510
+ shutil.rmtree(path)
511
+
512
+
513
+ def seed_workspace(root: Path, workspace: Path, inputs: Sequence[str]) -> None:
514
+ """Copy the declared inputs from the user's tree `root` into `workspace`.
515
+
516
+ Only reads `root`. Files keep their mode; symlinks are copied as links; an input
517
+ missing in `root` is removed from the workspace, so it sees what the user sees.
518
+ """
519
+ for rel in inputs:
520
+ files = _input_files(root, rel)
521
+ if files is None:
522
+ target = workspace / rel
523
+ if _within(target.parent, workspace):
524
+ _clear(target)
525
+ continue
526
+ for item in files:
527
+ src, dst = root / item, workspace / item
528
+ dst.parent.mkdir(parents=True, exist_ok=True)
529
+ if not _within(dst.parent, workspace):
530
+ raise FanoutError(f"input {item!r} resolves outside the workspace")
531
+ _clear(dst)
532
+ if src.is_symlink():
533
+ os.symlink(os.readlink(src), dst)
534
+ else:
535
+ shutil.copy2(src, dst)
536
+
537
+
538
+ # --- the run directory, its registry, and stale runs ------------------------------------
539
+
540
+
541
+ def fanout_tmp_root(root: Path, layout: StateLayout | None = None) -> Path:
542
+ """Where run directories go: `<state>/tmp/fanout` if outside the repo, else system temp."""
543
+ if layout is not None:
544
+ candidate = layout.tmp_dir / "fanout"
545
+ if not _within(candidate, root):
546
+ return candidate
547
+ return Path(tempfile.gettempdir())
548
+
549
+
550
+ class FanoutRegistry(BaseModel):
551
+ """The registry file of one run directory (machine-local, never committed)."""
552
+
553
+ model_config = ConfigDict(extra="forbid", frozen=True)
554
+
555
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
556
+ run_id: str
557
+ pid: int
558
+ host: str
559
+ repo: str = Field(description="The resolved repo root the workspaces belong to.")
560
+ started_at: datetime
561
+ workspaces: tuple[str, ...] = ()
562
+
563
+
564
+ def _write_registry(run_dir: Path, registry: FanoutRegistry) -> None:
565
+ path = run_dir / REGISTRY_FILE
566
+ tmp = path.with_suffix(".json.tmp")
567
+ tmp.write_text(registry.model_dump_json(indent=2), encoding="utf-8")
568
+ os.replace(tmp, path)
569
+
570
+
571
+ def _read_registry(run_dir: Path) -> FanoutRegistry | None:
572
+ try:
573
+ return FanoutRegistry.model_validate_json(
574
+ (run_dir / REGISTRY_FILE).read_text(encoding="utf-8")
575
+ )
576
+ except (OSError, ValueError):
577
+ return None
578
+
579
+
580
+ def _pid_alive(pid: int) -> bool:
581
+ if pid <= 0:
582
+ return False
583
+ try:
584
+ os.kill(pid, 0)
585
+ except ProcessLookupError:
586
+ return False
587
+ except PermissionError:
588
+ return True # exists, owned by someone else
589
+ except OSError:
590
+ return False
591
+ return True
592
+
593
+
594
+ class StaleRun(BaseModel):
595
+ """A run directory whose owner process is gone (machine-local report)."""
596
+
597
+ model_config = ConfigDict(extra="forbid", frozen=True)
598
+
599
+ run_dir: str
600
+ run_id: str
601
+ pid: int
602
+ workspaces: tuple[str, ...]
603
+
604
+
605
+ def find_stale(
606
+ root: Path, *, tmp_root: Path | None = None, layout: StateLayout | None = None
607
+ ) -> tuple[StaleRun, ...]:
608
+ """Run directories of the repo at `root` whose owner pid is dead on this host.
609
+
610
+ A run directory without a readable registry is skipped (it may be starting up).
611
+ """
612
+ base = tmp_root if tmp_root is not None else fanout_tmp_root(root, layout)
613
+ if not base.is_dir():
614
+ return ()
615
+ repo = str(root.resolve())
616
+ host = socket.gethostname()
617
+ stale: list[StaleRun] = []
618
+ for run_dir in sorted(base.glob(f"{TMP_PREFIX}*")):
619
+ if not run_dir.is_dir() or run_dir.is_symlink():
620
+ continue
621
+ registry = _read_registry(run_dir)
622
+ if registry is None or registry.host != host or registry.repo != repo:
623
+ continue
624
+ if _pid_alive(registry.pid):
625
+ continue
626
+ stale.append(
627
+ StaleRun(
628
+ run_dir=str(run_dir),
629
+ run_id=registry.run_id,
630
+ pid=registry.pid,
631
+ workspaces=registry.workspaces,
632
+ )
633
+ )
634
+ return tuple(stale)
635
+
636
+
637
+ def sweep_stale(
638
+ root: Path,
639
+ vcs: FanoutVcs,
640
+ *,
641
+ tmp_root: Path | None = None,
642
+ layout: StateLayout | None = None,
643
+ ) -> tuple[StaleRun, ...]:
644
+ """Remove the workspaces and run directories `find_stale` reports; prune the repo."""
645
+ stale = find_stale(root, tmp_root=tmp_root, layout=layout)
646
+ if not stale:
647
+ return ()
648
+ try:
649
+ known = vcs.list_workspaces(root)
650
+ except Exception:
651
+ known = ()
652
+ for run in stale:
653
+ run_dir = Path(run.run_dir)
654
+ paths = [Path(p) for p in run.workspaces]
655
+ paths += [p for p in known if _within(p, run_dir) and p not in paths]
656
+ for path in paths:
657
+ with contextlib.suppress(Exception):
658
+ vcs.remove(path)
659
+ shutil.rmtree(run_dir, ignore_errors=True)
660
+ with contextlib.suppress(Exception):
661
+ vcs.prune(root)
662
+ return stale
663
+
664
+
665
+ # --- the run ----------------------------------------------------------------------------
666
+
667
+
668
+ def _noop_emit(_event: str, _payload: dict[str, Any]) -> None:
669
+ return None
670
+
671
+
672
+ def _emitter(ctx: ExecContext | None, run_id: str) -> FanoutEmit:
673
+ """Fan-out events as `tool_call` journal events through the scheduler's context."""
674
+ if ctx is None:
675
+ return _noop_emit
676
+ exec_ctx = ctx
677
+
678
+ def emit(event: str, payload: dict[str, Any]) -> None:
679
+ exec_ctx.emit("tool_call", {"tool": "fanout", "event": event, "fanout": run_id, **payload})
680
+
681
+ return emit
682
+
683
+
684
+ def _join_error(index: int, exc: BaseException) -> CheckResult:
685
+ message = f"{type(exc).__name__}: {exc}"
686
+ check_id = f"fanout.join[{index}]"
687
+ return CheckResult(
688
+ check_id=check_id,
689
+ status="error",
690
+ issues=(Issue(rule="join.error", msg=message),),
691
+ log_tail=message,
692
+ duration_s=0.0,
693
+ idempotency_key=f"{check_id}:error",
694
+ )
695
+
696
+
697
+ def _order(branches: Sequence[FanoutBranch]) -> tuple[FanoutBranch, ...]:
698
+ seen: set[str] = set()
699
+ for branch in branches:
700
+ if not isinstance(branch, FanoutBranch):
701
+ raise FanoutError(f"not a FanoutBranch: {branch!r}")
702
+ if branch.id in seen:
703
+ raise FanoutError(f"branch id {branch.id!r} is used twice")
704
+ seen.add(branch.id)
705
+ return tuple(sorted(branches, key=lambda b: b.id))
706
+
707
+
708
+ @dataclass
709
+ class _Run:
710
+ root: Path
711
+ vcs: FanoutVcs
712
+ branches: tuple[FanoutBranch, ...]
713
+ run_id: str
714
+ name: str
715
+ join_checks: tuple[JoinCheck, ...]
716
+ limit: int
717
+ timeout: float | None
718
+ previous: FanoutResult | None
719
+ run_dir: Path
720
+ emit: FanoutEmit
721
+ make_branch: bool
722
+ keep_workspaces: bool = False
723
+ """Candidates: keep each branch's workspace until the run ends (for the chooser)."""
724
+ started: float = field(default_factory=time.monotonic)
725
+ base: str = ""
726
+ workspaces: list[Path] = field(default_factory=list)
727
+ tasks: list[asyncio.Task[None]] = field(default_factory=list)
728
+ registry: FanoutRegistry | None = None
729
+ kept: dict[str, Path] = field(default_factory=dict)
730
+
731
+ # --- workspaces --------------------------------------------------------------------
732
+
733
+ def write_registry(self) -> None:
734
+ self.registry = FanoutRegistry(
735
+ run_id=self.run_id,
736
+ pid=os.getpid(),
737
+ host=socket.gethostname(),
738
+ repo=str(self.root),
739
+ started_at=datetime.now(UTC),
740
+ workspaces=tuple(str(p) for p in self.workspaces),
741
+ )
742
+ _write_registry(self.run_dir, self.registry)
743
+
744
+ def workspace(self, name: str, inputs: Sequence[str]) -> Path:
745
+ """A workspace at the base commit with `inputs` copied in (registered first)."""
746
+ self.workspaces.append(self.run_dir / name)
747
+ self.write_registry() # before it exists: a kill now still leaves a record
748
+ path = self.vcs.create_branch_workspace(self.root, self.base, name, self.run_dir)
749
+ seed_workspace(self.root, path, inputs)
750
+ return path
751
+
752
+ def drop(self, path: Path) -> None:
753
+ with contextlib.suppress(Exception):
754
+ self.vcs.remove(path)
755
+ if path.exists() or path.is_symlink():
756
+ shutil.rmtree(path, ignore_errors=True)
757
+
758
+ async def stop_tasks(self) -> None:
759
+ pending = [task for task in self.tasks if not task.done()]
760
+ for task in pending:
761
+ task.cancel()
762
+ if pending:
763
+ with contextlib.suppress(BaseException):
764
+ await asyncio.gather(*pending, return_exceptions=True)
765
+
766
+ def cleanup(self) -> None:
767
+ """Remove every workspace and the run directory; prune the repo. Never raises."""
768
+ for path in self.workspaces:
769
+ self.drop(path)
770
+ shutil.rmtree(self.run_dir, ignore_errors=True)
771
+ with contextlib.suppress(Exception):
772
+ self.vcs.prune(self.root)
773
+
774
+ # --- one branch --------------------------------------------------------------------
775
+
776
+ def reusable(self, branch: FanoutBranch, inputs_hash: str) -> BranchResult | None:
777
+ """F7: a done result of `branch` from `previous`, if nothing it depends on changed."""
778
+ if self.previous is None or self.previous.base != self.base:
779
+ return None
780
+ old = self.previous.branch_result(branch.id)
781
+ if (
782
+ old is None
783
+ or not old.ok
784
+ or old.write_set != branch.write_set
785
+ or old.inputs != branch.inputs
786
+ or old.inputs_hash != inputs_hash
787
+ ):
788
+ return None
789
+ return old.model_copy(update={"reused": True})
790
+
791
+ def branch_emit(self, branch_id: str) -> FanoutEmit:
792
+ def emit(event: str, payload: dict[str, Any]) -> None:
793
+ self.emit(event, {"branch": branch_id, **payload})
794
+
795
+ return emit
796
+
797
+ async def run_branch(self, index: int, branch: FanoutBranch, inputs_hash: str) -> BranchResult:
798
+ started = time.monotonic()
799
+ common: dict[str, Any] = {
800
+ "id": branch.id,
801
+ "write_set": branch.write_set,
802
+ "inputs": branch.inputs,
803
+ "inputs_hash": inputs_hash,
804
+ }
805
+
806
+ def result(status: BranchStatus, message: str, **extra: Any) -> BranchResult:
807
+ done = BranchResult(
808
+ status=status,
809
+ message=message,
810
+ duration_s=time.monotonic() - started,
811
+ **common,
812
+ **extra,
813
+ )
814
+ self.emit(
815
+ "branch_done",
816
+ {"branch": branch.id, "status": status, "message": message},
817
+ )
818
+ return done
819
+
820
+ self.emit("branch_start", {"branch": branch.id})
821
+ path: Path | None = None
822
+ try:
823
+ try:
824
+ path = self.workspace(f"b{index:03d}", branch.inputs)
825
+ seed = self.vcs.commit_all(path, f"chipgraph fan-out: seed {branch.id}")
826
+ except Exception as exc:
827
+ return result("error", f"workspace: {type(exc).__name__}: {exc}")
828
+
829
+ ctx = BranchContext(
830
+ branch_id=branch.id,
831
+ run_id=self.run_id,
832
+ path=path,
833
+ write_set=branch.write_set,
834
+ inputs=branch.inputs,
835
+ emit=self.branch_emit(branch.id),
836
+ )
837
+ scope = asyncio.timeout(self.timeout)
838
+ try:
839
+ async with scope:
840
+ outcome = await branch.run(ctx)
841
+ except TimeoutError as exc:
842
+ if scope.expired():
843
+ return result("timeout", f"stopped after {self.timeout} s")
844
+ return result("error", f"{type(exc).__name__}: {exc}")
845
+ except Exception as exc:
846
+ return result("error", f"{type(exc).__name__}: {exc}")
847
+ if not isinstance(outcome, BranchOutcome):
848
+ return result("error", f"returned {type(outcome).__name__}, not a BranchOutcome")
849
+
850
+ try:
851
+ commit = self.vcs.commit_all(
852
+ path, f"chipgraph fan-out: {branch.id}", include_ignored=branch.write_set
853
+ )
854
+ changes = self.vcs.diff(path, seed, commit)
855
+ allowed = set(branch.write_set)
856
+ outside = tuple(c.path for c in changes if c.path not in allowed)
857
+ patch = (
858
+ self.vcs.patch(path, seed, commit, [c.path for c in changes])
859
+ if changes and not outside
860
+ else b""
861
+ )
862
+ except Exception as exc:
863
+ return result("error", f"collect: {type(exc).__name__}: {exc}")
864
+
865
+ extra: dict[str, Any] = {
866
+ "files": changes,
867
+ "outside_writes": outside,
868
+ "assumptions": outcome.assumptions,
869
+ "assumption_keys": dict(outcome.assumption_keys)
870
+ or keyed_assumptions(outcome.assumptions),
871
+ "agent": outcome.agent,
872
+ }
873
+ if outside:
874
+ return result(
875
+ "rejected",
876
+ f"changed files outside its write set: {', '.join(outside)}",
877
+ **extra,
878
+ )
879
+ if not outcome.ok:
880
+ return result("failed", outcome.message or "the branch reported a failure", **extra)
881
+ return result("done", outcome.message, patch=patch, **extra)
882
+ finally:
883
+ if path is not None and self.keep_workspaces:
884
+ self.kept[branch.id] = path # removed by `cleanup`
885
+ elif path is not None:
886
+ self.drop(path)
887
+
888
+ # --- the whole fan-out ---------------------------------------------------------------
889
+
890
+ def finish(
891
+ self, status: FanoutStatus, message: str, branches: tuple[BranchResult, ...], **extra: Any
892
+ ) -> FanoutResult:
893
+ result = FanoutResult(
894
+ run_id=self.run_id,
895
+ name=self.name,
896
+ status=status,
897
+ message=message,
898
+ base=self.base,
899
+ branches=branches,
900
+ duration_s=time.monotonic() - self.started,
901
+ **extra,
902
+ )
903
+ self.emit(
904
+ "done",
905
+ {"status": status, "message": message, "branch_name": result.branch},
906
+ )
907
+ return result
908
+
909
+ async def run_branches(self) -> tuple[BranchResult, ...]:
910
+ results: dict[str, BranchResult] = {}
911
+ pending: list[tuple[int, FanoutBranch, str]] = []
912
+ for index, branch in enumerate(self.branches):
913
+ inputs_hash = hash_inputs(self.root, branch.inputs)
914
+ kept = self.reusable(branch, inputs_hash)
915
+ if kept is not None:
916
+ results[branch.id] = kept
917
+ self.emit("branch_kept", {"branch": branch.id})
918
+ else:
919
+ pending.append((index, branch, inputs_hash))
920
+
921
+ semaphore = asyncio.Semaphore(self.limit)
922
+ fatal: list[BaseException] = []
923
+
924
+ async def guarded(index: int, branch: FanoutBranch, inputs_hash: str) -> None:
925
+ try:
926
+ async with semaphore:
927
+ results[branch.id] = await self.run_branch(index, branch, inputs_hash)
928
+ except (KeyboardInterrupt, SystemExit) as exc:
929
+ # Raised out of a task, these would leave the event loop at once and skip
930
+ # every `finally`: stop the other branches and re-raise after cleanup.
931
+ fatal.append(exc)
932
+ current = asyncio.current_task()
933
+ for task in self.tasks:
934
+ if task is not current:
935
+ task.cancel()
936
+
937
+ self.tasks = [asyncio.create_task(guarded(*item)) for item in pending]
938
+ await asyncio.gather(*self.tasks, return_exceptions=True)
939
+ if fatal:
940
+ raise fatal[0]
941
+ for branch in self.branches:
942
+ if branch.id not in results:
943
+ results[branch.id] = BranchResult(
944
+ id=branch.id,
945
+ status="error",
946
+ message="did not finish",
947
+ write_set=branch.write_set,
948
+ inputs=branch.inputs,
949
+ inputs_hash="",
950
+ )
951
+ return tuple(results[b.id] for b in self.branches)
952
+
953
+ async def run_join_checks(self, integration: Path) -> tuple[CheckResult, ...]:
954
+ out: list[CheckResult] = []
955
+ for index, check in enumerate(self.join_checks):
956
+ try:
957
+ checked = await check(integration)
958
+ except Exception as exc:
959
+ checked = _join_error(index, exc)
960
+ out.append(checked)
961
+ self.emit("join_check", {"check": checked.check_id, "status": checked.status})
962
+ return tuple(out)
963
+
964
+ async def execute(self) -> tuple[FanoutResult, Path | None]:
965
+ self.base = self.vcs.head(self.root)
966
+ self.emit(
967
+ "start",
968
+ {"name": self.name, "base": self.base, "branches": [b.id for b in self.branches]},
969
+ )
970
+ ordered = await self.run_branches()
971
+
972
+ mismatches = compare_assumptions({r.id: r.assumption_keys for r in ordered if r.ok})
973
+ bad = [r.id for r in ordered if not r.ok]
974
+ if bad:
975
+ return self.finish(
976
+ "failed",
977
+ f"branch(es) did not finish: {', '.join(bad)} (re-run them with `previous=`)",
978
+ ordered,
979
+ mismatches=mismatches,
980
+ ), None
981
+ if mismatches:
982
+ return self.finish(
983
+ "needs_human",
984
+ "the branches' assumptions differ; a person must decide (F4)",
985
+ ordered,
986
+ mismatches=mismatches,
987
+ open_questions=tuple(m.question() for m in mismatches),
988
+ ), None
989
+
990
+ conflicts: list[Conflict] = []
991
+ owners: dict[str, str] = {}
992
+ for res in ordered:
993
+ for change in res.files:
994
+ other = owners.setdefault(change.path.casefold(), res.id)
995
+ if other != res.id:
996
+ conflicts.append(
997
+ Conflict(
998
+ path=change.path,
999
+ branches=(other, res.id),
1000
+ message=f"{change.path} is changed by {other!r} and {res.id!r}",
1001
+ )
1002
+ )
1003
+ if conflicts:
1004
+ return self.finish(
1005
+ "failed", "merge conflict (F3)", ordered, conflicts=tuple(conflicts)
1006
+ ), None
1007
+
1008
+ listing = "\n".join(f"- {r.id}" for r in ordered)
1009
+ return await self.integrate(
1010
+ ordered,
1011
+ ordered,
1012
+ done=f"{len(ordered)} branch(es) merged",
1013
+ note=f"Branches, in merge order:\n{listing}\n",
1014
+ )
1015
+
1016
+ async def integrate(
1017
+ self,
1018
+ ordered: tuple[BranchResult, ...],
1019
+ merged: Sequence[BranchResult],
1020
+ *,
1021
+ done: str,
1022
+ note: str,
1023
+ **extra: Any,
1024
+ ) -> tuple[FanoutResult, Path | None]:
1025
+ """F3 and F5: apply `merged`'s patches onto the integration workspace, in order,
1026
+ run the join checks there, commit, and create the result branch."""
1027
+ union = sorted({p for b in self.branches for p in b.inputs})
1028
+ integration = self.workspace(JOIN_WORKSPACE, union)
1029
+ for res in merged: # ascending id order (F7)
1030
+ if not res.patch:
1031
+ continue
1032
+ error = self.vcs.apply_patch(integration, res.patch)
1033
+ if error is not None:
1034
+ conflict = Conflict(
1035
+ branches=(res.id,),
1036
+ message=f"branch {res.id!r} does not apply onto the integration: {error}",
1037
+ )
1038
+ return self.finish(
1039
+ "failed", "merge conflict (F3)", ordered, conflicts=(conflict,), **extra
1040
+ ), None
1041
+ files = tuple(sorted(c.path for r in merged for c in r.files))
1042
+ self.emit("merged", {"files": list(files)})
1043
+
1044
+ checks = await self.run_join_checks(integration)
1045
+ failed_checks = [c.check_id for c in checks if not c.ok]
1046
+ if failed_checks:
1047
+ return self.finish(
1048
+ "failed",
1049
+ f"join check(s) failed: {', '.join(failed_checks)} (F5); no branch created",
1050
+ ordered,
1051
+ files=files,
1052
+ join_checks=checks,
1053
+ **extra,
1054
+ ), integration
1055
+
1056
+ message = f"chipgraph fan-out {self.name} ({self.run_id})\n\n{note}"
1057
+ commit = self.vcs.commit_all(integration, message, paths=files)
1058
+ tree = self.vcs.tree_hash(integration, commit)
1059
+ branch_name: str | None = None
1060
+ if self.make_branch:
1061
+ branch_name = f"chipgraph/{self.run_id}/{self.name}"
1062
+ self.vcs.create_branch(self.root, branch_name, commit)
1063
+ return self.finish(
1064
+ "done",
1065
+ done,
1066
+ ordered,
1067
+ files=files,
1068
+ branch=branch_name,
1069
+ commit=commit,
1070
+ tree=tree,
1071
+ join_checks=checks,
1072
+ **extra,
1073
+ ), integration
1074
+
1075
+ async def execute_candidates(
1076
+ self, choose: CandidateChooser
1077
+ ) -> tuple[FanoutResult, Path | None]:
1078
+ """Candidates: run them all, let `choose` pick one while every workspace still
1079
+ exists, and merge only that one (F2 and F4 do not apply between candidates)."""
1080
+ self.base = self.vcs.head(self.root)
1081
+ self.emit(
1082
+ "start",
1083
+ {"name": self.name, "base": self.base, "candidates": [b.id for b in self.branches]},
1084
+ )
1085
+ ordered = await self.run_branches()
1086
+ chosen = await choose(ordered, dict(self.kept))
1087
+ self.emit("chosen", {"candidate": chosen})
1088
+ if chosen is None:
1089
+ return self.finish("failed", "no candidate was chosen", ordered), None
1090
+ winner = next((r for r in ordered if r.id == chosen), None)
1091
+ if winner is None:
1092
+ raise FanoutError(f"the chooser picked {chosen!r}, which is not a candidate")
1093
+ if not winner.ok:
1094
+ return self.finish(
1095
+ "failed",
1096
+ f"the chosen candidate {chosen!r} did not finish ({winner.status})",
1097
+ ordered,
1098
+ chosen=chosen,
1099
+ ), None
1100
+ return await self.integrate(
1101
+ ordered,
1102
+ (winner,),
1103
+ done=f"candidate {chosen!r} chosen of {len(ordered)}",
1104
+ note=f"Chosen candidate: {chosen} (of {', '.join(r.id for r in ordered)})\n",
1105
+ chosen=chosen,
1106
+ )
1107
+
1108
+
1109
+ @contextlib.asynccontextmanager
1110
+ async def open_fanout(
1111
+ branches: Sequence[FanoutBranch],
1112
+ *,
1113
+ root: Path,
1114
+ vcs: FanoutVcs,
1115
+ name: str = "fanout",
1116
+ run_id: str | None = None,
1117
+ shared: Iterable[str] = (),
1118
+ join_checks: Sequence[JoinCheck] = (),
1119
+ max_parallel: int | None = None,
1120
+ branch_timeout_s: float | None = None,
1121
+ previous: FanoutResult | None = None,
1122
+ tmp_root: Path | None = None,
1123
+ layout: StateLayout | None = None,
1124
+ ctx: ExecContext | None = None,
1125
+ make_branch: bool = True,
1126
+ ) -> AsyncIterator[FanoutSession]:
1127
+ """Run a fan-out and yield its result with the integrated tree, until the context exits.
1128
+
1129
+ `root` is the user's repo (only read, plus the result branch). `shared` lists files
1130
+ no branch may write (F2). `max_parallel` defaults to `min(4, cpu count)`; profiles set
1131
+ it as `fanout.max_parallel` (`FanoutCfg`). `previous` keeps the done branches of an
1132
+ earlier result (F7). `tmp_root` overrides where the run directory goes (it must be
1133
+ outside the working tree). `ctx` journals `tool_call` events. With `make_branch`
1134
+ false the merged commit is made but no branch points at it.
1135
+
1136
+ Raises `FanoutError` before anything starts for an F2 overlap, bad paths, ids or
1137
+ limits. Every workspace and the run directory are removed when the context exits,
1138
+ whatever the reason (an exception, a cancel, KeyboardInterrupt).
1139
+ """
1140
+ ordered = _order(branches)
1141
+ check_write_sets({b.id: b.write_set for b in ordered}, shared)
1142
+ async with _open_run(
1143
+ ordered,
1144
+ root=root,
1145
+ vcs=vcs,
1146
+ name=name,
1147
+ run_id=run_id,
1148
+ join_checks=join_checks,
1149
+ max_parallel=max_parallel,
1150
+ branch_timeout_s=branch_timeout_s,
1151
+ previous=previous,
1152
+ tmp_root=tmp_root,
1153
+ layout=layout,
1154
+ ctx=ctx,
1155
+ make_branch=make_branch,
1156
+ choose=None,
1157
+ ) as session:
1158
+ yield session
1159
+
1160
+
1161
+ @contextlib.asynccontextmanager
1162
+ async def _open_run(
1163
+ ordered: tuple[FanoutBranch, ...],
1164
+ *,
1165
+ root: Path,
1166
+ vcs: FanoutVcs,
1167
+ name: str,
1168
+ run_id: str | None,
1169
+ join_checks: Sequence[JoinCheck],
1170
+ max_parallel: int | None,
1171
+ branch_timeout_s: float | None,
1172
+ previous: FanoutResult | None,
1173
+ tmp_root: Path | None,
1174
+ layout: StateLayout | None,
1175
+ ctx: ExecContext | None,
1176
+ make_branch: bool,
1177
+ choose: CandidateChooser | None,
1178
+ ) -> AsyncIterator[FanoutSession]:
1179
+ """The run shared by `open_fanout` and `run_candidates` (after their own checks)."""
1180
+ repo = root.resolve()
1181
+ rid = run_id if run_id is not None else new_run_id()
1182
+ if not _RUN_ID_RE.match(rid):
1183
+ raise FanoutError(f"invalid run id {rid!r}")
1184
+ if not _NAME_RE.match(name):
1185
+ raise FanoutError(f"invalid fan-out name {name!r}: must match {_NAME_RE.pattern!r}")
1186
+ limit = max_parallel if max_parallel is not None else FanoutCfg().effective_max_parallel()
1187
+ if limit < 1:
1188
+ raise FanoutError(f"max_parallel must be >= 1, got {limit}")
1189
+ if branch_timeout_s is not None and branch_timeout_s <= 0:
1190
+ raise FanoutError(f"branch_timeout_s must be > 0, got {branch_timeout_s}")
1191
+ base_root = tmp_root if tmp_root is not None else fanout_tmp_root(repo, layout)
1192
+ if _within(base_root, repo):
1193
+ raise FanoutError(f"the fan-out directory {base_root} is inside the working tree")
1194
+
1195
+ sweep_stale(repo, vcs, tmp_root=base_root)
1196
+ base_root.mkdir(parents=True, exist_ok=True)
1197
+ run_dir = Path(tempfile.mkdtemp(prefix=f"{TMP_PREFIX}{rid}-", dir=base_root)).resolve()
1198
+ run = _Run(
1199
+ root=repo,
1200
+ vcs=vcs,
1201
+ branches=ordered,
1202
+ run_id=rid,
1203
+ name=name,
1204
+ join_checks=tuple(join_checks),
1205
+ limit=limit,
1206
+ timeout=branch_timeout_s,
1207
+ previous=previous,
1208
+ run_dir=run_dir,
1209
+ emit=_emitter(ctx, rid),
1210
+ make_branch=make_branch,
1211
+ keep_workspaces=choose is not None,
1212
+ )
1213
+ try:
1214
+ run.write_registry()
1215
+ if choose is not None:
1216
+ result, integration = await run.execute_candidates(choose)
1217
+ else:
1218
+ result, integration = await run.execute()
1219
+ yield FanoutSession(result=result, integration=integration)
1220
+ finally:
1221
+ await run.stop_tasks()
1222
+ run.cleanup()
1223
+
1224
+
1225
+ async def run_fanout(
1226
+ branches: Sequence[FanoutBranch],
1227
+ *,
1228
+ root: Path,
1229
+ vcs: FanoutVcs,
1230
+ name: str = "fanout",
1231
+ run_id: str | None = None,
1232
+ shared: Iterable[str] = (),
1233
+ join_checks: Sequence[JoinCheck] = (),
1234
+ max_parallel: int | None = None,
1235
+ branch_timeout_s: float | None = None,
1236
+ previous: FanoutResult | None = None,
1237
+ tmp_root: Path | None = None,
1238
+ layout: StateLayout | None = None,
1239
+ ctx: ExecContext | None = None,
1240
+ make_branch: bool = True,
1241
+ ) -> FanoutResult:
1242
+ """`open_fanout` without the integrated tree: run, clean up, return the result."""
1243
+ async with open_fanout(
1244
+ branches,
1245
+ root=root,
1246
+ vcs=vcs,
1247
+ name=name,
1248
+ run_id=run_id,
1249
+ shared=shared,
1250
+ join_checks=join_checks,
1251
+ max_parallel=max_parallel,
1252
+ branch_timeout_s=branch_timeout_s,
1253
+ previous=previous,
1254
+ tmp_root=tmp_root,
1255
+ layout=layout,
1256
+ ctx=ctx,
1257
+ make_branch=make_branch,
1258
+ ) as session:
1259
+ return session.result
1260
+
1261
+
1262
+ async def run_candidates(
1263
+ candidates: Sequence[FanoutBranch],
1264
+ *,
1265
+ choose: CandidateChooser,
1266
+ root: Path,
1267
+ vcs: FanoutVcs,
1268
+ name: str = "candidates",
1269
+ run_id: str | None = None,
1270
+ shared: Iterable[str] = (),
1271
+ join_checks: Sequence[JoinCheck] = (),
1272
+ max_parallel: int | None = None,
1273
+ branch_timeout_s: float | None = None,
1274
+ tmp_root: Path | None = None,
1275
+ layout: StateLayout | None = None,
1276
+ ctx: ExecContext | None = None,
1277
+ make_branch: bool = True,
1278
+ ) -> FanoutResult:
1279
+ """Candidates of ONE piece of work: run each, merge only the one `choose` picks.
1280
+
1281
+ Every candidate gets its own workspace, seeded identically, and must declare the same
1282
+ write set and inputs (they overlap by design, so F2 does not apply between them; the
1283
+ write set is still checked against `shared`). Each is diffed against its seed as in
1284
+ `run_fanout` (a change outside the write set rejects it). When all have finished,
1285
+ `choose` gets their results and their workspaces, which still exist; the chosen
1286
+ candidate's patch alone is applied onto the integration workspace, the join checks
1287
+ run there (F5), and the result branch is created. Workspaces are removed on any exit,
1288
+ as in `open_fanout`. `FanoutResult.chosen` names the merged candidate.
1289
+ """
1290
+ ordered = _order(candidates)
1291
+ if not ordered:
1292
+ raise FanoutError("run_candidates needs at least one candidate")
1293
+ first = ordered[0]
1294
+ for other in ordered[1:]:
1295
+ if set(other.write_set) != set(first.write_set) or set(other.inputs) != set(first.inputs):
1296
+ raise FanoutError(
1297
+ f"candidates {first.id!r} and {other.id!r} differ in write set or inputs: "
1298
+ "candidates are the same work, run more than once"
1299
+ )
1300
+ check_write_sets({first.id: first.write_set}, shared)
1301
+ async with _open_run(
1302
+ ordered,
1303
+ root=root,
1304
+ vcs=vcs,
1305
+ name=name,
1306
+ run_id=run_id,
1307
+ join_checks=join_checks,
1308
+ max_parallel=max_parallel,
1309
+ branch_timeout_s=branch_timeout_s,
1310
+ previous=None,
1311
+ tmp_root=tmp_root,
1312
+ layout=layout,
1313
+ ctx=ctx,
1314
+ make_branch=make_branch,
1315
+ choose=choose,
1316
+ ) as session:
1317
+ return session.result
1318
+
1319
+
1320
+ __all__ = [
1321
+ "JOIN_WORKSPACE",
1322
+ "REGISTRY_FILE",
1323
+ "TMP_PREFIX",
1324
+ "AssumptionMismatch",
1325
+ "AssumptionStatement",
1326
+ "BranchContext",
1327
+ "BranchFn",
1328
+ "BranchOutcome",
1329
+ "BranchResult",
1330
+ "BranchStatus",
1331
+ "CandidateChooser",
1332
+ "Conflict",
1333
+ "FanoutBranch",
1334
+ "FanoutEmit",
1335
+ "FanoutError",
1336
+ "FanoutRegistry",
1337
+ "FanoutResult",
1338
+ "FanoutSession",
1339
+ "FanoutStatus",
1340
+ "FanoutVcs",
1341
+ "FileChange",
1342
+ "FileStatus",
1343
+ "JoinCheck",
1344
+ "StaleRun",
1345
+ "check_write_sets",
1346
+ "compare_assumptions",
1347
+ "fanout_tmp_root",
1348
+ "find_stale",
1349
+ "hash_inputs",
1350
+ "keyed_assumptions",
1351
+ "normalize_assumption",
1352
+ "open_fanout",
1353
+ "run_candidates",
1354
+ "run_fanout",
1355
+ "seed_workspace",
1356
+ "sweep_stale",
1357
+ "validate_path",
1358
+ ]