Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2066623986 | ||
|
|
2b4e43042a | ||
|
|
0f9390aab4 | ||
|
|
59aab06fe1 |
+157
-46
@@ -386,6 +386,68 @@ def run_compensating_recovery(
|
|||||||
return recovery_info
|
return recovery_info
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
|
"""Normalize a Git object id for comparison, or ``None`` when unknown."""
|
||||||
|
normalized = (value or "").strip().lower()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _author_bootstrap_assessment(
|
||||||
|
*,
|
||||||
|
not_applicable: bool,
|
||||||
|
allowed: bool,
|
||||||
|
block: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
workspace: str,
|
||||||
|
root: str,
|
||||||
|
branch: str | None,
|
||||||
|
dirty: list[str],
|
||||||
|
under_branches: bool,
|
||||||
|
bootstrap_path: str | None = None,
|
||||||
|
local_head_sha: str | None = None,
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured author-bootstrap assessment consumable by bootstrap_permits (#892).
|
||||||
|
|
||||||
|
Field shape mirrors :func:`create_issue_bootstrap._result` so the shared
|
||||||
|
``bootstrap_permits_control_checkout`` predicate can prove control-checkout
|
||||||
|
eligibility for ``gitea_bootstrap_author_issue_worktree`` the same way it
|
||||||
|
does for ``create_issue``. Allowed control assessments must use empty
|
||||||
|
``reasons`` — narrative belongs in other fields, not the refusal list.
|
||||||
|
"""
|
||||||
|
local_tip = _normalize_sha(local_head_sha)
|
||||||
|
remote_tip = _normalize_sha(remote_master_sha)
|
||||||
|
base_tips_verified = bool(local_tip and remote_tip and local_tip == remote_tip)
|
||||||
|
return {
|
||||||
|
"not_applicable": not_applicable,
|
||||||
|
"allowed": allowed,
|
||||||
|
"block": block,
|
||||||
|
"proven": bool(allowed and not block and not not_applicable),
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
"current_branch": branch,
|
||||||
|
"dirty_files": list(dirty),
|
||||||
|
"under_branches": under_branches,
|
||||||
|
"exact_next_action": exact_next_action,
|
||||||
|
"bootstrap_path": bootstrap_path,
|
||||||
|
"task_scope": "author_issue_bootstrap",
|
||||||
|
"local_head_sha": local_tip,
|
||||||
|
"remote_master_sha": remote_tip,
|
||||||
|
"base_tips_verified": base_tips_verified,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP = (
|
||||||
|
"Restore the canonical control checkout to a clean accepted base branch "
|
||||||
|
"(master/main/dev) that matches live master, with no tracked local edits. "
|
||||||
|
"Re-resolve bootstrap_author_issue_worktree, then re-run "
|
||||||
|
"gitea_bootstrap_author_issue_worktree from that clean control checkout. "
|
||||||
|
"Do not use shell git worktree add as the primary path once bootstrap is healthy."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_author_issue_bootstrap(
|
def assess_author_issue_bootstrap(
|
||||||
*,
|
*,
|
||||||
workspace_path: str,
|
workspace_path: str,
|
||||||
@@ -397,7 +459,13 @@ def assess_author_issue_bootstrap(
|
|||||||
remote_master_sha_error: str | None = None,
|
remote_master_sha_error: str | None = None,
|
||||||
task: str | None = None,
|
task: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Assess whether author issue worktree bootstrap may proceed from control or worktree root."""
|
"""Assess whether author issue worktree bootstrap may proceed from control or worktree root.
|
||||||
|
|
||||||
|
#892: control-checkout successes emit the full field set required by
|
||||||
|
``create_issue_bootstrap.bootstrap_permits_control_checkout`` (empty reasons,
|
||||||
|
task_scope, base tip proof, binding paths) so the #274/#604 guards can
|
||||||
|
waive control-checkout for this one sanctioned bootstrap task.
|
||||||
|
"""
|
||||||
root = os.path.realpath(canonical_repo_root or "")
|
root = os.path.realpath(canonical_repo_root or "")
|
||||||
workspace = os.path.realpath(workspace_path or root or ".")
|
workspace = os.path.realpath(workspace_path or root or ".")
|
||||||
branch = (current_branch or "").strip()
|
branch = (current_branch or "").strip()
|
||||||
@@ -407,34 +475,50 @@ def assess_author_issue_bootstrap(
|
|||||||
if root
|
if root
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
|
local_tip = _normalize_sha(head_sha)
|
||||||
|
remote_tip = _normalize_sha(remote_master_sha)
|
||||||
|
|
||||||
if not is_author_issue_bootstrap_task(task):
|
if not is_author_issue_bootstrap_task(task):
|
||||||
return {
|
return _author_bootstrap_assessment(
|
||||||
"not_applicable": True,
|
not_applicable=True,
|
||||||
"allowed": False,
|
allowed=False,
|
||||||
"block": False,
|
block=False,
|
||||||
"proven": False,
|
reasons=["task is not author_issue_bootstrap"],
|
||||||
"reasons": ["task is not author_issue_bootstrap"],
|
workspace=workspace,
|
||||||
}
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=under_branches,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Already under branches/: ordinary #274 path applies; not a control waiver.
|
||||||
if under_branches:
|
if under_branches:
|
||||||
return {
|
return _author_bootstrap_assessment(
|
||||||
"not_applicable": False,
|
not_applicable=True,
|
||||||
"allowed": True,
|
allowed=False,
|
||||||
"block": False,
|
block=False,
|
||||||
"proven": True,
|
reasons=["workspace is under branches/; ordinary #274 path applies"],
|
||||||
"bootstrap_path": "existing_branches_worktree",
|
workspace=workspace,
|
||||||
"reasons": [
|
root=root,
|
||||||
"workspace is already a registered worktree under branches/"
|
branch=branch or None,
|
||||||
],
|
dirty=dirty,
|
||||||
}
|
under_branches=True,
|
||||||
|
bootstrap_path="existing_branches_worktree",
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
)
|
||||||
|
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if workspace != root:
|
if not root or workspace != root:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"bootstrap requires workspace to be canonical control checkout or branches/ worktree"
|
"bootstrap requires workspace to be canonical control checkout or branches/ worktree"
|
||||||
)
|
)
|
||||||
if branch not in author_mutation_worktree.BASE_BRANCHES:
|
if not branch:
|
||||||
|
reasons.append(
|
||||||
|
"control checkout is detached HEAD; expected an accepted base branch "
|
||||||
|
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
||||||
|
)
|
||||||
|
elif branch not in author_mutation_worktree.BASE_BRANCHES:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"control checkout branch '{branch}' is not an accepted base branch "
|
f"control checkout branch '{branch}' is not an accepted base branch "
|
||||||
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
||||||
@@ -444,37 +528,64 @@ def assess_author_issue_bootstrap(
|
|||||||
f"control checkout has tracked local edits: {', '.join(dirty[:5])}"
|
f"control checkout has tracked local edits: {', '.join(dirty[:5])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if remote_master_sha_error:
|
# Fail closed on missing tip proof (same bar as create_issue bootstrap #757).
|
||||||
|
if not local_tip:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"could not verify live master tip: {remote_master_sha_error}"
|
"control checkout HEAD SHA is unknown; base equivalence to live "
|
||||||
|
"master cannot be proven (fail closed)"
|
||||||
|
)
|
||||||
|
resolver_error = (remote_master_sha_error or "").strip() or None
|
||||||
|
if resolver_error:
|
||||||
|
reasons.append(
|
||||||
|
f"live master tip could not be resolved ({resolver_error}); "
|
||||||
|
"base equivalence cannot be proven (fail closed)"
|
||||||
|
)
|
||||||
|
elif not remote_tip:
|
||||||
|
reasons.append(
|
||||||
|
"live master tip is unknown; base equivalence cannot be proven "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
elif local_tip and remote_tip and local_tip != remote_tip:
|
||||||
|
reasons.append(
|
||||||
|
f"control checkout HEAD ({local_tip[:12]}) != live master tip "
|
||||||
|
f"({remote_tip[:12]})"
|
||||||
)
|
)
|
||||||
elif remote_master_sha and head_sha:
|
|
||||||
h = head_sha.strip().lower()
|
|
||||||
rm = remote_master_sha.strip().lower()
|
|
||||||
if h != rm:
|
|
||||||
reasons.append(
|
|
||||||
f"control checkout HEAD ({h[:12]}) != live master tip ({rm[:12]})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if reasons:
|
if reasons:
|
||||||
return {
|
return _author_bootstrap_assessment(
|
||||||
"not_applicable": False,
|
not_applicable=False,
|
||||||
"allowed": False,
|
allowed=False,
|
||||||
"block": True,
|
block=True,
|
||||||
"proven": False,
|
reasons=reasons,
|
||||||
"reasons": reasons,
|
workspace=workspace,
|
||||||
}
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
# Allowed: empty reasons so bootstrap_permits_control_checkout can pass.
|
||||||
"not_applicable": False,
|
return _author_bootstrap_assessment(
|
||||||
"allowed": True,
|
not_applicable=False,
|
||||||
"block": False,
|
allowed=True,
|
||||||
"proven": True,
|
block=False,
|
||||||
"bootstrap_path": "clean_canonical_control_checkout",
|
reasons=[],
|
||||||
"reasons": [
|
workspace=workspace,
|
||||||
"control checkout is clean on accepted base branch matching live master"
|
root=root,
|
||||||
],
|
branch=branch or None,
|
||||||
}
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
bootstrap_path="clean_canonical_control_checkout",
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
exact_next_action=(
|
||||||
|
"Call gitea_bootstrap_author_issue_worktree with the allocated "
|
||||||
|
"issue/lease pins; it will create the branches/ worktree and lock."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
|
|||||||
@@ -241,9 +241,14 @@ def bootstrap_permits_control_checkout(
|
|||||||
caller's ordinary block in force.
|
caller's ordinary block in force.
|
||||||
|
|
||||||
``assessment`` is server-derived only: it is produced by
|
``assessment`` is server-derived only: it is produced by
|
||||||
:func:`assess_create_issue_bootstrap` from inspected repository state. It is
|
:func:`assess_create_issue_bootstrap` or
|
||||||
never accepted from an MCP tool argument, so no caller can assert
|
:func:`author_issue_bootstrap.assess_author_issue_bootstrap` from inspected
|
||||||
eligibility it has not proven.
|
repository state. It is never accepted from an MCP tool argument, so no
|
||||||
|
caller can assert eligibility it has not proven.
|
||||||
|
|
||||||
|
#892: author issue worktree bootstrap uses the same predicate with
|
||||||
|
``task_scope='author_issue_bootstrap'`` so a clean control checkout can
|
||||||
|
create the first ``branches/`` worktree without the lock↔worktree cycle.
|
||||||
"""
|
"""
|
||||||
if not isinstance(assessment, dict):
|
if not isinstance(assessment, dict):
|
||||||
return False
|
return False
|
||||||
@@ -264,9 +269,16 @@ def bootstrap_permits_control_checkout(
|
|||||||
if assessment.get("reasons"):
|
if assessment.get("reasons"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Scope proof: only the create_issue bootstrap, only via the clean
|
# Scope proof: create_issue (#749) or author issue bootstrap (#850/#892),
|
||||||
# canonical control checkout path.
|
# only via the clean canonical control checkout path.
|
||||||
if assessment.get("task_scope") != "create_issue_only":
|
task_scope = assessment.get("task_scope")
|
||||||
|
if is_create_issue_task(task):
|
||||||
|
if task_scope != "create_issue_only":
|
||||||
|
return False
|
||||||
|
elif author_issue_bootstrap.is_author_issue_bootstrap_task(task):
|
||||||
|
if task_scope != "author_issue_bootstrap":
|
||||||
|
return False
|
||||||
|
else:
|
||||||
return False
|
return False
|
||||||
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
|
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Regression: author worktree bootstrap from clean control checkout (#892).
|
||||||
|
|
||||||
|
#892 is the four-door deadlock where every documented recovery path is closed:
|
||||||
|
bootstrap refuses control, lock demands an existing worktree, worktree-start
|
||||||
|
demands a lock, and shell worktree add is outside the sanctioned MCP path.
|
||||||
|
|
||||||
|
Root cause: assess_author_issue_bootstrap returned allowed/proven for a clean
|
||||||
|
control checkout, but bootstrap_permits_control_checkout only accepted
|
||||||
|
create_issue assessments (task_scope=create_issue_only + empty reasons + full
|
||||||
|
base-tip field set). Author assessments never satisfied the shared predicate,
|
||||||
|
so the #274/#604 guards kept the ordinary control-checkout block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import author_issue_bootstrap as aib
|
||||||
|
import create_issue_bootstrap as cib
|
||||||
|
|
||||||
|
|
||||||
|
CONTROL = "/repo/Gitea-Tools"
|
||||||
|
MASTER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
OTHER = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
|
||||||
|
|
||||||
|
def _assess(
|
||||||
|
*,
|
||||||
|
workspace=CONTROL,
|
||||||
|
root=CONTROL,
|
||||||
|
branch="master",
|
||||||
|
head=MASTER,
|
||||||
|
porcelain="",
|
||||||
|
remote=MASTER,
|
||||||
|
remote_error=None,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
):
|
||||||
|
return aib.assess_author_issue_bootstrap(
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=root,
|
||||||
|
current_branch=branch,
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status=porcelain,
|
||||||
|
remote_master_sha=remote,
|
||||||
|
remote_master_sha_error=remote_error,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorBootstrapAssessmentShape(unittest.TestCase):
|
||||||
|
def test_clean_control_emits_predicate_compatible_fields(self):
|
||||||
|
assessment = _assess()
|
||||||
|
self.assertTrue(assessment["allowed"])
|
||||||
|
self.assertTrue(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
self.assertFalse(assessment["not_applicable"])
|
||||||
|
self.assertEqual(assessment["reasons"], [])
|
||||||
|
self.assertEqual(assessment["task_scope"], "author_issue_bootstrap")
|
||||||
|
self.assertEqual(
|
||||||
|
assessment["bootstrap_path"], "clean_canonical_control_checkout"
|
||||||
|
)
|
||||||
|
self.assertEqual(assessment["dirty_files"], [])
|
||||||
|
self.assertIs(assessment["under_branches"], False)
|
||||||
|
self.assertTrue(assessment["base_tips_verified"])
|
||||||
|
self.assertEqual(assessment["local_head_sha"], MASTER)
|
||||||
|
self.assertEqual(assessment["remote_master_sha"], MASTER)
|
||||||
|
self.assertEqual(assessment["workspace_path"], os.path.realpath(CONTROL))
|
||||||
|
self.assertEqual(
|
||||||
|
assessment["canonical_repo_root"], os.path.realpath(CONTROL)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_task_not_applicable(self):
|
||||||
|
assessment = _assess(task="lock_issue")
|
||||||
|
self.assertTrue(assessment["not_applicable"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
|
||||||
|
def test_branches_worktree_not_applicable_for_control_waiver(self):
|
||||||
|
branches = os.path.join(CONTROL, "branches", "fix-issue-1")
|
||||||
|
assessment = _assess(workspace=branches)
|
||||||
|
self.assertTrue(assessment["not_applicable"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertEqual(assessment["bootstrap_path"], "existing_branches_worktree")
|
||||||
|
|
||||||
|
def test_dirty_control_blocks(self):
|
||||||
|
assessment = _assess(porcelain=" M gitea_mcp_server.py\n")
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertTrue(any("tracked local edits" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_head_remote_mismatch_blocks(self):
|
||||||
|
assessment = _assess(head=MASTER, remote=OTHER)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
|
||||||
|
def test_missing_remote_tip_blocks(self):
|
||||||
|
assessment = _assess(remote=None)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorBootstrapPredicate(unittest.TestCase):
|
||||||
|
def _permits(self, assessment, task="bootstrap_author_issue_worktree"):
|
||||||
|
return cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task=task,
|
||||||
|
workspace_path=os.path.realpath(CONTROL),
|
||||||
|
canonical_repo_root=os.path.realpath(CONTROL),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clean_author_bootstrap_permits(self):
|
||||||
|
self.assertTrue(self._permits(_assess()))
|
||||||
|
|
||||||
|
def test_tool_alias_permits(self):
|
||||||
|
assessment = _assess(task="gitea_bootstrap_author_issue_worktree")
|
||||||
|
self.assertTrue(
|
||||||
|
self._permits(assessment, task="gitea_bootstrap_author_issue_worktree")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_issue_scope_cannot_license_author_bootstrap(self):
|
||||||
|
# Cross-scope smuggling: a create_issue-shaped assessment must not
|
||||||
|
# authorize the author bootstrap task.
|
||||||
|
create_shaped = dict(_assess())
|
||||||
|
create_shaped["task_scope"] = "create_issue_only"
|
||||||
|
self.assertFalse(self._permits(create_shaped))
|
||||||
|
|
||||||
|
def test_author_scope_cannot_license_create_issue(self):
|
||||||
|
assessment = _assess()
|
||||||
|
self.assertFalse(
|
||||||
|
cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="create_issue",
|
||||||
|
workspace_path=os.path.realpath(CONTROL),
|
||||||
|
canonical_repo_root=os.path.realpath(CONTROL),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nonempty_reasons_fail_closed(self):
|
||||||
|
bad = dict(_assess(), reasons=["informational text must not be here"])
|
||||||
|
self.assertFalse(self._permits(bad))
|
||||||
|
|
||||||
|
def test_dirty_fails_closed(self):
|
||||||
|
self.assertFalse(self._permits(_assess(porcelain=" M x.py\n")))
|
||||||
|
|
||||||
|
def test_mismatch_fails_closed(self):
|
||||||
|
self.assertFalse(self._permits(_assess(remote=OTHER)))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorBootstrapPreflightIntegration(unittest.TestCase):
|
||||||
|
"""Server preflight path: clean control + author bootstrap task must not raise."""
|
||||||
|
|
||||||
|
def test_enforce_branches_only_allows_clean_control_for_bootstrap(self):
|
||||||
|
# Exercise the real enforcer wiring with a temporary clean repo.
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = os.path.join(tmp, "repo")
|
||||||
|
os.makedirs(os.path.join(repo, "branches"))
|
||||||
|
# Minimal git repo on master at a known tip.
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.check_call(["git", "init", "-b", "master", repo])
|
||||||
|
subprocess.check_call(
|
||||||
|
["git", "-C", repo, "commit", "--allow-empty", "-m", "init"]
|
||||||
|
)
|
||||||
|
head = subprocess.check_output(
|
||||||
|
["git", "-C", repo, "rev-parse", "HEAD"], text=True
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
assessment = aib.assess_author_issue_bootstrap(
|
||||||
|
workspace_path=repo,
|
||||||
|
canonical_repo_root=repo,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=head,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
workspace_path=repo,
|
||||||
|
canonical_repo_root=repo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate what _enforce_branches_only_author_mutation does when
|
||||||
|
# durable resolution blocks control: the shared predicate must waive.
|
||||||
|
durable_block = {
|
||||||
|
"block": True,
|
||||||
|
"workspace_path": repo,
|
||||||
|
"workspace_binding_source": "process_project_root",
|
||||||
|
"reasons": [
|
||||||
|
"author mutation blocked: workspace is the stable control checkout"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
workspace_path=repo,
|
||||||
|
canonical_repo_root=repo,
|
||||||
|
):
|
||||||
|
waived = True
|
||||||
|
else:
|
||||||
|
waived = False
|
||||||
|
self.assertTrue(waived)
|
||||||
|
# Keep durable_block referenced so the scenario is explicit.
|
||||||
|
self.assertTrue(durable_block["block"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""Concurrent-session MCP restart safety & dogfooding test suite (#666).
|
||||||
|
|
||||||
|
Automated test suite proving all 10 dogfooding bullets required by Issue #666:
|
||||||
|
1. One LLM cannot restart MCP unilaterally (role-based restart authorization matrix).
|
||||||
|
2. New work stops during drain (assignments_stopped gate enforcement).
|
||||||
|
3. Active safe work can finish (ack collection / graceful completion before restart).
|
||||||
|
4. Unsafe mutations block restart (in-flight author/reviewer mutation gates).
|
||||||
|
5. Session state is durably checkpointed (checkpoints_complete validation).
|
||||||
|
6. Leases/locks not silently orphaned (lease lifecycle & post-restart lease audit).
|
||||||
|
7. Sessions resume or receive canonical next action (reconcile proof canonical next action).
|
||||||
|
8. Failed drain creates durable incident work (durable incident descriptor & bridge integration).
|
||||||
|
9. Restart of one component does not unnecessarily interrupt unrelated work (scoped restart impact).
|
||||||
|
10. Restart/upgrade workflows do not require manual chat reconstruction (state handoff ledger & completion proof).
|
||||||
|
|
||||||
|
Links parent #655, vision #652, roadmap #653, #658, #659, #660, #661, #662, #663.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import drain_proof as dp
|
||||||
|
import mcp_restart_paths as rp
|
||||||
|
import post_restart_reconcile as prr
|
||||||
|
import restart_coordinator as rc
|
||||||
|
from restart_coordinator import RestartClass
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
SECRET = b"test-secret-dogfooding-issue-666-0123456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _live_pid() -> int:
|
||||||
|
return os.getpid()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_drain_state() -> dict:
|
||||||
|
return {
|
||||||
|
"assignments_stopped": True,
|
||||||
|
"checkpoints_complete": True,
|
||||||
|
"handoffs_verified": True,
|
||||||
|
"leases_handled": True,
|
||||||
|
"acks": {},
|
||||||
|
"ack_timeout_policy_applied": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_inventory() -> dict:
|
||||||
|
return {
|
||||||
|
"service_health": {"healthy": True},
|
||||||
|
"clients": [],
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checkpoints": [],
|
||||||
|
"leases": [],
|
||||||
|
"capabilities": {},
|
||||||
|
"worktree_bindings": [],
|
||||||
|
"pending_mutations": [],
|
||||||
|
"inventory_complete": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet1UnilateralRestartForbidden(unittest.TestCase):
|
||||||
|
"""Bullet 1: One LLM cannot restart MCP unilaterally."""
|
||||||
|
|
||||||
|
def test_worker_role_unilateral_full_restart_denied(self):
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.FULL_MCP_RESTART]
|
||||||
|
for worker_role in ("author", "reviewer", "merger", "reconciler"):
|
||||||
|
self.assertNotIn(
|
||||||
|
worker_role,
|
||||||
|
policy.request_roles,
|
||||||
|
f"Worker role '{worker_role}' must not unilaterally authorize FULL_MCP_RESTART",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_privileged_role_full_restart_authorized(self):
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.FULL_MCP_RESTART]
|
||||||
|
for priv_role in ("controller", "operator", "admin"):
|
||||||
|
self.assertIn(
|
||||||
|
priv_role,
|
||||||
|
policy.request_roles,
|
||||||
|
f"Privileged role '{priv_role}' must be authorized for FULL_MCP_RESTART",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_evaluate_impact_records_unauthorized_worker_request(self):
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
restart_class=RestartClass.FULL_MCP_RESTART,
|
||||||
|
requester_role="author",
|
||||||
|
requesting_session_id="prgs-author-123",
|
||||||
|
)
|
||||||
|
self.assertFalse(report.role_authorized)
|
||||||
|
self.assertEqual(report.verdict, rc.VERDICT_UNSAFE)
|
||||||
|
self.assertTrue(any("may not request" in r.lower() or "authorization denied" in r.lower() for r in report.reasons))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet2NewWorkStopsDuringDrain(unittest.TestCase):
|
||||||
|
"""Bullet 2: New work stops during drain."""
|
||||||
|
|
||||||
|
def test_assignments_stopped_false_blocks_drain_proof(self):
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["assignments_stopped"] = False
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_ASSIGNMENTS_STOPPED)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_DENY)
|
||||||
|
self.assertFalse(gate.allow)
|
||||||
|
self.assertTrue(any("drain proof invalid" in r.lower() or "assignments_stopped" in r.lower() for r in gate.reasons))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet3ActiveSafeWorkCanFinish(unittest.TestCase):
|
||||||
|
"""Bullet 3: Active safe work can finish."""
|
||||||
|
|
||||||
|
def test_active_safe_sessions_ack_allows_clean_drain(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-reviewer-42",
|
||||||
|
"role": "reviewer",
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
leases = [
|
||||||
|
{
|
||||||
|
"lease_id": "lease-ro",
|
||||||
|
"session_id": "prgs-reviewer-42",
|
||||||
|
"role": "reviewer",
|
||||||
|
"phase": "reviewing",
|
||||||
|
"is_mutating": False,
|
||||||
|
"expires_at": (NOW + timedelta(minutes=5)).isoformat(),
|
||||||
|
"pid": _live_pid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": leases, "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["acks"] = {"prgs-reviewer-42": "ack"}
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(proof.clean)
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertTrue(gate.allow)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_ALLOW)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet4UnsafeMutationsBlockRestart(unittest.TestCase):
|
||||||
|
"""Bullet 4: Unsafe mutations block restart."""
|
||||||
|
|
||||||
|
def test_inflight_unsafe_mutation_yields_unsafe_verdict(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-99",
|
||||||
|
"role": "author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
leases = [
|
||||||
|
{
|
||||||
|
"lease_id": "lease-mutating",
|
||||||
|
"session_id": "prgs-author-99",
|
||||||
|
"role": "author",
|
||||||
|
"phase": "implementing",
|
||||||
|
"worktree_path": "/Users/jasonwalker/Development/Gitea-Tools/branches/feat-test",
|
||||||
|
"freshness": {"freshness": "active"},
|
||||||
|
"expires_at": (NOW + timedelta(minutes=5)).isoformat(),
|
||||||
|
"pid": _live_pid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": leases, "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report.verdict, rc.VERDICT_UNSAFE)
|
||||||
|
self.assertFalse(report.allow_restart)
|
||||||
|
self.assertGreater(len(report.mutations), 0)
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=report.as_dict(),
|
||||||
|
drain_state=_clean_drain_state(),
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_NO_INFLIGHT_MUTATIONS)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_DENY)
|
||||||
|
self.assertFalse(gate.allow)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet5DurableSessionCheckpoints(unittest.TestCase):
|
||||||
|
"""Bullet 5: Session state is durably checkpointed."""
|
||||||
|
|
||||||
|
def test_incomplete_checkpoints_blocks_drain_proof(self):
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["checkpoints_complete"] = False
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_CHECKPOINTS_COMPLETE)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
def test_post_restart_reconcile_audits_checkpoint_dimension(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
inv["checkpoints_available"] = True
|
||||||
|
inv["checkpoints"] = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-99",
|
||||||
|
"checkpoint_id": "chk-1",
|
||||||
|
"stale": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_ENFORCE)
|
||||||
|
chk_item = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||||
|
self.assertIn(chk_item.status, (prr.ITEM_UNRESOLVED, prr.ITEM_DEGRADED, prr.ITEM_SKIPPED))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet6LeasesNotSilentlyOrphaned(unittest.TestCase):
|
||||||
|
"""Bullet 6: Leases/locks not silently orphaned."""
|
||||||
|
|
||||||
|
def test_unhandled_leases_block_drain_proof(self):
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["leases_handled"] = False
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_LEASES_HANDLED)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
def test_post_restart_reconcile_audits_all_leases(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
inv["leases"] = [
|
||||||
|
{
|
||||||
|
"lease_id": "lease-orphaned-1",
|
||||||
|
"session_id": "prgs-author-dead",
|
||||||
|
"role": "author",
|
||||||
|
"status": "active",
|
||||||
|
"freshness": "expired",
|
||||||
|
"expires_at": (NOW - timedelta(minutes=10)).isoformat(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_LOG_ONLY)
|
||||||
|
lease_item = next(i for i in proof.items if i.dimension == prr.DIM_LEASES)
|
||||||
|
self.assertIsNotNone(lease_item)
|
||||||
|
self.assertTrue(lease_item.summary)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet7SessionsResumeOrReceiveNextAction(unittest.TestCase):
|
||||||
|
"""Bullet 7: Sessions resume or receive canonical next action."""
|
||||||
|
|
||||||
|
def test_reconcile_provides_canonical_next_action_for_unresolved(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
inv["pending_mutations"] = [
|
||||||
|
{
|
||||||
|
"mutation_id": "mut-404",
|
||||||
|
"session_id": "prgs-author-77",
|
||||||
|
"phase": "implementing",
|
||||||
|
"issue_number": 666,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_ENFORCE)
|
||||||
|
self.assertEqual(proof.overall_status, prr.STATUS_DEGRADED)
|
||||||
|
self.assertTrue(proof.mutation_hold)
|
||||||
|
self.assertTrue(proof.note)
|
||||||
|
self.assertGreater(len(proof.proposed_follow_ups), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet8FailedDrainCreatesIncidentWork(unittest.TestCase):
|
||||||
|
"""Bullet 8: Failed drain creates durable incident work."""
|
||||||
|
|
||||||
|
def test_denied_drain_gate_mints_durable_incident_descriptor(self):
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["assignments_stopped"] = False
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_DENY)
|
||||||
|
|
||||||
|
incident = gate.incident
|
||||||
|
self.assertIsNotNone(incident)
|
||||||
|
self.assertEqual(incident["kind"], "restart_drain_gate_denied")
|
||||||
|
self.assertTrue(any("assignments_stopped" in r for r in incident["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet9ScopedRestartNonInterference(unittest.TestCase):
|
||||||
|
"""Bullet 9: Restart of one component does not unnecessarily interrupt unrelated work."""
|
||||||
|
|
||||||
|
def test_scoped_role_restart_impacts_only_target_role(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-10",
|
||||||
|
"role": "author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-reviewer-20",
|
||||||
|
"role": "reviewer",
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.ROLE_RUNTIME_RESTART]
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
restart_class=RestartClass.ROLE_RUNTIME_RESTART,
|
||||||
|
target_role="reviewer",
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
requester_role="controller",
|
||||||
|
requester_permissions=list(policy.request_roles),
|
||||||
|
controller_approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(report.role_authorized)
|
||||||
|
|
||||||
|
def test_scoped_connector_restart_limits_blast_radius(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-10",
|
||||||
|
"role": "author",
|
||||||
|
"connector": "gitea-author",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-reviewer-20",
|
||||||
|
"role": "reviewer",
|
||||||
|
"connector": "gitea-reviewer",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.CONNECTOR_RESTART]
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
restart_class=RestartClass.CONNECTOR_RESTART,
|
||||||
|
target_connector="gitea-author",
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
requester_role="controller",
|
||||||
|
requester_permissions=list(policy.request_roles),
|
||||||
|
controller_approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(report)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet10NoManualChatReconstruction(unittest.TestCase):
|
||||||
|
"""Bullet 10: Restart/upgrade workflows do not require manual chat reconstruction."""
|
||||||
|
|
||||||
|
def test_end_to_end_restart_reconcile_handoff_proof(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_LOG_ONLY)
|
||||||
|
|
||||||
|
proof_dict = proof.as_dict()
|
||||||
|
self.assertEqual(proof_dict["overall_status"], prr.STATUS_COMPLETE)
|
||||||
|
self.assertFalse(proof_dict["mutation_hold"])
|
||||||
|
self.assertTrue(proof_dict["note"])
|
||||||
|
self.assertIn("links", proof_dict)
|
||||||
|
self.assertEqual(proof_dict["links"]["umbrella"], 655)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user