Merge branch 'master' into fix/issue-790-slice-a-heartbeat-policy
This commit is contained in:
+312
-9
@@ -2025,6 +2025,7 @@ import review_quarantine # noqa: E402 # #695 contaminated formal-review quaran
|
||||
import mcp_daemon_guard # noqa: E402 # #695 native transport provenance
|
||||
import already_landed_reconcile # noqa: E402
|
||||
import author_mutation_worktree # noqa: E402
|
||||
import branch_publish # noqa: E402 # #812 AC20 unpublished-commit publication
|
||||
import root_checkout_guard # noqa: E402
|
||||
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
|
||||
import stable_branch_push_guard # noqa: E402
|
||||
@@ -9228,6 +9229,253 @@ def gitea_commit_files(
|
||||
}
|
||||
|
||||
|
||||
def _publication_block(reasons: list[str], **extra) -> dict:
|
||||
"""Uniform fail-closed shape for publication refusals (#812 AC20)."""
|
||||
payload = {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"published": False,
|
||||
"verified": False,
|
||||
"outcome": branch_publish.REFUSED,
|
||||
"reasons": reasons,
|
||||
# State every record this operation left alone, so a refusal can never
|
||||
# be misread as a lock mutation (#812 AC23).
|
||||
"issue_lock_record_mutated": False,
|
||||
"workflow_lease_touched": False,
|
||||
}
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_publish_unpublished_issue_branch(
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
expected_head: str,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
git_remote_name: str | None = None,
|
||||
expected_file_hashes: dict | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Publish an already-committed, unpublished issue branch (#812 AC20).
|
||||
|
||||
Creates the remote head for a branch whose work is *already* a local commit
|
||||
on a registered, clean worktree, so exact-owner lease renewal
|
||||
(``issue_lock_renewal``) has the published head its evidence model requires.
|
||||
This is the one step of the entry point B deadlock that no existing tool can
|
||||
perform: publication is otherwise lock-derived under #618, and the lock
|
||||
itself is withheld until a remote head exists.
|
||||
|
||||
Not a lock bypass. The branch's **durable issue-lock record must already
|
||||
name the caller as claimant** — ownership is read from the lock file, never
|
||||
asserted by the caller — so this can only publish work the caller already
|
||||
owns. It refuses a dirty or untracked-carrying worktree, an unregistered
|
||||
worktree, a changed local HEAD, a remote head that is not an ancestor of the
|
||||
commit, a competing open PR on another branch for the same issue, and any
|
||||
declared-hash mismatch. It renews, reclaims, and clears nothing: the durable
|
||||
issue-lock file and the control-plane workflow lease are both left untouched
|
||||
(#812 AC23), and the recorded owner pid's liveness is never consulted or
|
||||
asserted (#812 AC24).
|
||||
|
||||
Args:
|
||||
issue_number: The issue whose recorded claim authorizes publication.
|
||||
branch_name: Issue branch to publish, ``(fix|feat|docs|chore)/issue-N-…``.
|
||||
worktree_path: Registered worktree holding the commit.
|
||||
expected_head: Full 40-character SHA of the commit to publish. Required:
|
||||
publication names the exact commit, and a mismatch fails closed.
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
git_remote_name: Git remote to publish to; defaults to *remote*.
|
||||
expected_file_hashes: Optional ``{path: sha256}`` verified against the
|
||||
worktree before publication. Any mismatch or missing file refuses.
|
||||
dry_run: Report the decision and evidence, mutate nothing.
|
||||
|
||||
Returns:
|
||||
dict with 'success', 'performed', 'published', 'verified', 'outcome',
|
||||
'remote_head_sha', 'reasons', and 'evidence'.
|
||||
"""
|
||||
task = "publish_unpublished_branch"
|
||||
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||||
task
|
||||
)
|
||||
if not ok:
|
||||
return _publication_block(block_reasons)
|
||||
|
||||
blocked = _namespace_mutation_block(task, remote=remote)
|
||||
if blocked:
|
||||
return blocked
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission(task),
|
||||
remote=remote, host=host, org=org, repo=repo,
|
||||
org_explicit=org is not None,
|
||||
repo_explicit=repo is not None,
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
# #815: resolve the caller's worktree *before* preflight and forward it, so
|
||||
# every workspace-resolution layer behind verify_preflight_purity — including
|
||||
# the #618 branches-only guard — judges the registered issue worktree this
|
||||
# publication actually operates on. Resolving it afterwards let preflight
|
||||
# fall back to the MCP process root, so a daemon rooted at the stable control
|
||||
# checkout refused a valid explicit worktree before the assessor ever ran.
|
||||
# A caller supplying nothing usable forwards None and keeps the ordinary
|
||||
# fail-closed fallback.
|
||||
explicit_worktree = (worktree_path or "").strip()
|
||||
workspace = os.path.realpath(os.path.abspath(explicit_worktree or "."))
|
||||
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=workspace if explicit_worktree else None,
|
||||
task=task,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
git_remote = (git_remote_name or remote or "").strip()
|
||||
|
||||
existing_lock = issue_lock_store.load_issue_lock(
|
||||
remote=remote, org=o, repo=r, issue_number=int(issue_number)
|
||||
)
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||||
registered = author_mutation_worktree.path_in_git_worktree_list(
|
||||
workspace, PROJECT_ROOT
|
||||
)
|
||||
|
||||
remote_probe = branch_publish.read_remote_branch_head(
|
||||
workspace, git_remote, branch_name
|
||||
)
|
||||
ancestry = None
|
||||
probe_head = (remote_probe.get("remote_head_sha") or "").strip()
|
||||
if probe_head and probe_head.lower() != (expected_head or "").strip().lower():
|
||||
ancestry = branch_publish.read_is_ancestor(workspace, probe_head, expected_head)
|
||||
|
||||
# A PR on this very branch is this work's own PR, not a rival claim. Only an
|
||||
# open PR for the same issue on a *different* branch is a competing claim.
|
||||
competing: list = []
|
||||
for pull in _list_open_pulls(h, o, r, _auth(h)):
|
||||
ref = str((pull.get("head") or {}).get("ref") or "")
|
||||
if not ref or ref == branch_name:
|
||||
continue
|
||||
if issue_lock_adoption.branch_carries_issue_marker(ref, int(issue_number)):
|
||||
competing.append(pull.get("number"))
|
||||
|
||||
observed_hashes = None
|
||||
if expected_file_hashes:
|
||||
observed_hashes = branch_publish.hash_worktree_files(
|
||||
workspace, list(expected_file_hashes.keys())
|
||||
)
|
||||
|
||||
claimant = _work_lease_claimant(h)
|
||||
assessment = branch_publish.assess_unpublished_commit_publication(
|
||||
existing_lock,
|
||||
issue_number=int(issue_number),
|
||||
branch_name=branch_name,
|
||||
worktree_path=workspace,
|
||||
expected_head=expected_head,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=claimant.get("username"),
|
||||
profile=claimant.get("profile"),
|
||||
worktree_state=git_state,
|
||||
worktree_registered=registered,
|
||||
remote_probe=remote_probe,
|
||||
ancestry=ancestry,
|
||||
competing_open_prs=competing,
|
||||
expected_file_hashes=expected_file_hashes,
|
||||
observed_file_hashes=observed_hashes,
|
||||
)
|
||||
|
||||
if assessment["outcome"] == branch_publish.REFUSED:
|
||||
return _publication_block(
|
||||
assessment["reasons"], evidence=assessment["evidence"]
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"success": True,
|
||||
"performed": False,
|
||||
"published": False,
|
||||
"verified": False,
|
||||
"dry_run": True,
|
||||
"outcome": assessment["outcome"],
|
||||
"would_publish": assessment["publish_sanctioned"],
|
||||
"remote_head_sha": assessment["evidence"].get("remote_head_sha"),
|
||||
"reasons": [],
|
||||
"evidence": assessment["evidence"],
|
||||
"issue_lock_record_mutated": False,
|
||||
"workflow_lease_touched": False,
|
||||
}
|
||||
|
||||
performed = False
|
||||
if assessment["publish_sanctioned"]:
|
||||
with _audited(
|
||||
task, host=h, remote=remote, org=o, repo=r,
|
||||
target_branch=branch_name,
|
||||
request_metadata={
|
||||
"issue_number": int(issue_number),
|
||||
"expected_head": expected_head,
|
||||
"git_remote": git_remote,
|
||||
},
|
||||
):
|
||||
push = branch_publish.publish_commit_to_remote_branch(
|
||||
worktree_path=workspace,
|
||||
remote_name=git_remote,
|
||||
branch_name=branch_name,
|
||||
expected_head=expected_head,
|
||||
)
|
||||
if not push.get("success"):
|
||||
return _publication_block(
|
||||
push.get("reasons") or ["publication failed"],
|
||||
evidence=assessment["evidence"],
|
||||
stderr=push.get("stderr"),
|
||||
)
|
||||
performed = True
|
||||
|
||||
verification = branch_publish.verify_published_head(
|
||||
worktree_path=workspace,
|
||||
remote_name=git_remote,
|
||||
branch_name=branch_name,
|
||||
expected_head=expected_head,
|
||||
)
|
||||
if not verification.get("verified"):
|
||||
return _publication_block(
|
||||
verification.get("reasons") or ["read-after-write verification failed"],
|
||||
evidence=assessment["evidence"],
|
||||
performed=performed,
|
||||
published=performed,
|
||||
remote_head_sha=verification.get("remote_head_sha"),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"performed": performed,
|
||||
"published": True,
|
||||
"verified": True,
|
||||
"outcome": assessment["outcome"],
|
||||
"remote_head_sha": verification.get("remote_head_sha"),
|
||||
"reasons": [],
|
||||
"evidence": assessment["evidence"],
|
||||
# Publication is the whole of this operation's authority (#812 AC23).
|
||||
"issue_lock_record_mutated": False,
|
||||
"workflow_lease_touched": False,
|
||||
"exact_next_action": (
|
||||
f"Remote head for '{branch_name}' is now observable. Call "
|
||||
f"gitea_lock_issue(issue_number={int(issue_number)}, "
|
||||
f"branch_name='{branch_name}', worktree_path='{workspace}') to renew "
|
||||
"the exact-owner lease, then gitea_create_pr."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# Merge methods supported by the Gitea merge API.
|
||||
_MERGE_METHODS = ("merge", "squash", "rebase")
|
||||
|
||||
@@ -12682,10 +12930,39 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _git_default_remote_name(root: str) -> str:
|
||||
"""First configured git remote name for *root*, defaulting to 'origin'.
|
||||
|
||||
Used to resolve the live remote master target for parity (#610). Best
|
||||
effort: any failure falls back to 'origin' so callers never raise.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "remote"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except Exception:
|
||||
return "origin"
|
||||
if res.returncode != 0:
|
||||
return "origin"
|
||||
names = [n.strip() for n in (res.stdout or "").splitlines() if n.strip()]
|
||||
return names[0] if names else "origin"
|
||||
|
||||
|
||||
def _current_master_parity() -> dict:
|
||||
"""Assess this process's code against the on-disk master HEAD (#420)."""
|
||||
"""Assess this process's code against local and live remote master (#420/#610).
|
||||
|
||||
Compares the daemon's startup commit, the on-disk checkout HEAD, and the
|
||||
live remote master target. A stale daemon relative to live master fails
|
||||
closed for mutations even when the local checkout HEAD still matches the
|
||||
startup commit. The live-remote read is best effort: an unresolved live
|
||||
head leaves read-only diagnostics unblocked but is never mutation-safe.
|
||||
"""
|
||||
current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
|
||||
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
|
||||
live_head = master_parity_gate.read_remote_master_head(
|
||||
PROJECT_ROOT, remote=_git_default_remote_name(PROJECT_ROOT))
|
||||
return master_parity_gate.assess_master_parity(
|
||||
_STARTUP_PARITY, current_head, live_remote_head=live_head)
|
||||
|
||||
|
||||
def _current_runtime_mode_report(refresh: bool = False) -> dict:
|
||||
@@ -16626,15 +16903,34 @@ def gitea_get_runtime_context(
|
||||
"restart_required": parity["restart_required"],
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
# #610 distinguished mutation-safety signals:
|
||||
"daemon_start_head": parity["daemon_start_head"],
|
||||
"local_head": parity["local_head"],
|
||||
"live_remote_head": parity["live_remote_head"],
|
||||
"live_known": parity["live_known"],
|
||||
"live_stale": parity["live_stale"],
|
||||
"mutation_safe": parity["mutation_safe"],
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"mutation_gate_enforced": not master_parity_gate.gate_disabled(),
|
||||
# #610: the capability resolver is authoritative for mutation safety;
|
||||
# local parity alone must never authorize a mutation.
|
||||
"resolver_authoritative_for_mutation_safety": True,
|
||||
}
|
||||
if parity["stale"] and not master_parity_gate.gate_disabled():
|
||||
safe_next_action = (
|
||||
"Server code is stale relative to master; restart the Gitea MCP "
|
||||
"server to load current capability gates before mutating. "
|
||||
f"({master_parity_gate.format_parity(parity)})"
|
||||
)
|
||||
if parity["restart_required"] and not master_parity_gate.gate_disabled():
|
||||
if parity["live_stale"]:
|
||||
safe_next_action = (
|
||||
"Daemon is stale relative to LIVE remote master "
|
||||
f"(started {parity['startup_head'][:12] if parity['startup_head'] else 'unknown'}, "
|
||||
f"live master {parity['live_remote_head'][:12] if parity['live_remote_head'] else 'unknown'}); "
|
||||
"restart/reconnect the Gitea MCP server before mutating. The "
|
||||
"capability resolver is authoritative for mutation safety."
|
||||
)
|
||||
else:
|
||||
safe_next_action = (
|
||||
"Server code is stale relative to master; restart the Gitea MCP "
|
||||
"server to load current capability gates before mutating. "
|
||||
f"({master_parity_gate.format_parity(parity)})"
|
||||
)
|
||||
result["safe_next_action"] = safe_next_action
|
||||
|
||||
if reveal and h:
|
||||
@@ -16683,6 +16979,13 @@ def gitea_assess_master_parity(
|
||||
"determinable": parity["determinable"],
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
# #610 distinguished mutation-safety signals:
|
||||
"daemon_start_head": parity["daemon_start_head"],
|
||||
"local_head": parity["local_head"],
|
||||
"live_remote_head": parity["live_remote_head"],
|
||||
"live_known": parity["live_known"],
|
||||
"live_stale": parity["live_stale"],
|
||||
"mutation_safe": parity["mutation_safe"],
|
||||
"mutation_gate_enforced": enforced,
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"reasons": parity["reasons"],
|
||||
@@ -16699,7 +17002,7 @@ def gitea_assess_master_parity(
|
||||
source=canonical_source,
|
||||
),
|
||||
}
|
||||
if parity["stale"] and enforced:
|
||||
if parity["restart_required"] and enforced:
|
||||
out["report"] = master_parity_gate.parity_report(parity)
|
||||
return out
|
||||
|
||||
|
||||
Reference in New Issue
Block a user