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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- 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
|
+
]
|