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:
+157
-39
@@ -55,18 +55,26 @@ def resolve_namespace_workspace(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
demotions: list[str] | None = None,
|
||||
verify_paths: bool = False,
|
||||
durable_author_result: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*.
|
||||
|
||||
With *verify_paths*, env-sourced candidates whose path no longer exists
|
||||
are demoted (#702): a binding to a deleted worktree can never name a
|
||||
valid task workspace, so resolution falls through to the next candidate.
|
||||
Explicit arguments are never demoted — a caller-declared path must fail
|
||||
loudly downstream rather than silently rebind. Demotion notes are
|
||||
appended to *demotions* when provided. Runtime-context and mutation
|
||||
are demoted (#702) for non-author roles: a binding to a deleted worktree
|
||||
can never name a valid task workspace, so resolution falls through to the
|
||||
next candidate. Explicit arguments are never demoted — a caller-declared
|
||||
path must fail loudly downstream rather than silently rebind. Demotion
|
||||
notes are appended to *demotions* when provided.
|
||||
|
||||
Author role (#618): never demotes a missing configured binding to the
|
||||
control checkout. When *verify_paths* is true, resolution goes through
|
||||
:func:`author_mutation_worktree.resolve_durable_author_worktree` so
|
||||
mutations either use an explicit validated worktree, derive from the
|
||||
active author issue lock, or fail closed. Runtime-context and mutation
|
||||
guards resolve through :func:`resolve_namespace_mutation_context`, which
|
||||
always verifies.
|
||||
"""
|
||||
@@ -74,6 +82,32 @@ def resolve_namespace_workspace(
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
# #618: durable author resolution — no silent control/master fallback.
|
||||
if role == "author" and verify_paths:
|
||||
durable = durable_author_result
|
||||
if durable is None:
|
||||
durable = amw.resolve_durable_author_worktree(
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
# Path selection only here; full validation is re-run in
|
||||
# resolve_namespace_mutation_context with the canonical root.
|
||||
validate=False,
|
||||
)
|
||||
workspace = durable.get("workspace_path") or os.path.realpath(
|
||||
process_project_root
|
||||
)
|
||||
source = durable.get("workspace_binding_source") or "no author worktree binding"
|
||||
if demotions is not None and durable.get("bound_worktree_missing"):
|
||||
demotions.append(
|
||||
f"{source} '{workspace}' not demoted: {amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||
)
|
||||
return workspace, source
|
||||
|
||||
for candidate, source, env_sourced in (
|
||||
(worktree_path, "worktree_path argument", False),
|
||||
(worktree, "worktree argument", False),
|
||||
@@ -83,6 +117,11 @@ def resolve_namespace_workspace(
|
||||
f"{role_env_key} environment variable", True),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree", False),
|
||||
# Author lock derivation is handled by the durable path above when
|
||||
# verify_paths is true; when verify_paths is false, surface the lock
|
||||
# path as a non-demoted candidate so tooling can inspect it.
|
||||
(session_lock_worktree if role == "author" else None,
|
||||
"active author issue lock worktree", False),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if not text:
|
||||
@@ -107,6 +146,7 @@ def resolve_namespace_mutation_context(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
@@ -119,21 +159,54 @@ def resolve_namespace_mutation_context(
|
||||
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||
repository the namespace actually mutates. Without it the single-repo
|
||||
default is preserved: the canonical root follows the process checkout.
|
||||
|
||||
Author role (#618): uses durable worktree resolution (explicit path, env,
|
||||
or active issue lock) and never silently falls back to the control checkout.
|
||||
"""
|
||||
demotions: list[str] = []
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
env_map = env if env is not None else os.environ
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
configured = (configured_canonical_root or "").strip()
|
||||
if configured:
|
||||
canonical_root = os.path.realpath(configured)
|
||||
else:
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
|
||||
durable: dict | None = None
|
||||
if role == "author":
|
||||
durable = amw.resolve_durable_author_worktree(
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_root,
|
||||
active_worktree_env=_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||
author_worktree_env=_env_value(env_map, AUTHOR_WORKTREE_ENV),
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
canonical_repo_root=canonical_root,
|
||||
profile_name=profile_name,
|
||||
validate=True,
|
||||
)
|
||||
workspace = durable["workspace_path"]
|
||||
binding_source = durable["workspace_binding_source"]
|
||||
if durable.get("bound_worktree_missing"):
|
||||
demotions.append(
|
||||
f"{binding_source} '{workspace}' not demoted: "
|
||||
f"{amw.BOUND_WORKTREE_MISSING_MESSAGE}"
|
||||
)
|
||||
else:
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
demotions=demotions,
|
||||
verify_paths=True,
|
||||
)
|
||||
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
@@ -141,12 +214,7 @@ def resolve_namespace_mutation_context(
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
configured = (configured_canonical_root or "").strip()
|
||||
if configured:
|
||||
canonical_root = os.path.realpath(configured)
|
||||
else:
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
result = {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
@@ -155,6 +223,17 @@ def resolve_namespace_mutation_context(
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
if durable is not None:
|
||||
result["author_worktree_resolution"] = durable
|
||||
result["bound_worktree_missing"] = bool(durable.get("bound_worktree_missing"))
|
||||
result["path_exists"] = durable.get("path_exists")
|
||||
result["in_git_worktree_list"] = durable.get("in_git_worktree_list")
|
||||
result["inspected_git_root"] = durable.get("inspected_git_root")
|
||||
result["author_worktree_block"] = bool(durable.get("block"))
|
||||
result["author_worktree_reasons"] = list(durable.get("reasons") or [])
|
||||
result["author_worktree_blocker_kind"] = durable.get("blocker_kind")
|
||||
result["operator_recovery"] = durable.get("operator_recovery")
|
||||
return result
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
@@ -231,10 +310,29 @@ def format_namespace_workspace_binding_error(
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
operator_recovery: str | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
reason_list = list(reasons or [])
|
||||
# #618: prefer the durable author missing-worktree message when present.
|
||||
if role == "author" and any(
|
||||
amw.BOUND_WORKTREE_MISSING_MESSAGE in r for r in reason_list
|
||||
):
|
||||
return amw.format_bound_worktree_missing_error(
|
||||
{
|
||||
"reasons": reason_list,
|
||||
"binding_source": binding_source,
|
||||
"configured_path": workspace_path,
|
||||
"role_kind": role,
|
||||
"operator_recovery": operator_recovery
|
||||
or amw.OPERATOR_RECOVERY_RECREATE_REPOINT,
|
||||
}
|
||||
)
|
||||
try:
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
except OSError:
|
||||
workspace = workspace_path
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
@@ -249,15 +347,18 @@ def format_namespace_workspace_binding_error(
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
if reason_list:
|
||||
parts.append("Details: " + "; ".join(reason_list) + ".")
|
||||
if operator_recovery:
|
||||
parts.append(f"Operator recovery: {operator_recovery}")
|
||||
else:
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@@ -269,6 +370,7 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
session_lock_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
@@ -281,6 +383,7 @@ def assess_namespace_mutation_workspace(
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
session_lock_worktree=session_lock_worktree,
|
||||
profile_name=profile_name,
|
||||
configured_canonical_root=configured_canonical_root,
|
||||
)
|
||||
@@ -305,14 +408,23 @@ def assess_namespace_mutation_workspace(
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
operator_recovery = ctx.get("operator_recovery")
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
# #618 durable resolution already validated existence, membership,
|
||||
# branches/, lock ownership, and traversal safety when present.
|
||||
durable_reasons = list(ctx.get("author_worktree_reasons") or [])
|
||||
if durable_reasons:
|
||||
reasons.extend(durable_reasons)
|
||||
elif ctx.get("author_worktree_block"):
|
||||
reasons.append(amw.BOUND_WORKTREE_MISSING_MESSAGE)
|
||||
else:
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
@@ -345,4 +457,10 @@ def assess_namespace_mutation_workspace(
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"bound_worktree_missing": bool(ctx.get("bound_worktree_missing")),
|
||||
"path_exists": ctx.get("path_exists"),
|
||||
"in_git_worktree_list": ctx.get("in_git_worktree_list"),
|
||||
"inspected_git_root": ctx.get("inspected_git_root"),
|
||||
"operator_recovery": operator_recovery,
|
||||
"blocker_kind": ctx.get("author_worktree_blocker_kind"),
|
||||
}
|
||||
Reference in New Issue
Block a user