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:
2026-07-20 12:55:40 -05:00
co-authored by Claude Opus 4.8
parent 0568f44cb2
commit 5ed2ab8a38
9 changed files with 1335 additions and 68 deletions
+146 -9
View File
@@ -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"]