Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions doc/architecture/SPEC/adapters/base.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# adapters/base.py —— 规范

<!-- verified-against: 2026-08-25 -->
<!-- verified-against: 2026-09-26 -->

`LOC ~423 · 边缘(仓库知识) · refactor-status: ok`

Expand Down Expand Up @@ -28,6 +28,9 @@
获胜且**绝不被修改**;仍未解析的变量 → 返回 ""(fail-closed,能力缺口路径)。
- **未知 adapter 名抛 `AdapterNotFound`**(子类);把"不存在"当兼容路径的调用方
只能捕获这个子类 —— 已知 adapter 的畸形 manifest 必须仍是硬失败。
- `load_adapter` 将 manifest 读取、编码与 YAML 解析错误归一为 `AdapterError`,
并要求 manifest 为映射、`name` 为文本、`repo` 为映射;调用方不能把这些错误
当作 adapter 缺失而忽略。
- `capabilities` 由 manifest 推导(repo.path/language.*/ci.provider/upstream.*/
modules)+ 显式的 `capabilities:` —— 与 playbook 的 `requires:` 匹配。
- `high_risk_modules` = 标了 `risk: high` 的模块(喂给 patch-review,**A5**)。
Expand All @@ -52,5 +55,6 @@
## 重构备注
两个关注点在这里共存得很干净:`RepoAdapter`(访问器)+ 注册表 + 引导。
如果引导继续长大(更丰富的指纹),可考虑 `adapters/bootstrap.py`。
**把 `capabilities` 推导保持为 planner 信任的唯一来源** —— 不要在 `cli.resolve` 里
复制一份能力逻辑(它目前只为 REPO_PATHS **补上** repo.path,这是可以接受的)。
**把 `capabilities` 推导保持为 planner 信任的唯一来源** ——
`app.repository_context` 只为实际 checkout **补上** `repo.path`,
并将同一 adapter policy 交给执行层。
7 changes: 7 additions & 0 deletions doc/architecture/SPEC/app/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ executes or resumes a playbook, and reserves/executes durable runs. The CLI
imports it through `cli.copilot` for
compatibility; the application never imports CLI or MCP transports.

Repository checkout, capabilities, and adapter policy come from
`app.repository_context.RepositoryContextResolver`. Planning and execution
use the same context contract. A missing adapter retains the legacy
unknown-capabilities path when a checkout exists; a broken known adapter
blocks the run. Execution rechecks the context after planning and refuses a
changed checkout or policy before starting the executor.

`Copilot` keeps reservation and run-path methods as compatibility delegates
to `app.reservation.RunReservation`. It owns workflow resolution and child
execution; it does not own idempotency indexing or run ID validation.
Expand Down
20 changes: 20 additions & 0 deletions doc/architecture/SPEC/app/repository_context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# app/repository_context.py — repository context boundary

<!-- verified-against: 2026-09-26 -->

`RepositoryContextResolver.for_spec` produces the checkout, planner
capabilities, protected branches, and high-risk modules for one `TaskSpec`.
The frozen `spec.repo_path` wins over ambient `REPO_PATHS` and the adapter
path. The resulting immutable `RepositoryContext` is passed from planning to
execution and re-resolved before execution starts; a change blocks the run.

Only genuine adapter absence permits compatibility behavior. With no adapter,
capabilities are unknown (`None`) when a checkout exists and empty otherwise.
A known adapter with a missing, unreadable, malformed, or mismatched manifest
raises `AdapterError`. Malformed checkout or policy fields also raise
`AdapterError`; no execution path silently substitutes the default policy.

The resolver owns no planner, executor, persistence, or transport behavior.
`Copilot` retains its older path and adapter helpers as delegates. Tests in
`test/test_repository_context.py` cover checkout precedence, policy
propagation, malformed manifests, and context drift.
9 changes: 8 additions & 1 deletion src/infermatrix_copilot/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,17 @@ def load_adapter(adapter_dir: str | Path) -> RepoAdapter:
manifest_path = root / "manifest.yaml"
if not manifest_path.exists():
raise AdapterError(f"no manifest.yaml in {root}")
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise AdapterError(f"cannot load adapter manifest in {root}: {exc}") from exc
if not isinstance(manifest, dict):
raise AdapterError(f"adapter {root.name}: manifest must be a mapping")
for section in REQUIRED_SECTIONS:
if section not in manifest:
raise AdapterError(f"adapter {root.name}: missing required section '{section}'")
if not isinstance(manifest["name"], str) or not isinstance(manifest["repo"], dict):
raise AdapterError(f"adapter {root.name}: name must be text and repo a mapping")
return RepoAdapter(name=manifest["name"], root=root, manifest=manifest)


Expand Down
105 changes: 39 additions & 66 deletions src/infermatrix_copilot/app/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from typing import NamedTuple

from .. import run_status as rs
from ..adapters.base import AdapterError
from ..config import Settings, TierNotConfiguredError
from ..engine.executor import Executor
from ..engine.lifecycle import RunLock, RunLockHeld, run_guarded
Expand All @@ -34,6 +35,7 @@
from ..ui import style
from ..metrics import format_metrics_line
from .reservation import RunReservation
from .repository_context import RepositoryContext, RepositoryContextResolver


class GateOutcome(NamedTuple):
Expand Down Expand Up @@ -70,6 +72,7 @@ def __init__(self, settings: Settings | None = None):
and is filled by the first execution."""
self.settings = settings or Settings()
self.reservations = RunReservation(self.settings)
self.repository_context = RepositoryContextResolver(self.settings)
# provider-registry seam: a real llm.LLM under backend "api"
# (byte-identical), a HarnessLLM adapter under a harness backend
from ..providers import llm_for
Expand All @@ -89,41 +92,15 @@ def resolve(self, spec: TaskSpec) -> Resolution:
capability set so the planner only reuses playbooks the target supports.
Capabilities come from the repo's adapter (if any), plus `repo.path` when
a path is resolvable even without a adapter (REPO_PATHS works adapter-less)."""
# Only genuine adapter ABSENCE takes the unknown-capabilities
# compatibility path — a malformed/unreadable KNOWN adapter must fail
# closed here, not fail open into capabilities=None and recall a
# playbook whose requirements were never established.
from ..adapters.base import AdapterError, AdapterNotFound, AdapterRegistry
adapter_name = spec.repo.replace("-", "_")
try:
adapter = AdapterRegistry(self.settings.adapters_dir).resolve(
name=adapter_name)
except AdapterNotFound:
# the registry resolves by DECLARED manifest name and skips
# manifest-less directories — so "not found" alone does not
# prove absence. Only a genuinely absent directory is the
# v1-compatible path; an existing directory that failed to
# load/resolve (deleted manifest, wrong name:) fails closed.
if (Path(self.settings.adapters_dir) / adapter_name).exists():
raise AdapterError(
f"adapter directory {adapter_name!r} exists but did not "
"load/resolve (missing manifest.yaml or mismatched "
"name:) — refusing to plan with unknown capabilities")
adapter = None # absence: v1-compatible
except FileNotFoundError:
adapter = None # no adapters directory at all
if adapter is None:
# No adapter means capabilities are UNKNOWN, not zero — the
# store's requires-filter (now covering exact-repo playbooks too)
# skips on None, keeping adapter-less setups v1-compatible
# instead of silently dropping every playbook with a `requires:`.
if self._repo_path_for(spec):
return self.planner.resolve(spec, capabilities=None)
return self.planner.resolve(spec, capabilities=set())
capabilities = set(adapter.capabilities)
if self._repo_path_for(spec): # REPO_PATHS works adapter-less
capabilities.add("repo.path")
return self.planner.resolve(spec, capabilities=capabilities)
return self._resolve_with_context(spec)[0]

def _resolve_with_context(
self, spec: TaskSpec,
) -> tuple[Resolution, RepositoryContext]:
context = self.repository_context.for_spec(spec)
return self.planner.resolve(
spec, capabilities=context.capabilities,
), context

def _plan_review_gate(self, resolution: Resolution, spec: TaskSpec,
assume_yes: bool) -> bool:
Expand Down Expand Up @@ -162,8 +139,8 @@ def run_task(self, spec: TaskSpec, *, assume_yes: bool = False,
`plan_only` prints the resolved plan and returns 0 without running;
`assume_yes` skips the interactive confirm."""
try:
resolution = self.resolve(spec)
except PlanningError as exc:
resolution, repo_context = self._resolve_with_context(spec)
except (PlanningError, AdapterError) as exc:
print(style("✋ cannot plan: ", "red", "bold") + str(exc))
return BLOCKED_EXIT
# tier preflight (plan v2): backend availability is DEPLOYMENT state,
Expand Down Expand Up @@ -215,7 +192,8 @@ def run_task(self, spec: TaskSpec, *, assume_yes: bool = False,
"invocation_id": os.environ.get("IMX_INVOCATION_ID", ""),
}, indent=2))
return self._execute(resolution.playbook, spec, run_dir,
resolution_mode=resolution.mode, tier=resolution.tier)
resolution_mode=resolution.mode, tier=resolution.tier,
planned_context=repo_context)
Comment on lines 194 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep drift-blocked runs out of resume

When REPO_PATHS or the adapter manifest changes while confirmation is pending, the context comparison occurs only after run_task has created the run directory and written task.json. resume_last() treats that artifact as resumable and calls _execute without planned_context, so the next resume accepts the changed context and executes the run that this drift check just refused, potentially against a different checkout or policy. Recheck before persisting the run, or persist and enforce the planned context during resume.

Useful? React with 👍 / 👎.


def run_playbook(self, name: str, *, params: dict | None = None,
report_only: bool = False, assume_yes: bool = False,
Expand Down Expand Up @@ -287,7 +265,8 @@ def run_playbook(self, name: str, *, params: dict | None = None,

def _execute(self, playbook, spec: TaskSpec, run_dir: Path, *,
resolution_mode: str = "resume", tier: str = "?",
resuming: bool = False, held_lock: RunLock | None = None) -> int:
resuming: bool = False, held_lock: RunLock | None = None,
planned_context: RepositoryContext | None = None) -> int:
"""Run a resolved `playbook` to completion in `run_dir`: init tracing +
notifier, seed the shared state (repo path, push policy, protected
branches / high-risk modules from the adapter when present), drive the
Expand All @@ -298,6 +277,16 @@ def _execute(self, playbook, spec: TaskSpec, run_dir: Path, *,
the code but owns the terminal `run_status.json` write."""
self.last_run_dir = run_dir
self.last_blocked_reason = ""
try:
repo_context = self.repository_context.for_spec(spec)
except AdapterError as exc:
self.last_blocked_reason = f"repository context invalid: {exc}"
print(style("✋ ", "red", "bold") + self.last_blocked_reason)
return BLOCKED_EXIT
if planned_context is not None and repo_context != planned_context:
self.last_blocked_reason = "repository context changed after planning"
print(style("✋ ", "red", "bold") + self.last_blocked_reason)
return BLOCKED_EXIT
lock = held_lock
if lock is None:
try:
Expand Down Expand Up @@ -349,17 +338,13 @@ def _execute(self, playbook, spec: TaskSpec, run_dir: Path, *,
playbook=playbook.name, tier=tier)
state: dict = {
"task_spec": spec.model_dump(),
"repo_path": self._repo_path_for(spec),
"repo_path": repo_context.repo_path,
"push_policy": PushPolicy(), # steps may replace with a derived policy
"protected_branches": self.settings.protected_branches,
"protected_branches": list(repo_context.protected_branches),
"resuming": resuming,
}
adapter = self._adapter_for(spec.repo)
if adapter is not None:
# repo knowledge from the adapter, not core settings (v2 P0 fix #5)
state["protected_branches"] = adapter.protected_branches
if adapter.high_risk_modules:
state["high_risk_modules"] = adapter.high_risk_modules
if repo_context.high_risk_modules:
state["high_risk_modules"] = list(repo_context.high_risk_modules)
executor = Executor(self.registry, self.settings, run_dir=run_dir,
trace=trace, llm=self.llm, notifier=notifier)
# run_guarded finalizes inside the event loop: playbooks that
Expand Down Expand Up @@ -509,8 +494,8 @@ def _execute_reserved_locked(self, run_dir: Path, lock: RunLock,
rs.mark(run_dir, rs.FAILED, note=f"policy/request rejected: {exc}")
return 1
try:
resolution = self.resolve(spec)
except PlanningError as exc:
resolution, repo_context = self._resolve_with_context(spec)
except (PlanningError, AdapterError) as exc:
rs.mark(run_dir, rs.BLOCKED, note=f"cannot plan: {exc}")
return BLOCKED_EXIT
try: # tier preflight is re-enforced in the MCP child (authoritative)
Expand All @@ -526,7 +511,7 @@ def _execute_reserved_locked(self, run_dir: Path, lock: RunLock,
try:
code = self._execute(resolution.playbook, spec, run_dir,
resolution_mode=resolution.mode, tier=resolution.tier,
held_lock=lock)
held_lock=lock, planned_context=repo_context)
except Exception as exc: # a crash still leaves a terminal record
rs.mark(run_dir, rs.FAILED, note=f"{type(exc).__name__}: {exc}")
raise
Expand All @@ -540,14 +525,8 @@ def _execute_reserved_locked(self, run_dir: Path, lock: RunLock,
return code

def _adapter_for(self, repo: str):
"""The repo's registered adapter, or None (never raises)."""
try:
from ..adapters.base import AdapterRegistry

return AdapterRegistry(self.settings.adapters_dir).resolve(
name=repo.replace("-", "_"))
except Exception:
return None
"""Compatibility lookup; a broken known adapter remains an error."""
return self.repository_context.adapter_for(repo)

def _repo_path_for(self, spec: TaskSpec) -> str:
"""The checkout THIS spec runs against: its frozen `repo_path` first,
Expand All @@ -560,21 +539,15 @@ def _repo_path_for(self, spec: TaskSpec) -> str:
resolve a playbook that requires it — while a perfectly valid checkout
sat in the spec. With both configured and differing, planning would
evaluate one checkout and execution would run against another."""
return spec.repo_path or self._resolve_repo_path(spec.repo)
return self.repository_context.repo_path_for(spec)

def _resolve_repo_path(self, repo: str) -> str:
"""REPO_PATHS first; fall back to the repo's adapter manifest (adapter zero
declares repo.path), so runs work even without a .env in reach.

Alias-only: callers that hold a TaskSpec want `_repo_path_for`, which
honors a frozen per-run path."""
p = self.settings.repo_path(repo)
if p:
return str(p)
adapter = self._adapter_for(repo)
if adapter and adapter.repo_path:
return adapter.repo_path
return ""
return self.repository_context.ambient_repo_path(repo)

# -- built-ins ---------------------------------------------------------------
def status(self) -> str:
Expand Down
Loading
Loading