121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""Regress automatic decomposition against existing execution history."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
from hermes_cli import kanban_db
|
|
from hermes_cli import kanban_decompose
|
|
from hermes_execution_regression_support import ExecutionSafetyTestCase
|
|
|
|
|
|
class DecompositionSafetyTests(ExecutionSafetyTestCase):
|
|
"""Verify only unexecuted triage roots may decompose automatically."""
|
|
|
|
def test_automatic_decomposer_skips_executed_triage_task(self) -> None:
|
|
task_id = self._route_executed_task_to_triage()
|
|
completions = self._client(
|
|
{"fanout": False, "title": "must not run", "body": "must not run"}
|
|
)
|
|
before = len(kanban_db.list_tasks(self.connection))
|
|
|
|
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
|
|
|
self.assertFalse(outcome.ok)
|
|
self.assertIn("execution history", outcome.reason)
|
|
self.assertEqual(completions.calls, 0)
|
|
self.assertEqual(self._status(task_id), "triage")
|
|
self.assertEqual(len(kanban_db.list_tasks(self.connection)), before)
|
|
|
|
def test_manual_decomposition_remains_available_after_execution(self) -> None:
|
|
task_id = self._route_executed_task_to_triage()
|
|
completions = self._client(
|
|
{
|
|
"fanout": False,
|
|
"title": "operator-approved retry",
|
|
"body": "Retain one bounded task.",
|
|
"assignee": "default",
|
|
}
|
|
)
|
|
|
|
outcome = kanban_decompose.decompose_task(task_id, author="operator")
|
|
|
|
self.assertTrue(outcome.ok)
|
|
self.assertFalse(outcome.fanout)
|
|
self.assertEqual(completions.calls, 1)
|
|
self.assertEqual(self._status(task_id), "ready")
|
|
|
|
def test_fresh_triage_task_still_auto_promotes(self) -> None:
|
|
task_id = kanban_db.create_task(
|
|
self.connection,
|
|
title="fresh objective",
|
|
triage=True,
|
|
)
|
|
completions = self._client(
|
|
{
|
|
"fanout": False,
|
|
"title": "specified objective",
|
|
"body": "One bounded task.",
|
|
"assignee": "default",
|
|
}
|
|
)
|
|
|
|
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
|
|
|
self.assertTrue(outcome.ok)
|
|
self.assertEqual(completions.calls, 1)
|
|
self.assertEqual(self._status(task_id), "ready")
|
|
|
|
def test_execution_history_gained_during_single_llm_call_blocks_commit(self) -> None:
|
|
task_id = kanban_db.create_task(
|
|
self.connection,
|
|
title="concurrent single objective",
|
|
triage=True,
|
|
)
|
|
completions = self._client(
|
|
{
|
|
"fanout": False,
|
|
"title": "redundant rewrite",
|
|
"body": "must not be committed",
|
|
"assignee": "default",
|
|
},
|
|
before_response=lambda: self._insert_run(task_id),
|
|
)
|
|
|
|
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
|
|
|
self.assertFalse(outcome.ok)
|
|
self.assertIn("gained execution history", outcome.reason)
|
|
self.assertEqual(completions.calls, 1)
|
|
self.assertEqual(self._status(task_id), "triage")
|
|
|
|
def test_execution_history_gained_during_fanout_llm_call_blocks_commit(self) -> None:
|
|
task_id = kanban_db.create_task(
|
|
self.connection,
|
|
title="concurrent triage objective",
|
|
triage=True,
|
|
)
|
|
|
|
completions = self._client(
|
|
{
|
|
"fanout": True,
|
|
"tasks": [
|
|
{
|
|
"title": "redundant child",
|
|
"body": "must not be created",
|
|
"assignee": "default",
|
|
"parents": [],
|
|
}
|
|
],
|
|
},
|
|
before_response=lambda: self._insert_run(task_id),
|
|
)
|
|
before = len(kanban_db.list_tasks(self.connection))
|
|
|
|
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
|
|
|
self.assertFalse(outcome.ok)
|
|
self.assertIn("gained execution history", outcome.reason)
|
|
self.assertEqual(completions.calls, 1)
|
|
self.assertEqual(self._status(task_id), "triage")
|
|
self.assertEqual(len(kanban_db.list_tasks(self.connection)), before)
|