fix: durable author worktree resolution without control fallback (Closes #618)
Author mutation tools now resolve workspace via explicit worktree_path,
env bindings, or the active author issue lock — never silent fallback to
the control checkout/master. Missing configured bindings fail closed with
operator recovery; create_issue and create_issue_comment agree.
Recovered onto 0568f44 from preserved candidate cbf56ccd (AUTHOR_RECOVERY).
LLM_LOCK_ID=author-618-recovery-508eb3162d01-1784569919
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+146
-9
@@ -422,6 +422,20 @@ def _canonical_local_git_root() -> str:
|
||||
return crr.resolve_repo_toplevel(configured) or os.path.realpath(configured)
|
||||
|
||||
|
||||
def _session_author_lock_worktree() -> str | None:
|
||||
"""Worktree path recorded on the active author issue lock (#618).
|
||||
|
||||
Used to derive the author mutation workspace when no explicit
|
||||
``worktree_path`` or env binding is provided. Never invents a path.
|
||||
"""
|
||||
try:
|
||||
lock = issue_lock_store.read_session_issue_lock() or {}
|
||||
except Exception:
|
||||
return None
|
||||
path = (lock.get("worktree_path") or "").strip()
|
||||
return path or None
|
||||
|
||||
|
||||
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
"""Resolve the namespace-scoped workspace root inspected by pre-flight guards.
|
||||
|
||||
@@ -429,6 +443,9 @@ def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
canonical mutation context (verify_paths). A dead env binding must demote
|
||||
here exactly as it does in :func:`_resolve_namespace_mutation_context`,
|
||||
or preflight would inspect a path the mutation guard never uses.
|
||||
|
||||
#618: author role never demotes a missing configured binding to the
|
||||
control checkout; resolution may derive from the active issue lock.
|
||||
"""
|
||||
role = _effective_workspace_role()
|
||||
workspace, _source = nwb.resolve_namespace_workspace(
|
||||
@@ -438,6 +455,9 @@ def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
session_lease_worktree=(
|
||||
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
|
||||
),
|
||||
session_lock_worktree=(
|
||||
_session_author_lock_worktree() if role == "author" else None
|
||||
),
|
||||
profile_name=get_profile().get("profile_name"),
|
||||
verify_paths=True,
|
||||
)
|
||||
@@ -445,7 +465,7 @@ def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
|
||||
|
||||
|
||||
def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict:
|
||||
"""Canonical namespace workspace + repository root for guards (#460/#510/#706)."""
|
||||
"""Canonical namespace workspace + repository root for guards (#460/#510/#706/#618)."""
|
||||
role = _effective_workspace_role()
|
||||
configured_root, _source = _configured_canonical_root()
|
||||
return nwb.resolve_namespace_mutation_context(
|
||||
@@ -455,6 +475,9 @@ def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dic
|
||||
session_lease_worktree=(
|
||||
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
|
||||
),
|
||||
session_lock_worktree=(
|
||||
_session_author_lock_worktree() if role == "author" else None
|
||||
),
|
||||
profile_name=get_profile().get("profile_name"),
|
||||
configured_canonical_root=configured_root,
|
||||
)
|
||||
@@ -555,7 +578,10 @@ def _format_preflight_files(files: list[str]) -> str:
|
||||
def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict:
|
||||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
inspected_root = _get_git_root(workspace)
|
||||
# Prefer durable-resolution evidence when present (#618); fall back to live git.
|
||||
inspected_root = ctx.get("inspected_git_root")
|
||||
if inspected_root is None and workspace and os.path.isdir(workspace):
|
||||
inspected_root = _get_git_root(workspace)
|
||||
process_root = ctx["process_project_root"]
|
||||
canonical_root = ctx["canonical_repo_root"]
|
||||
active_root = os.path.realpath(inspected_root or workspace)
|
||||
@@ -563,6 +589,9 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
|
||||
dirty_scope = "control checkout"
|
||||
else:
|
||||
dirty_scope = "active task workspace"
|
||||
path_exists = ctx.get("path_exists")
|
||||
if path_exists is None:
|
||||
path_exists = os.path.isdir(workspace) if workspace else False
|
||||
details = {
|
||||
"mcp_server_process_root": process_root,
|
||||
"canonical_repository_root": canonical_root,
|
||||
@@ -574,6 +603,16 @@ def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[st
|
||||
"workspace_role_kind": ctx.get("workspace_role_kind"),
|
||||
"workspace_binding_source": ctx.get("workspace_binding_source"),
|
||||
"ignored_bindings": list(ctx.get("ignored_bindings") or []),
|
||||
# #618 runtime health surface for bound worktree disappearance
|
||||
"path_exists": path_exists,
|
||||
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
|
||||
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
|
||||
"author_worktree_block": bool(ctx.get("author_worktree_block")),
|
||||
"author_worktree_reasons": list(ctx.get("author_worktree_reasons") or []),
|
||||
"operator_recovery": ctx.get("operator_recovery"),
|
||||
"workspace_healthy": not bool(
|
||||
ctx.get("bound_worktree_missing") or ctx.get("author_worktree_block")
|
||||
),
|
||||
}
|
||||
if not ctx["roots_aligned"]:
|
||||
details["workspace_root_mismatch"] = (
|
||||
@@ -590,6 +629,10 @@ def _format_preflight_workspace_details(details: dict) -> str:
|
||||
f"workspace role: {details.get('workspace_role_kind')}",
|
||||
f"binding source: {details.get('workspace_binding_source')}",
|
||||
f"inspected git root: {details.get('inspected_git_root')}",
|
||||
f"path_exists: {details.get('path_exists')}",
|
||||
f"in_git_worktree_list: {details.get('in_git_worktree_list')}",
|
||||
f"bound_worktree_missing: {details.get('bound_worktree_missing')}",
|
||||
f"workspace_healthy: {details.get('workspace_healthy')}",
|
||||
f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}",
|
||||
f"dirty scope: {details.get('dirty_scope')}",
|
||||
]
|
||||
@@ -600,7 +643,7 @@ def _format_preflight_workspace_details(details: dict) -> str:
|
||||
|
||||
|
||||
def assess_preflight_status(worktree_path: str | None = None) -> dict:
|
||||
"""Non-throwing pre-flight readiness for runtime-context alignment (#252)."""
|
||||
"""Non-throwing pre-flight readiness for runtime-context alignment (#252/#618)."""
|
||||
reasons: list[str] = []
|
||||
if not _preflight_whoami_called:
|
||||
reasons.append(
|
||||
@@ -610,16 +653,52 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
|
||||
reasons.append(
|
||||
"Task capability (gitea_resolve_task_capability) has not been resolved"
|
||||
)
|
||||
try:
|
||||
role = _effective_workspace_role()
|
||||
except Exception:
|
||||
role = "author"
|
||||
|
||||
# #618: always compute workspace details for author so runtime_context can
|
||||
# report unhealthy state when a configured role-bound path is missing.
|
||||
dirty_files: list[str] = []
|
||||
workspace_details = None
|
||||
if worktree_path:
|
||||
dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(worktree_path)))
|
||||
try:
|
||||
dirty_files = sorted(
|
||||
_parse_porcelain_entries(_get_workspace_porcelain(worktree_path))
|
||||
)
|
||||
workspace_details = _preflight_workspace_details(worktree_path, dirty_files)
|
||||
if dirty_files:
|
||||
except Exception:
|
||||
workspace_details = None
|
||||
|
||||
if workspace_details is not None:
|
||||
if workspace_details.get("bound_worktree_missing") or (
|
||||
workspace_details.get("author_worktree_block")
|
||||
and workspace_details.get("path_exists") is False
|
||||
):
|
||||
reasons.append(
|
||||
author_mutation_worktree.BOUND_WORKTREE_MISSING_MESSAGE
|
||||
+ f" ({_format_preflight_workspace_details(workspace_details)})"
|
||||
)
|
||||
elif (
|
||||
role == "author"
|
||||
and workspace_details.get("workspace_healthy") is False
|
||||
and workspace_details.get("author_worktree_reasons")
|
||||
):
|
||||
reasons.append(
|
||||
"Author worktree binding unhealthy: "
|
||||
+ "; ".join(workspace_details.get("author_worktree_reasons") or [])
|
||||
+ f" ({_format_preflight_workspace_details(workspace_details)})"
|
||||
)
|
||||
|
||||
if worktree_path:
|
||||
if dirty_files and not any("bound worktree missing" in r for r in reasons):
|
||||
details = workspace_details or _preflight_workspace_details(
|
||||
worktree_path, dirty_files
|
||||
)
|
||||
reasons.append(
|
||||
"Active task workspace has tracked file edits before mutation "
|
||||
f"({_format_preflight_workspace_details(workspace_details)})"
|
||||
f"({_format_preflight_workspace_details(details)})"
|
||||
)
|
||||
role = _effective_workspace_role()
|
||||
if role in nwb.NON_AUTHOR_ROLES:
|
||||
binding = nwb.assess_metadata_only_worktree_binding(
|
||||
role_kind=role,
|
||||
@@ -681,7 +760,9 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
|
||||
"preflight_whoami_violation_files": list(_preflight_whoami_violation_files),
|
||||
"preflight_capability_violation_files": list(_preflight_capability_violation_files),
|
||||
"preflight_reviewer_violation_files": list(_preflight_reviewer_violation_files),
|
||||
"preflight_workspace": _preflight_workspace_details(None, []),
|
||||
"preflight_workspace": workspace_details
|
||||
if workspace_details is not None
|
||||
else _preflight_workspace_details(None, []),
|
||||
}
|
||||
|
||||
|
||||
@@ -926,6 +1007,58 @@ def _enforce_branches_only_author_mutation(
|
||||
return
|
||||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
|
||||
# #618: durable resolution already failed closed (missing binding, lock
|
||||
# mismatch, traversal, membership). Bound-worktree-missing never receives
|
||||
# the create_issue control-checkout bootstrap exemption — a configured but
|
||||
# missing worktree is never "clean control".
|
||||
durable = ctx.get("author_worktree_resolution") or {}
|
||||
if ctx.get("bound_worktree_missing") or (
|
||||
durable.get("block") and durable.get("bound_worktree_missing")
|
||||
):
|
||||
raise RuntimeError(
|
||||
author_mutation_worktree.format_durable_author_worktree_error(
|
||||
durable or {
|
||||
"reasons": ctx.get("author_worktree_reasons")
|
||||
or [author_mutation_worktree.BOUND_WORKTREE_MISSING_MESSAGE],
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": ctx.get("workspace_binding_source"),
|
||||
"bound_worktree_missing": True,
|
||||
"blocker_kind": author_mutation_worktree.BOUND_WORKTREE_MISSING,
|
||||
"operator_recovery": ctx.get("operator_recovery"),
|
||||
"configured_path": workspace,
|
||||
"binding_source": ctx.get("workspace_binding_source"),
|
||||
"profile_name": get_profile().get("profile_name"),
|
||||
}
|
||||
)
|
||||
)
|
||||
if durable.get("block") and not durable.get("bound_worktree_missing"):
|
||||
# Other durable failures (lock ownership, traversal, no binding while
|
||||
# on control). create_issue bootstrap may still permit clean control.
|
||||
import create_issue_bootstrap as _cib
|
||||
|
||||
bootstrap = (
|
||||
_create_issue_bootstrap_assessment(task, worktree_path)
|
||||
if bootstrap_assessment is _BOOTSTRAP_UNSET
|
||||
else bootstrap_assessment
|
||||
)
|
||||
if _cib.bootstrap_permits_control_checkout(
|
||||
bootstrap,
|
||||
task=task,
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=ctx["canonical_repo_root"],
|
||||
):
|
||||
return
|
||||
if (
|
||||
isinstance(bootstrap, dict)
|
||||
and bootstrap.get("block")
|
||||
and not bootstrap.get("not_applicable")
|
||||
):
|
||||
raise RuntimeError(_cib.format_create_issue_bootstrap_error(bootstrap))
|
||||
raise RuntimeError(
|
||||
author_mutation_worktree.format_durable_author_worktree_error(durable)
|
||||
)
|
||||
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||||
assessment = author_mutation_worktree.assess_author_mutation_worktree(
|
||||
workspace_path=workspace,
|
||||
@@ -1644,6 +1777,9 @@ def _verify_role_mutation_workspace(
|
||||
session_lease_worktree=(
|
||||
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
|
||||
),
|
||||
session_lock_worktree=(
|
||||
_session_author_lock_worktree() if role == "author" else None
|
||||
),
|
||||
profile_name=get_profile().get("profile_name"),
|
||||
current_branch=git_state.get("current_branch"),
|
||||
configured_canonical_root=_configured_root,
|
||||
@@ -1657,6 +1793,7 @@ def _verify_role_mutation_workspace(
|
||||
or "unknown binding source",
|
||||
reasons=assessment.get("reasons"),
|
||||
ignored_bindings=assessment.get("ignored_bindings"),
|
||||
operator_recovery=assessment.get("operator_recovery"),
|
||||
)
|
||||
)
|
||||
resolved = assessment["mutation_workspace"]
|
||||
|
||||
Reference in New Issue
Block a user