cih-agent 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.
- cih/__init__.py +1 -0
- cih/agents.py +57 -0
- cih/attempts.py +61 -0
- cih/config.py +81 -0
- cih/contracts.py +32 -0
- cih/integration.py +192 -0
- cih/ledger.py +102 -0
- cih/merge_queue.py +37 -0
- cih/orchestrator.py +233 -0
- cih/progress.py +16 -0
- cih/report.py +176 -0
- cih/roles.py +59 -0
- cih/runner.py +80 -0
- cih/safety.py +68 -0
- cih/staging.py +65 -0
- cih/state.py +46 -0
- cih/tdd_verifier.py +131 -0
- cih/team.py +78 -0
- cih/transitions.py +32 -0
- cih/worktree.py +39 -0
- cih_agent-0.1.0.dist-info/METADATA +148 -0
- cih_agent-0.1.0.dist-info/RECORD +26 -0
- cih_agent-0.1.0.dist-info/WHEEL +5 -0
- cih_agent-0.1.0.dist-info/entry_points.txt +2 -0
- cih_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- cih_agent-0.1.0.dist-info/top_level.txt +1 -0
cih/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
cih/agents.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# cih/agents.py
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
from cih.contracts import AgentContract
|
|
6
|
+
|
|
7
|
+
class AgentRunner(Protocol):
|
|
8
|
+
def run(self, contract: AgentContract, input_data: dict) -> dict: ...
|
|
9
|
+
|
|
10
|
+
class StubRunner:
|
|
11
|
+
"""Test double: returns canned responses keyed by role."""
|
|
12
|
+
def __init__(self, responses: dict):
|
|
13
|
+
self.responses = responses
|
|
14
|
+
self.calls: list[dict] = []
|
|
15
|
+
|
|
16
|
+
def run(self, contract: AgentContract, input_data: dict) -> dict:
|
|
17
|
+
self.calls.append({"role": contract.role, "input": input_data})
|
|
18
|
+
if contract.role not in self.responses:
|
|
19
|
+
raise KeyError(f"no stub response for role {contract.role}")
|
|
20
|
+
return self.responses[contract.role]
|
|
21
|
+
|
|
22
|
+
class ClaudeCliRunner:
|
|
23
|
+
"""Headless adapter: drives `claude -p --append-system-prompt`.
|
|
24
|
+
|
|
25
|
+
Flags precede the prompt; output is expected as JSON on stdout.
|
|
26
|
+
"""
|
|
27
|
+
def __init__(self, cwd: str, extra_args: list[str] | None = None):
|
|
28
|
+
self.cwd = cwd
|
|
29
|
+
self.extra_args = extra_args or []
|
|
30
|
+
|
|
31
|
+
def run(self, contract: AgentContract, input_data: dict) -> dict:
|
|
32
|
+
prompt = json.dumps(input_data)
|
|
33
|
+
cmd = ["claude", "-p", "--output-format", "json",
|
|
34
|
+
"--append-system-prompt", contract.role_prompt,
|
|
35
|
+
*self.extra_args, "--", prompt]
|
|
36
|
+
proc = subprocess.run(cmd, cwd=self.cwd, capture_output=True, text=True)
|
|
37
|
+
if proc.returncode != 0:
|
|
38
|
+
raise RuntimeError(f"claude failed for {contract.role}: {proc.stderr}")
|
|
39
|
+
try:
|
|
40
|
+
envelope = json.loads(proc.stdout)
|
|
41
|
+
except json.JSONDecodeError as e:
|
|
42
|
+
raise RuntimeError(f"{contract.role}: non-JSON stdout from claude -p: {proc.stdout[:500]!r}") from e
|
|
43
|
+
if envelope.get("is_error"):
|
|
44
|
+
raise RuntimeError(f"{contract.role}: claude reported error: {envelope.get('result')}")
|
|
45
|
+
result = envelope.get("result")
|
|
46
|
+
if isinstance(result, dict):
|
|
47
|
+
return result
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(result)
|
|
50
|
+
except (TypeError, json.JSONDecodeError) as e:
|
|
51
|
+
from cih.contracts import OutputValidationError
|
|
52
|
+
raise OutputValidationError(f"{contract.role}: result was not JSON: {result!r}") from e
|
|
53
|
+
|
|
54
|
+
def invoke(runner: AgentRunner, contract: AgentContract, input_data: dict) -> dict:
|
|
55
|
+
output = runner.run(contract, input_data)
|
|
56
|
+
contract.validate_output(output)
|
|
57
|
+
return output
|
cih/attempts.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from dataclasses import dataclass, asdict
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
class AttemptKind(str, Enum):
|
|
6
|
+
PLAN = "plan_retry"
|
|
7
|
+
EXECUTION = "execution_retry"
|
|
8
|
+
INTEGRATION = "integration_retry"
|
|
9
|
+
FINAL_REJECT = "final_reject"
|
|
10
|
+
|
|
11
|
+
class AttemptCapExceeded(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Attempt:
|
|
16
|
+
attempt_id: str
|
|
17
|
+
kind: str
|
|
18
|
+
base_sha: str
|
|
19
|
+
branch: str
|
|
20
|
+
worktree_path: str
|
|
21
|
+
feedback_input: str
|
|
22
|
+
parent_attempt_id: Optional[str] = None
|
|
23
|
+
is_current: bool = True
|
|
24
|
+
|
|
25
|
+
class AttemptLog:
|
|
26
|
+
def __init__(self, team_id: str, cap: int):
|
|
27
|
+
self.team_id = team_id
|
|
28
|
+
self.cap = cap
|
|
29
|
+
self._attempts: list[Attempt] = []
|
|
30
|
+
|
|
31
|
+
def start(self, kind: AttemptKind, base_sha: str, branch: str,
|
|
32
|
+
worktree_path: str, feedback: str,
|
|
33
|
+
parent: Optional[str] = None) -> Attempt:
|
|
34
|
+
if len(self._attempts) >= self.cap:
|
|
35
|
+
raise AttemptCapExceeded(
|
|
36
|
+
f"{self.team_id}: attempt cap {self.cap} reached")
|
|
37
|
+
for a in self._attempts:
|
|
38
|
+
a.is_current = False
|
|
39
|
+
att = Attempt(
|
|
40
|
+
attempt_id=f"attempt-{len(self._attempts)+1:02d}",
|
|
41
|
+
kind=kind.value if isinstance(kind, AttemptKind) else kind,
|
|
42
|
+
base_sha=base_sha, branch=branch, worktree_path=worktree_path,
|
|
43
|
+
feedback_input=feedback, parent_attempt_id=parent)
|
|
44
|
+
self._attempts.append(att)
|
|
45
|
+
return att
|
|
46
|
+
|
|
47
|
+
def current(self) -> Optional[Attempt]:
|
|
48
|
+
return self._attempts[-1] if self._attempts else None
|
|
49
|
+
|
|
50
|
+
def all(self) -> list[Attempt]:
|
|
51
|
+
return list(self._attempts)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return {"team_id": self.team_id, "cap": self.cap,
|
|
55
|
+
"attempts": [asdict(a) for a in self._attempts]}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, d: dict) -> "AttemptLog":
|
|
59
|
+
log = cls(team_id=d["team_id"], cap=d["cap"])
|
|
60
|
+
log._attempts = [Attempt(**a) for a in d["attempts"]]
|
|
61
|
+
return log
|
cih/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass, field, asdict
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
class ConfigError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
_MODES = {"fixed-N", "until-converged"}
|
|
10
|
+
|
|
11
|
+
DEPTH_BUDGET = {"low": 3, "medium": 6, "high": 10}
|
|
12
|
+
DEFAULT_DEPTH = "medium"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def depth_budget(name: Optional[str] = None) -> int:
|
|
16
|
+
"""Map a --depth name to its question budget (upper bound). None → default."""
|
|
17
|
+
if name is None:
|
|
18
|
+
name = DEFAULT_DEPTH
|
|
19
|
+
if name not in DEPTH_BUDGET:
|
|
20
|
+
raise ConfigError(
|
|
21
|
+
f"depth must be one of {sorted(DEPTH_BUDGET, key=DEPTH_BUDGET.__getitem__)} (got {name!r})"
|
|
22
|
+
)
|
|
23
|
+
return DEPTH_BUDGET[name]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RunConfig:
|
|
28
|
+
mode: str
|
|
29
|
+
target_repo: str
|
|
30
|
+
state_dir: str
|
|
31
|
+
iterations: Optional[int] = None
|
|
32
|
+
max_iterations: int = 25
|
|
33
|
+
budget_cap: Optional[int] = None
|
|
34
|
+
focus_areas: list[str] = field(default_factory=list)
|
|
35
|
+
value_threshold: float = 0.5
|
|
36
|
+
convergence_dry_streak: int = 2
|
|
37
|
+
plan_review_retries: int = 2
|
|
38
|
+
exec_review_retries: int = 2
|
|
39
|
+
max_teams_per_iteration: int = 4
|
|
40
|
+
integration_retries: int = 2
|
|
41
|
+
per_team_attempt_cap: int = 4
|
|
42
|
+
cooldown_iterations: int = 2
|
|
43
|
+
opportunity_max_attempts: int = 3
|
|
44
|
+
tdd_adapter: str = "pytest"
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _validate_paths(target_repo: str, state_dir: str) -> None:
|
|
48
|
+
for label, p in (("target_repo", target_repo), ("state_dir", state_dir)):
|
|
49
|
+
if not os.path.isabs(p):
|
|
50
|
+
raise ConfigError(f"{label} must be an absolute path: {p}")
|
|
51
|
+
t = Path(target_repo).resolve()
|
|
52
|
+
s = Path(state_dir).resolve()
|
|
53
|
+
if t == s:
|
|
54
|
+
raise ConfigError("target_repo and state_dir must be distinct")
|
|
55
|
+
if t in s.parents or s in t.parents:
|
|
56
|
+
raise ConfigError("state_dir must not be nested inside target_repo (or vice versa)")
|
|
57
|
+
for label, p in (("target_repo", t), ("state_dir", s)):
|
|
58
|
+
if not p.is_dir():
|
|
59
|
+
raise ConfigError(f"{label} must be an existing directory: {p}")
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def create(cls, **kwargs) -> "RunConfig":
|
|
63
|
+
mode = kwargs.get("mode")
|
|
64
|
+
if mode not in _MODES:
|
|
65
|
+
raise ConfigError(f"mode must be one of {_MODES}")
|
|
66
|
+
iterations = kwargs.get("iterations")
|
|
67
|
+
if mode == "fixed-N":
|
|
68
|
+
if not isinstance(iterations, int) or iterations <= 0:
|
|
69
|
+
raise ConfigError("fixed-N mode requires iterations to be a positive int")
|
|
70
|
+
elif mode == "until-converged":
|
|
71
|
+
if iterations is not None:
|
|
72
|
+
raise ConfigError("until-converged mode must not set iterations")
|
|
73
|
+
cls._validate_paths(kwargs["target_repo"], kwargs["state_dir"])
|
|
74
|
+
return cls(**kwargs)
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict:
|
|
77
|
+
return asdict(self)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, d: dict) -> "RunConfig":
|
|
81
|
+
return cls.create(**d)
|
cih/contracts.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# cih/contracts.py
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from jsonschema import validate, ValidationError
|
|
6
|
+
|
|
7
|
+
class OutputValidationError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AgentContract:
|
|
12
|
+
role: str
|
|
13
|
+
agent_version: str
|
|
14
|
+
role_prompt: str
|
|
15
|
+
input_schema: dict
|
|
16
|
+
output_schema: dict
|
|
17
|
+
allowed_tools: list = field(default_factory=list)
|
|
18
|
+
runtime_adapter_settings: dict = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
def validate_output(self, output: dict) -> None:
|
|
21
|
+
try:
|
|
22
|
+
validate(instance=output, schema=self.output_schema)
|
|
23
|
+
except ValidationError as e:
|
|
24
|
+
raise OutputValidationError(f"{self.role} output invalid: {e.message}") from e
|
|
25
|
+
|
|
26
|
+
def prompt_hash(self) -> str:
|
|
27
|
+
blob = json.dumps({"prompt": self.role_prompt, "in": self.input_schema,
|
|
28
|
+
"out": self.output_schema, "v": self.agent_version,
|
|
29
|
+
"tools": self.allowed_tools,
|
|
30
|
+
"adapter": self.runtime_adapter_settings},
|
|
31
|
+
sort_keys=True)
|
|
32
|
+
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
cih/integration.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# cih/integration.py
|
|
2
|
+
"""Real git-backed integration layer for the orchestrator.
|
|
3
|
+
|
|
4
|
+
`build_integration` wires up a `(team_runner, integrate_fn)` pair that share an
|
|
5
|
+
internal `WorktreeManager`, a mutable integration `head`, and a per-iteration
|
|
6
|
+
`pending` registry. team_runner runs each team in its own iteration-scoped
|
|
7
|
+
worktree (branched off the CURRENT integration head), keeping it for passed
|
|
8
|
+
teams and removing it for failed/crashed ones, and persists per-team artifacts.
|
|
9
|
+
|
|
10
|
+
integrate_fn MERGES each passing team's branch into a single, advancing
|
|
11
|
+
integration worktree/branch (`cih/<run_id>/integration`), re-runs the suite +
|
|
12
|
+
execution-reviewer there, and threads the rolling tip via the merge queue.
|
|
13
|
+
Using merge (not rebase) preserves the executor commit SHAs so `reconcile` can
|
|
14
|
+
still resolve them, and lets iteration N+1 build on iteration N's merged result.
|
|
15
|
+
"""
|
|
16
|
+
import functools
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from cih import merge_queue
|
|
21
|
+
from cih.agents import invoke
|
|
22
|
+
from cih.safety import GitError, run_git
|
|
23
|
+
from cih.state import StateHeader, write_state
|
|
24
|
+
from cih.tdd_verifier import verify_tdd
|
|
25
|
+
from cih.team import TeamResult, run_team
|
|
26
|
+
from cih.worktree import WorktreeManager
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_integration(*, contracts, runner, verifier=None, repo, worktrees_root, run_id,
|
|
30
|
+
base_sha, state_dir, plan_review_retries, exec_review_retries,
|
|
31
|
+
attempt_cap, integration_retries, tdd_adapter="pytest", log=None):
|
|
32
|
+
mgr = WorktreeManager(repo, worktrees_root, run_id, log)
|
|
33
|
+
repo = Path(repo)
|
|
34
|
+
worktrees_root = Path(worktrees_root)
|
|
35
|
+
state_dir = Path(state_dir)
|
|
36
|
+
pending: dict[str, dict] = {}
|
|
37
|
+
# Run-scoped (NOT cleared per iteration): every passed-team worktree we keep.
|
|
38
|
+
# teardown() removes these dirs at run end while preserving their branches.
|
|
39
|
+
kept: list = []
|
|
40
|
+
|
|
41
|
+
# Mutable integration state, advances across iterations so improvements compound.
|
|
42
|
+
int_branch = f"cih/{run_id}/integration"
|
|
43
|
+
state = {"head": base_sha, "int_wt": None}
|
|
44
|
+
|
|
45
|
+
def _ensure_int_wt():
|
|
46
|
+
if state["int_wt"] is None:
|
|
47
|
+
int_wt = worktrees_root / run_id / "integration"
|
|
48
|
+
int_wt.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
run_git(["worktree", "add", "-b", int_branch, str(int_wt), state["head"]],
|
|
50
|
+
cwd=repo, log=log)
|
|
51
|
+
state["int_wt"] = int_wt
|
|
52
|
+
return state["int_wt"]
|
|
53
|
+
|
|
54
|
+
def _persist(iteration, team_id, result, wt=None):
|
|
55
|
+
iter_id = f"iter-{iteration:03d}"
|
|
56
|
+
teamdir = state_dir / "iterations" / iter_id / "teams" / team_id
|
|
57
|
+
status = "passed" if result.passed else "failed"
|
|
58
|
+
header = StateHeader(run_id, iter_id, team_id, None, status, "team")
|
|
59
|
+
|
|
60
|
+
body = {"commits": result.commits}
|
|
61
|
+
if wt is not None:
|
|
62
|
+
body["branch"] = wt.branch
|
|
63
|
+
try:
|
|
64
|
+
body["head_sha"] = mgr.head_sha(wt)
|
|
65
|
+
except GitError:
|
|
66
|
+
body["head_sha"] = None
|
|
67
|
+
write_state(teamdir / "plan.json", header, result.plan)
|
|
68
|
+
write_state(teamdir / "execution.json", header, body)
|
|
69
|
+
write_state(teamdir / "exec_review.json", header,
|
|
70
|
+
{"passed": result.passed, "reason": result.reason})
|
|
71
|
+
write_state(teamdir / "attempts.json", header, {"attempts": result.attempts})
|
|
72
|
+
|
|
73
|
+
def team_runner(charters, ctx):
|
|
74
|
+
iteration = ctx["iteration"]
|
|
75
|
+
iter_id = f"iter-{iteration:03d}"
|
|
76
|
+
# Reset pending so integrate_fn only ever processes THIS iteration's teams.
|
|
77
|
+
pending.clear()
|
|
78
|
+
results = []
|
|
79
|
+
for charter in charters:
|
|
80
|
+
team_id = charter["id"]
|
|
81
|
+
# Iteration-scoped worktree/branch: cih/<run_id>/iter-NNN/<team_id>.
|
|
82
|
+
# Branch off the CURRENT integration head so teams build on prior merges.
|
|
83
|
+
wt = mgr.create(f"{iter_id}/{team_id}", state["head"])
|
|
84
|
+
# When no explicit verifier is injected (production), bind a real
|
|
85
|
+
# mechanical TDD verifier to THIS team's worktree path.
|
|
86
|
+
team_verifier = verifier
|
|
87
|
+
if team_verifier is None:
|
|
88
|
+
team_verifier = functools.partial(
|
|
89
|
+
verify_tdd, repo=wt.path, adapter=tdd_adapter)
|
|
90
|
+
try:
|
|
91
|
+
result = run_team(
|
|
92
|
+
charter=charter, contracts=contracts, runner=runner,
|
|
93
|
+
verifier=team_verifier, plan_review_retries=plan_review_retries,
|
|
94
|
+
exec_review_retries=exec_review_retries, attempt_cap=attempt_cap,
|
|
95
|
+
base_sha=state["head"], branch=wt.branch, worktree_path=wt.path)
|
|
96
|
+
except Exception as e: # don't leak the worktree on an unexpected crash
|
|
97
|
+
mgr.remove(wt)
|
|
98
|
+
result = TeamResult(team_id, False, f"team crashed: {e}")
|
|
99
|
+
_persist(iteration, team_id, result)
|
|
100
|
+
results.append(result)
|
|
101
|
+
continue
|
|
102
|
+
_persist(iteration, team_id, result, wt=wt)
|
|
103
|
+
if result.passed:
|
|
104
|
+
pending[team_id] = {"worktree": wt, "charter": charter,
|
|
105
|
+
"result": result}
|
|
106
|
+
kept.append(wt)
|
|
107
|
+
else:
|
|
108
|
+
mgr.remove(wt)
|
|
109
|
+
results.append(result)
|
|
110
|
+
return results
|
|
111
|
+
|
|
112
|
+
def integrate_fn(results, ctx):
|
|
113
|
+
teams = [(tid, pending[tid]["charter"]) for tid in pending
|
|
114
|
+
if pending[tid]["result"].passed]
|
|
115
|
+
if not teams:
|
|
116
|
+
return merge_queue.MergeOutcome(final_base_sha=state["head"])
|
|
117
|
+
|
|
118
|
+
int_wt = _ensure_int_wt()
|
|
119
|
+
base = state["head"]
|
|
120
|
+
|
|
121
|
+
def reverify(team_id, current_base):
|
|
122
|
+
# Operate on the single advancing integration worktree. We merge into
|
|
123
|
+
# the integration branch (which already advanced past prior merges);
|
|
124
|
+
# current_base is bookkeeping only — the actual merge target is the
|
|
125
|
+
# integration HEAD. On any rejection we reset back to `base`.
|
|
126
|
+
team_branch = pending[team_id]["worktree"].branch
|
|
127
|
+
try:
|
|
128
|
+
run_git(["merge", "--no-ff", "--no-edit", team_branch],
|
|
129
|
+
cwd=int_wt, log=log)
|
|
130
|
+
except GitError: # merge conflict
|
|
131
|
+
try:
|
|
132
|
+
run_git(["merge", "--abort"], cwd=int_wt, log=log)
|
|
133
|
+
except GitError:
|
|
134
|
+
pass
|
|
135
|
+
return (False, None)
|
|
136
|
+
|
|
137
|
+
def _reject():
|
|
138
|
+
run_git(["reset", "--hard", base], cwd=int_wt, log=log)
|
|
139
|
+
return (False, None)
|
|
140
|
+
|
|
141
|
+
# Full suite in the integration worktree (exit 5 == no tests, ok).
|
|
142
|
+
proc = subprocess.run(["python", "-m", "pytest", "-q"],
|
|
143
|
+
cwd=int_wt, capture_output=True, text=True)
|
|
144
|
+
if proc.returncode not in (0, 5):
|
|
145
|
+
return _reject()
|
|
146
|
+
review = invoke(runner, contracts["execution-reviewer"],
|
|
147
|
+
{"team_id": team_id, "merged": True})
|
|
148
|
+
if not review["approved"]:
|
|
149
|
+
return _reject()
|
|
150
|
+
return (True, run_git(["rev-parse", "HEAD"], cwd=int_wt, log=log).strip())
|
|
151
|
+
|
|
152
|
+
# integration_retries=0: an in-call retry of a deterministic merge is a
|
|
153
|
+
# no-op. Cross-iteration recovery happens via the orchestrator ledger
|
|
154
|
+
# cooldown -> reopen, which re-runs a FRESH executor against the new base
|
|
155
|
+
# next iteration — that IS "re-execute against a new base" at iteration
|
|
156
|
+
# granularity.
|
|
157
|
+
outcome = merge_queue.integrate(
|
|
158
|
+
teams, base_sha=base, reverify=reverify, integration_retries=0)
|
|
159
|
+
|
|
160
|
+
if outcome.merged:
|
|
161
|
+
state["head"] = outcome.final_base_sha
|
|
162
|
+
# Belt-and-suspenders: the integration worktree branch already points
|
|
163
|
+
# here; keep the stable ref in sync for reconcile/resume.
|
|
164
|
+
run_git(["update-ref", f"refs/heads/{int_branch}", state["head"]],
|
|
165
|
+
cwd=repo, log=log)
|
|
166
|
+
return outcome
|
|
167
|
+
|
|
168
|
+
def teardown():
|
|
169
|
+
# Best-effort: prune the worktree DIRECTORIES (integration + every kept
|
|
170
|
+
# team worktree) at run end while PRESERVING all branch refs, so
|
|
171
|
+
# reconcile/resume still find them. Idempotent — git's `worktree remove`
|
|
172
|
+
# on an already-removed worktree raises GitError, which we swallow.
|
|
173
|
+
int_wt = state.get("int_wt")
|
|
174
|
+
if int_wt is not None:
|
|
175
|
+
try:
|
|
176
|
+
run_git(["worktree", "remove", "--force", str(int_wt)],
|
|
177
|
+
cwd=repo, log=log)
|
|
178
|
+
except GitError:
|
|
179
|
+
pass
|
|
180
|
+
for wt in kept:
|
|
181
|
+
try:
|
|
182
|
+
run_git(["worktree", "remove", "--force", wt.path],
|
|
183
|
+
cwd=repo, log=log)
|
|
184
|
+
except GitError:
|
|
185
|
+
pass
|
|
186
|
+
try:
|
|
187
|
+
run_git(["worktree", "prune"], cwd=repo, log=log)
|
|
188
|
+
except GitError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
integrate_fn.teardown = teardown
|
|
192
|
+
return team_runner, integrate_fn
|
cih/ledger.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# cih/ledger.py
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field, asdict
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from cih.transitions import Status, assert_transition
|
|
8
|
+
|
|
9
|
+
def fingerprint(title: str, scope: str) -> str:
|
|
10
|
+
norm = re.sub(r"\s+", " ", title.strip().lower())
|
|
11
|
+
return hashlib.sha256(f"{norm}|{scope}".encode()).hexdigest()[:16]
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Opportunity:
|
|
15
|
+
fp: str
|
|
16
|
+
title: str
|
|
17
|
+
scope: str
|
|
18
|
+
value: float
|
|
19
|
+
confidence: float
|
|
20
|
+
effort: float
|
|
21
|
+
risk: float
|
|
22
|
+
rationale: str
|
|
23
|
+
state: str = "open"
|
|
24
|
+
attempt_count: int = 0
|
|
25
|
+
cooldown_until: Optional[int] = None
|
|
26
|
+
|
|
27
|
+
class Ledger:
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self._items: dict[str, Opportunity] = {}
|
|
30
|
+
|
|
31
|
+
def upsert(self, opp: Opportunity) -> None:
|
|
32
|
+
existing = self._items.get(opp.fp)
|
|
33
|
+
if existing and existing.state in ("merged", "expired"):
|
|
34
|
+
return # terminal; ignore re-discovery
|
|
35
|
+
if existing:
|
|
36
|
+
opp.attempt_count = existing.attempt_count
|
|
37
|
+
opp.state = existing.state
|
|
38
|
+
opp.cooldown_until = existing.cooldown_until
|
|
39
|
+
self._items[opp.fp] = opp
|
|
40
|
+
|
|
41
|
+
def get(self, fp: str) -> Optional[Opportunity]:
|
|
42
|
+
return self._items.get(fp)
|
|
43
|
+
|
|
44
|
+
def _set_state(self, o, dst: str) -> None:
|
|
45
|
+
# A same-state write is an idempotent no-op (e.g. a still-cooling item
|
|
46
|
+
# re-entering cooldown on a subsequent failed retry); it is trivially
|
|
47
|
+
# monotonic, so it does not need a table edge.
|
|
48
|
+
if o.state == dst:
|
|
49
|
+
return
|
|
50
|
+
assert_transition(Status(o.state), Status(dst))
|
|
51
|
+
o.state = dst
|
|
52
|
+
|
|
53
|
+
def _refresh_cooldowns(self, current_iteration: Optional[int]) -> None:
|
|
54
|
+
if current_iteration is None:
|
|
55
|
+
return
|
|
56
|
+
for o in self._items.values():
|
|
57
|
+
if o.state == "cooldown" and o.cooldown_until is not None \
|
|
58
|
+
and current_iteration >= o.cooldown_until:
|
|
59
|
+
self._set_state(o, "open")
|
|
60
|
+
o.cooldown_until = None
|
|
61
|
+
|
|
62
|
+
def select_open(self, value_threshold: float,
|
|
63
|
+
current_iteration: Optional[int] = None) -> list[Opportunity]:
|
|
64
|
+
self._refresh_cooldowns(current_iteration)
|
|
65
|
+
return [o for o in self._items.values()
|
|
66
|
+
if o.state == "open" and o.value >= value_threshold]
|
|
67
|
+
|
|
68
|
+
def is_dry(self, value_threshold: float, current_iteration: int) -> bool:
|
|
69
|
+
# Spec §5: dry = no open opportunity above threshold AND no retryable
|
|
70
|
+
# opportunity OUTSIDE cooldown. select_open() refreshes cooldowns first,
|
|
71
|
+
# so items whose cooldown has elapsed are already reopened and counted;
|
|
72
|
+
# items still cooling are correctly excluded.
|
|
73
|
+
return not self.select_open(value_threshold, current_iteration)
|
|
74
|
+
|
|
75
|
+
def mark_merged(self, fp: str) -> None:
|
|
76
|
+
self._set_state(self._items[fp], "merged")
|
|
77
|
+
|
|
78
|
+
def mark_cooldown(self, fp: str, current_iteration: int,
|
|
79
|
+
cooldown_iterations: int) -> None:
|
|
80
|
+
o = self._items[fp]
|
|
81
|
+
self._set_state(o, "cooldown")
|
|
82
|
+
o.cooldown_until = current_iteration + cooldown_iterations
|
|
83
|
+
|
|
84
|
+
def record_attempt_failure(self, fp: str, current_iteration: int,
|
|
85
|
+
cooldown_iterations: int, max_attempts: int) -> None:
|
|
86
|
+
o = self._items[fp]
|
|
87
|
+
o.attempt_count += 1
|
|
88
|
+
if o.attempt_count >= max_attempts:
|
|
89
|
+
self._set_state(o, "expired")
|
|
90
|
+
o.cooldown_until = None
|
|
91
|
+
else:
|
|
92
|
+
self.mark_cooldown(fp, current_iteration, cooldown_iterations)
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict:
|
|
95
|
+
return {fp: asdict(o) for fp, o in self._items.items()}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, d: dict) -> "Ledger":
|
|
99
|
+
led = cls()
|
|
100
|
+
for fp, raw in d.items():
|
|
101
|
+
led._items[fp] = Opportunity(**raw)
|
|
102
|
+
return led
|
cih/merge_queue.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# cih/merge_queue.py
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class MergeOutcome:
|
|
7
|
+
merged: list = field(default_factory=list)
|
|
8
|
+
rejected: list = field(default_factory=list)
|
|
9
|
+
final_base_sha: str = ""
|
|
10
|
+
|
|
11
|
+
def order_by_overlap(charters: list[dict]) -> list[dict]:
|
|
12
|
+
# cheap precheck: fewer intended files -> integrate earlier (less collision surface)
|
|
13
|
+
return sorted(charters,
|
|
14
|
+
key=lambda c: len(c.get("impact_manifest", {}).get("intended_files", [])))
|
|
15
|
+
|
|
16
|
+
def integrate(teams: list[tuple], base_sha: str,
|
|
17
|
+
reverify: Callable[[str, str], tuple[bool, Optional[str]]],
|
|
18
|
+
integration_retries: int) -> MergeOutcome:
|
|
19
|
+
"""teams: list of (team_id, charter). reverify(team_id, base)->(ok, new_base_sha)
|
|
20
|
+
re-runs the full suite + execution-reviewer on the rebased branch and returns
|
|
21
|
+
the real new base SHA on success."""
|
|
22
|
+
ordered_ids = [c["id"] for c in order_by_overlap([c for _, c in teams])]
|
|
23
|
+
by_id = dict(teams)
|
|
24
|
+
outcome = MergeOutcome(final_base_sha=base_sha)
|
|
25
|
+
for team_id in ordered_ids:
|
|
26
|
+
new_base_sha = None
|
|
27
|
+
for _ in range(integration_retries + 1):
|
|
28
|
+
ok, candidate = reverify(team_id, outcome.final_base_sha)
|
|
29
|
+
if ok:
|
|
30
|
+
new_base_sha = candidate
|
|
31
|
+
break
|
|
32
|
+
if new_base_sha is not None:
|
|
33
|
+
outcome.merged.append(team_id)
|
|
34
|
+
outcome.final_base_sha = new_base_sha
|
|
35
|
+
else:
|
|
36
|
+
outcome.rejected.append(team_id)
|
|
37
|
+
return outcome
|