feat: add Controller Handoff + Thread State Ledger validation (#507)
Introduce thread_state_ledger_validator for the mandatory two-comment workflow: tagged [CONTROLLER HANDOFF] paired with [THREAD STATE LEDGER]. Wire validation into final_report_validator and gitea_create_issue_comment, add runbook docs, worked examples, and tests. Closes #507 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -108,6 +108,28 @@ def _reconcile_handoff(drop=(), **overrides):
|
||||
return text
|
||||
|
||||
|
||||
class TestTwoCommentWorkflowIntegration(unittest.TestCase):
|
||||
def test_tagged_pair_produces_no_two_comment_findings(self):
|
||||
from thread_state_ledger_validator import findings_for_final_report # noqa: E402
|
||||
from tests.test_thread_state_ledger_validator import ( # noqa: E402
|
||||
_minimal_handoff,
|
||||
_minimal_ledger,
|
||||
)
|
||||
|
||||
report = _minimal_handoff() + "\n\n" + _minimal_ledger()
|
||||
self.assertEqual(findings_for_final_report(report), [])
|
||||
|
||||
def test_tagged_handoff_without_ledger_blocks_via_validator(self):
|
||||
from thread_state_ledger_validator import findings_for_final_report # noqa: E402
|
||||
from tests.test_thread_state_ledger_validator import _minimal_handoff # noqa: E402
|
||||
|
||||
findings = findings_for_final_report(_minimal_handoff())
|
||||
self.assertTrue(findings)
|
||||
self.assertTrue(
|
||||
any(f["severity"] == "block" for f in findings)
|
||||
)
|
||||
|
||||
|
||||
class TestValidatorFinding(unittest.TestCase):
|
||||
def test_finding_shape(self):
|
||||
finding = validator_finding(
|
||||
|
||||
@@ -3078,6 +3078,20 @@ class TestIssueCommentTools(unittest.TestCase):
|
||||
self.assertFalse(result["success"])
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_tagged_handoff_missing_mutation_ledger_blocked(
|
||||
self, _auth, mock_api
|
||||
):
|
||||
body = "[CONTROLLER HANDOFF] PR #1 — test\n\nBlockers:\n- none"
|
||||
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
|
||||
result = gitea_create_issue_comment(
|
||||
issue_number=9, body=body, remote="prgs")
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertTrue(result["reasons"])
|
||||
mock_api.assert_not_called()
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_empty_body_blocked(self, _auth, mock_api):
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for two-comment workflow validation (#507)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from thread_state_ledger_validator import ( # noqa: E402
|
||||
BLOCKER_CLASSIFICATIONS,
|
||||
assess_controller_handoff_comment,
|
||||
assess_thread_state_ledger_comment,
|
||||
assess_two_comment_workflow,
|
||||
assess_two_comment_pair,
|
||||
)
|
||||
|
||||
|
||||
def _minimal_handoff(**overrides):
|
||||
lines = [
|
||||
"[CONTROLLER HANDOFF] PR #203 / Issue #182 — review complete",
|
||||
"",
|
||||
"Identity/profile:",
|
||||
"- Active profile: prgs-reviewer",
|
||||
"- Authenticated identity: sysadmin",
|
||||
"",
|
||||
"Server-side mutation ledger:",
|
||||
"- gitea_submit_pr_review → APPROVED review posted to Gitea",
|
||||
"",
|
||||
"Blockers:",
|
||||
"- none",
|
||||
]
|
||||
text = "\n".join(lines)
|
||||
for key, value in overrides.items():
|
||||
if f"{key}:" in text:
|
||||
text = text.replace(f"{key}:", f"{key}: {value}", 1)
|
||||
return text
|
||||
|
||||
|
||||
def _minimal_ledger(**overrides):
|
||||
lines = [
|
||||
"[THREAD STATE LEDGER] PR #203 — APPROVED review posted to Gitea",
|
||||
"",
|
||||
"What is true now:",
|
||||
"- PR state: open",
|
||||
"- Current head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Server-side decision state: APPROVED review posted to Gitea",
|
||||
"- Local verdict/state: APPROVE verdict prepared locally",
|
||||
"- Latest known validation: tests passed locally",
|
||||
"",
|
||||
"What changed:",
|
||||
"- APPROVED review posted to Gitea at pinned head",
|
||||
"",
|
||||
"What is blocked:",
|
||||
"- Blocker classification: no blocker",
|
||||
"",
|
||||
"Who/what acts next:",
|
||||
"- Next actor: merger",
|
||||
"- Required action: merge on explicit operator command",
|
||||
"- Do not do: re-post APPROVE",
|
||||
"- Resume from: PR #203 review feedback",
|
||||
]
|
||||
text = "\n".join(lines)
|
||||
for key, value in overrides.items():
|
||||
text = text.replace(f"- {key}:", f"- {key}: {value}")
|
||||
return text
|
||||
|
||||
|
||||
class TestTwoCommentWorkflow(unittest.TestCase):
|
||||
def test_valid_combined_report_passes(self):
|
||||
report = _minimal_handoff() + "\n\n" + _minimal_ledger()
|
||||
result = assess_two_comment_workflow(report)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_missing_ledger_after_tagged_handoff_blocks(self):
|
||||
result = assess_two_comment_workflow(_minimal_handoff())
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(
|
||||
any("THREAD STATE LEDGER" in r for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_legacy_controller_handoff_section_not_required_ledger(self):
|
||||
legacy = "## Controller Handoff\n\n- Task: work issue\n- Next: continue\n"
|
||||
result = assess_two_comment_workflow(legacy)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_ambiguous_approved_blocks(self):
|
||||
handoff = _minimal_handoff().replace(
|
||||
"APPROVED review posted to Gitea",
|
||||
"approved",
|
||||
)
|
||||
report = handoff + "\n\n" + _minimal_ledger()
|
||||
result = assess_two_comment_workflow(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("approved" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_ambiguous_merged_blocks(self):
|
||||
handoff = _minimal_handoff() + "\n\n- Narrative: merged successfully"
|
||||
ledger = _minimal_ledger()
|
||||
report = handoff + "\n\n" + ledger
|
||||
result = assess_two_comment_workflow(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("merge" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_blocked_without_gate_blocks(self):
|
||||
handoff = _minimal_handoff().replace("- none", "- blocked")
|
||||
ledger = _minimal_ledger().replace(
|
||||
"no blocker", "blocked but no gate named"
|
||||
)
|
||||
report = handoff + "\n\n" + ledger
|
||||
result = assess_two_comment_workflow(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_mutation_ledger_contradiction_blocks(self):
|
||||
handoff = _minimal_handoff().replace(
|
||||
"APPROVED review posted to Gitea",
|
||||
"none — no server-side state changed",
|
||||
)
|
||||
handoff += "\n- Narrative: APPROVED review posted to Gitea"
|
||||
report = handoff + "\n\n" + _minimal_ledger()
|
||||
result = assess_two_comment_workflow(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("contradict" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_local_verdict_as_server_side_blocks(self):
|
||||
ledger = _minimal_ledger(
|
||||
**{
|
||||
"Server-side decision state": "APPROVE verdict prepared locally",
|
||||
"Local verdict/state": "none",
|
||||
}
|
||||
)
|
||||
report = _minimal_handoff() + "\n\n" + ledger
|
||||
result = assess_two_comment_workflow(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestHandoffComment(unittest.TestCase):
|
||||
def test_handoff_comment_valid(self):
|
||||
result = assess_controller_handoff_comment(_minimal_handoff())
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_handoff_without_marker_skips(self):
|
||||
result = assess_controller_handoff_comment("casual comment")
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["performed"])
|
||||
|
||||
|
||||
class TestLedgerComment(unittest.TestCase):
|
||||
def test_ledger_requires_blocker_classification(self):
|
||||
ledger = _minimal_ledger().replace(
|
||||
"Blocker classification: no blocker",
|
||||
"something unclear",
|
||||
)
|
||||
result = assess_thread_state_ledger_comment(ledger)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_all_blocker_classifications_recognized(self):
|
||||
self.assertIn("no blocker", BLOCKER_CLASSIFICATIONS)
|
||||
self.assertIn("environment/tooling blocker", BLOCKER_CLASSIFICATIONS)
|
||||
|
||||
|
||||
class TestPairedComments(unittest.TestCase):
|
||||
def test_pair_passes(self):
|
||||
result = assess_two_comment_pair(_minimal_handoff(), _minimal_ledger())
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_pair_missing_ledger_blocks(self):
|
||||
result = assess_two_comment_pair(_minimal_handoff(), "")
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestExamples(unittest.TestCase):
|
||||
"""Smoke-test bundled examples from docs."""
|
||||
|
||||
def test_examples_module_loads(self):
|
||||
from thread_state_ledger_examples import EXAMPLES # noqa: E402
|
||||
|
||||
self.assertGreaterEqual(len(EXAMPLES), 8)
|
||||
for name, handoff, ledger in EXAMPLES:
|
||||
result = assess_two_comment_pair(handoff, ledger)
|
||||
self.assertTrue(
|
||||
result["proven"],
|
||||
f"example '{name}' failed: {result['reasons']}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user