Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89657a06c4 | ||
|
|
ba3ea3012c | ||
|
|
ef14622ba0 | ||
|
|
a3f8f67c93 | ||
|
|
6d0015cabc |
File diff suppressed because it is too large
Load Diff
+278
-1
@@ -440,13 +440,32 @@ def _session_author_lock_worktree() -> str | None:
|
|||||||
|
|
||||||
Used to derive the author mutation workspace when no explicit
|
Used to derive the author mutation workspace when no explicit
|
||||||
``worktree_path`` or env binding is provided. Never invents a path.
|
``worktree_path`` or env binding is provided. Never invents a path.
|
||||||
|
|
||||||
|
#864: a session pointer whose owner PID is dead and is not this process
|
||||||
|
must not force workspace binding for other issues — rebind is required for
|
||||||
|
that issue, and a stale dead-owner pointer must not poison unrelated work.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
lock = issue_lock_store.read_session_issue_lock() or {}
|
lock = issue_lock_store.read_session_issue_lock() or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
path = (lock.get("worktree_path") or "").strip()
|
path = (lock.get("worktree_path") or "").strip()
|
||||||
return path or None
|
if not path:
|
||||||
|
return None
|
||||||
|
pid = lock.get("session_pid")
|
||||||
|
if pid is None:
|
||||||
|
pid = lock.get("pid")
|
||||||
|
try:
|
||||||
|
pid_i = int(pid) if pid is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pid_i = None
|
||||||
|
if (
|
||||||
|
pid_i is not None
|
||||||
|
and pid_i != os.getpid()
|
||||||
|
and not issue_lock_store.is_process_alive(pid_i)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||||
@@ -2031,6 +2050,7 @@ import issue_lock_store # noqa: E402
|
|||||||
import issue_lock_adoption # noqa: E402
|
import issue_lock_adoption # noqa: E402
|
||||||
import issue_lock_recovery # noqa: E402
|
import issue_lock_recovery # noqa: E402
|
||||||
import issue_lock_renewal # noqa: E402
|
import issue_lock_renewal # noqa: E402
|
||||||
|
import dirty_same_claimant_session_rebind # noqa: E402 # #864
|
||||||
import stacked_pr_support # noqa: E402
|
import stacked_pr_support # noqa: E402
|
||||||
import merge_approval_gate # noqa: E402
|
import merge_approval_gate # noqa: E402
|
||||||
import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine
|
import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine
|
||||||
@@ -4342,6 +4362,263 @@ def gitea_lock_issue(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_rebind_dirty_same_claimant_author_session(
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
old_pid: int,
|
||||||
|
expected_local_head: str,
|
||||||
|
expected_remote_head: str,
|
||||||
|
expected_dirty_paths: list[str],
|
||||||
|
expected_fingerprints: dict,
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
authorize_reconciler_execute: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Rebind a dirty registered issue worktree to this session (#864).
|
||||||
|
|
||||||
|
Sanctioned only when every pin agrees: same claimant, dead old_pid matching
|
||||||
|
the durable lock, matching local/remote heads, exact dirty path set, and
|
||||||
|
per-path sha256 fingerprints. Preserves every tracked/untracked byte.
|
||||||
|
Does not sync remote, create recovery worktrees, clean, reset, or move heads.
|
||||||
|
|
||||||
|
Role gate:
|
||||||
|
* author — must match the lock claimant identity/profile
|
||||||
|
* reconciler — execute only when ``authorize_reconciler_execute=True``
|
||||||
|
* reviewer/merger — always refuse
|
||||||
|
|
||||||
|
``gitea.issue.comment`` (author map entry) is required for mutation; dry_run
|
||||||
|
still assesses fully but writes nothing. Permission alone is never ownership
|
||||||
|
proof — every pin is re-checked server-side.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue_number: Tracking issue number on the durable lock.
|
||||||
|
branch_name: Exact locked branch name.
|
||||||
|
worktree_path: Registered dirty worktree path (must be under branches/).
|
||||||
|
old_pid: Dead owner PID recorded on the lock (must match session_pid/pid).
|
||||||
|
expected_local_head: Full local HEAD sha the caller observed.
|
||||||
|
expected_remote_head: Full remote-tracking HEAD sha the caller observed.
|
||||||
|
expected_dirty_paths: Exact set of dirty relative paths (tracked+untracked).
|
||||||
|
expected_fingerprints: Map of relative path -> sha256 hex of file bytes.
|
||||||
|
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||||
|
host/org/repo: Optional target overrides (validated against binding).
|
||||||
|
dry_run: When true, assess only (no lock/session writes).
|
||||||
|
authorize_reconciler_execute: Reconciler-only execute gate.
|
||||||
|
"""
|
||||||
|
role = _profile_role_kind(get_profile())
|
||||||
|
role_norm = (role or "").strip().lower()
|
||||||
|
|
||||||
|
# Permission: authors need comment; dry_run assess is reachable under read
|
||||||
|
# for diagnosis, but execute always needs comment. Reconciler execute also
|
||||||
|
# needs comment when authorized.
|
||||||
|
if dry_run:
|
||||||
|
read_block = _profile_operation_gate("gitea.read")
|
||||||
|
if read_block:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"dry_run": True,
|
||||||
|
"reasons": read_block,
|
||||||
|
"permission_report": _permission_block_report("gitea.read"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
blocked = _profile_permission_block(
|
||||||
|
task_capability_map.required_permission(
|
||||||
|
"rebind_dirty_same_claimant_author_session"
|
||||||
|
),
|
||||||
|
issue_number=issue_number,
|
||||||
|
remote=remote,
|
||||||
|
host=host,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
org_explicit=org is not None,
|
||||||
|
repo_explicit=repo is not None,
|
||||||
|
)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
|
||||||
|
if role_norm in {"reviewer", "merger"}:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"dry_run": bool(dry_run),
|
||||||
|
"outcome": dirty_same_claimant_session_rebind.REFUSED,
|
||||||
|
"reasons": [
|
||||||
|
f"role '{role_norm}' cannot rebind dirty same-claimant author "
|
||||||
|
"sessions (fail closed)"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if role_norm == "reconciler" and not authorize_reconciler_execute and not dry_run:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"dry_run": False,
|
||||||
|
"outcome": dirty_same_claimant_session_rebind.REFUSED,
|
||||||
|
"reasons": [
|
||||||
|
"reconciler role requires authorize_reconciler_execute=True "
|
||||||
|
"to execute dirty same-claimant rebind (fail closed)"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
try:
|
||||||
|
identity = _authenticated_username(h)
|
||||||
|
except Exception:
|
||||||
|
identity = None
|
||||||
|
profile = get_profile()
|
||||||
|
profile_name = profile.get("profile_name")
|
||||||
|
|
||||||
|
existing = _load_existing_issue_lock(
|
||||||
|
remote=remote, org=o, repo=r, issue_number=issue_number
|
||||||
|
)
|
||||||
|
resolved_wt = os.path.realpath(os.path.abspath((worktree_path or "").strip()))
|
||||||
|
inv = dirty_same_claimant_session_rebind.collect_dirty_inventory(resolved_wt)
|
||||||
|
|
||||||
|
branch_res = subprocess.run(
|
||||||
|
["git", "-C", resolved_wt, "branch", "--show-current"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
current_branch = (branch_res.stdout or "").strip() or None
|
||||||
|
head_res = subprocess.run(
|
||||||
|
["git", "-C", resolved_wt, "rev-parse", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
local_head = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||||
|
|
||||||
|
# Observe remote-tracking head without network when possible.
|
||||||
|
remote_head = None
|
||||||
|
for ref in (
|
||||||
|
f"refs/remotes/origin/{branch_name}",
|
||||||
|
f"origin/{branch_name}",
|
||||||
|
f"refs/remotes/{remote}/{branch_name}",
|
||||||
|
f"{remote}/{branch_name}",
|
||||||
|
):
|
||||||
|
rh = subprocess.run(
|
||||||
|
["git", "-C", resolved_wt, "rev-parse", "--verify", "--quiet", ref],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if rh.returncode == 0 and (rh.stdout or "").strip():
|
||||||
|
remote_head = (rh.stdout or "").strip()
|
||||||
|
break
|
||||||
|
if remote_head is None:
|
||||||
|
# Fall back to caller's pin only for observation absence — assessment
|
||||||
|
# still requires pin==observed, so missing observation fails closed.
|
||||||
|
remote_head = None
|
||||||
|
|
||||||
|
# Competing live locks (other issues / other worktrees).
|
||||||
|
competing_live = []
|
||||||
|
for entry in issue_lock_store.list_live_locks():
|
||||||
|
competing_live.append(entry)
|
||||||
|
|
||||||
|
# Session pointers that claim this issue lock.
|
||||||
|
competing_sessions = []
|
||||||
|
lock_dir = issue_lock_store.default_lock_dir()
|
||||||
|
lock_path = issue_lock_store.lock_file_path(
|
||||||
|
remote=remote, org=o, repo=r, issue_number=issue_number, lock_dir=lock_dir
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for name in os.listdir(lock_dir):
|
||||||
|
if not name.startswith("session-") or not name.endswith(".json"):
|
||||||
|
continue
|
||||||
|
ptr = issue_lock_store.read_lock_file(os.path.join(lock_dir, name))
|
||||||
|
if not ptr:
|
||||||
|
continue
|
||||||
|
ptr_lock = str(ptr.get("lock_file_path") or "").strip()
|
||||||
|
if not ptr_lock:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
same = os.path.realpath(ptr_lock) == os.path.realpath(lock_path)
|
||||||
|
except OSError:
|
||||||
|
same = ptr_lock == lock_path
|
||||||
|
if not same:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sess_pid = int(str(name)[len("session-") : -len(".json")])
|
||||||
|
except ValueError:
|
||||||
|
sess_pid = ptr.get("pid")
|
||||||
|
competing_sessions.append(
|
||||||
|
{
|
||||||
|
"pid": sess_pid,
|
||||||
|
"lock_file_path": ptr_lock,
|
||||||
|
"live": issue_lock_store.is_process_alive(sess_pid),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Best-effort workflow-lease scan: any live lock file whose work_lease is a
|
||||||
|
# non-author workflow lease on this issue/branch counts as active.
|
||||||
|
workflow_lease_active = False
|
||||||
|
for path in issue_lock_store.iter_lock_files(lock_dir):
|
||||||
|
rec = issue_lock_store.read_lock_file(path)
|
||||||
|
if not rec:
|
||||||
|
continue
|
||||||
|
lease = rec.get("work_lease") if isinstance(rec.get("work_lease"), dict) else {}
|
||||||
|
op = str(lease.get("operation_type") or "")
|
||||||
|
if op and op != issue_lock_store.AUTHOR_ISSUE_WORK_LEASE:
|
||||||
|
if rec.get("issue_number") == issue_number or str(
|
||||||
|
rec.get("branch_name") or ""
|
||||||
|
) == branch_name:
|
||||||
|
if issue_lock_store.is_lease_live(rec):
|
||||||
|
workflow_lease_active = True
|
||||||
|
break
|
||||||
|
|
||||||
|
repo_root = _canonical_local_git_root()
|
||||||
|
# permission_allowed reflects profile gate only — never ownership proof.
|
||||||
|
permission_allowed = True
|
||||||
|
|
||||||
|
result = dirty_same_claimant_session_rebind.apply_dirty_same_claimant_session_rebind(
|
||||||
|
remote=remote,
|
||||||
|
org=o,
|
||||||
|
repo=r,
|
||||||
|
issue_number=issue_number,
|
||||||
|
branch_name=branch_name,
|
||||||
|
worktree_path=resolved_wt,
|
||||||
|
claimant_identity=identity,
|
||||||
|
claimant_profile=profile_name,
|
||||||
|
old_pid=old_pid,
|
||||||
|
expected_local_head=expected_local_head,
|
||||||
|
expected_remote_head=expected_remote_head,
|
||||||
|
expected_dirty_paths=list(expected_dirty_paths or []),
|
||||||
|
expected_fingerprints=dict(expected_fingerprints or {}),
|
||||||
|
existing_lock=existing,
|
||||||
|
current_identity=identity,
|
||||||
|
current_profile=profile_name,
|
||||||
|
role_kind=role_norm or role,
|
||||||
|
current_pid=os.getpid(),
|
||||||
|
current_branch=current_branch,
|
||||||
|
local_head=local_head,
|
||||||
|
remote_head=remote_head,
|
||||||
|
dirty_inventory=inv,
|
||||||
|
competing_live_locks=competing_live,
|
||||||
|
competing_sessions=competing_sessions,
|
||||||
|
workflow_lease_active=workflow_lease_active,
|
||||||
|
authorize_reconciler_execute=bool(authorize_reconciler_execute),
|
||||||
|
permission_allowed=permission_allowed,
|
||||||
|
repo_root=repo_root,
|
||||||
|
dry_run=bool(dry_run),
|
||||||
|
lock_dir=lock_dir,
|
||||||
|
)
|
||||||
|
result["observed"] = {
|
||||||
|
"local_head": local_head,
|
||||||
|
"remote_head": remote_head,
|
||||||
|
"current_branch": current_branch,
|
||||||
|
"dirty_paths": inv.get("dirty_paths"),
|
||||||
|
"fingerprints": inv.get("fingerprints"),
|
||||||
|
"identity": identity,
|
||||||
|
"profile": profile_name,
|
||||||
|
"role_kind": role_norm,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_assess_work_issue_duplicate(
|
def gitea_assess_work_issue_duplicate(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock
|
|||||||
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
|
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
|
||||||
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
|
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
|
||||||
SOURCE_OPERATOR_OVERRIDE = "operator_override"
|
SOURCE_OPERATOR_OVERRIDE = "operator_override"
|
||||||
|
# #864: dirty-preserving same-claimant author-session rebind (dead owner PID).
|
||||||
|
SOURCE_DIRTY_SAME_CLAIMANT_REBIND = (
|
||||||
|
"gitea_rebind_dirty_same_claimant_author_session"
|
||||||
|
)
|
||||||
|
|
||||||
SANCTIONED_LOCK_SOURCES = frozenset({
|
SANCTIONED_LOCK_SOURCES = frozenset({
|
||||||
SOURCE_LOCK_ISSUE,
|
SOURCE_LOCK_ISSUE,
|
||||||
SOURCE_LOCK_ADOPTION,
|
SOURCE_LOCK_ADOPTION,
|
||||||
SOURCE_OPERATOR_OVERRIDE,
|
SOURCE_OPERATOR_OVERRIDE,
|
||||||
|
SOURCE_DIRTY_SAME_CLAIMANT_REBIND,
|
||||||
})
|
})
|
||||||
|
|
||||||
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# #864: dirty-preserving same-claimant author-session rebind (dead owner PID).
|
||||||
|
# Author MCP tool path. Reconciler execute is gated inside the tool via
|
||||||
|
# authorize_reconciler_execute + role_kind checks (not this map entry).
|
||||||
|
"rebind_dirty_same_claimant_author_session": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_rebind_dirty_same_claimant_author_session": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"set_issue_labels": {
|
"set_issue_labels": {
|
||||||
"permission": "gitea.issue.comment",
|
"permission": "gitea.issue.comment",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user