atlas-iac/dockerfiles/hermes-kanban-blocked-regression.py

250 lines
10 KiB
Python
Raw Normal View History

hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
"""Regression tests for Hermes Kanban blocked-task scheduling semantics."""
from __future__ import annotations
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
import json
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
import os
import tempfile
import unittest
from pathlib import Path
_HERMES_HOME = tempfile.TemporaryDirectory(prefix="hermes-kanban-test-home-")
os.environ["HERMES_HOME"] = _HERMES_HOME.name
from hermes_cli import kanban_db # noqa: E402
class BlockedTaskSchedulingTests(unittest.TestCase):
"""Exercise the real upstream database API against isolated databases."""
def setUp(self) -> None:
self._database_dir = tempfile.TemporaryDirectory(
prefix="hermes-kanban-test-db-"
)
self.connection = kanban_db.connect(
Path(self._database_dir.name) / "kanban.db"
)
def tearDown(self) -> None:
self.connection.close()
self._database_dir.cleanup()
def create_task(self, title: str, **kwargs: object) -> str:
return kanban_db.create_task(self.connection, title=title, **kwargs)
def status(self, task_id: str) -> str:
task = kanban_db.get_task(self.connection, task_id)
self.assertIsNotNone(task)
return task.status
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
def created_payload(self, task_id: str) -> dict:
"""Return the decoded payload of the task's ``created`` event."""
row = self.connection.execute(
"SELECT payload FROM task_events "
"WHERE task_id = ? AND kind = 'created' ORDER BY id LIMIT 1",
(task_id,),
).fetchone()
self.assertIsNotNone(row, "create_task emitted no 'created' event")
return json.loads(row["payload"] or "{}")
def set_created_payload(self, task_id: str, payload: object) -> None:
"""Overwrite a ``created`` event payload to simulate producer drift."""
with self.connection:
self.connection.execute(
"UPDATE task_events SET payload = ? "
"WHERE task_id = ? AND kind = 'created'",
(payload, task_id),
)
def block_via_circuit_breaker(self, title: str) -> str:
"""Create + claim a normal task, then trip the breaker once on it.
The resulting task is ``blocked`` with exactly one recorded failure and
-- critically -- no ``blocked`` event, because the breaker emits
``gave_up``. Its most recent created/blocked/unblocked event is
therefore its own ``created`` event, which is what makes it the right
probe for the created-event branch of the sticky gate.
"""
task_id = self.create_task(title)
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
self.assertTrue(
kanban_db._record_spawn_failure(
self.connection,
task_id,
"worker failed",
failure_limit=1,
)
)
self.assertEqual(self.status(task_id), "blocked")
return task_id
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
def test_initial_block_without_parents_is_sticky(self) -> None:
task_id = self.create_task("operator parked", initial_status="blocked")
self.assertEqual(kanban_db.recompute_ready(self.connection), 0)
self.assertEqual(self.status(task_id), "blocked")
def test_initial_block_with_complete_parents_is_sticky(self) -> None:
parent_id = self.create_task("completed prerequisite")
self.assertTrue(kanban_db.complete_task(self.connection, parent_id))
task_id = self.create_task(
"operator parked after prerequisite",
initial_status="blocked",
parents=[parent_id],
)
self.assertEqual(kanban_db.recompute_ready(self.connection), 0)
self.assertEqual(self.status(task_id), "blocked")
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
def test_initial_block_remains_sticky_when_parent_later_completes(self) -> None:
parent_id = self.create_task("incomplete prerequisite")
task_id = self.create_task(
"operator parked before prerequisite completes",
initial_status="blocked",
parents=[parent_id],
)
self.assertEqual(self.status(task_id), "blocked")
self.assertTrue(kanban_db.complete_task(self.connection, parent_id))
self.assertEqual(self.status(task_id), "blocked")
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
def test_initial_block_is_released_by_an_explicit_unblock(self) -> None:
task_id = self.create_task("operator parked", initial_status="blocked")
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
self.assertEqual(self.status(task_id), "ready")
def test_dependency_block_promotes_after_parent_completes(self) -> None:
parent_id = self.create_task("incomplete prerequisite")
task_id = self.create_task("dependency gated", parents=[parent_id])
self.assertEqual(self.status(task_id), "todo")
self.assertTrue(kanban_db.complete_task(self.connection, parent_id))
self.assertEqual(self.status(task_id), "ready")
def test_explicit_block_and_unblock_remain_sticky_and_reversible(self) -> None:
task_id = self.create_task("worker handoff")
self.assertTrue(
kanban_db.block_task(
self.connection,
task_id,
reason="human review required",
)
)
self.assertEqual(kanban_db.recompute_ready(self.connection), 0)
self.assertEqual(self.status(task_id), "blocked")
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
self.assertEqual(self.status(task_id), "ready")
def test_circuit_breaker_block_is_not_auto_promoted(self) -> None:
task_id = self.create_task("repeated worker failure")
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
# Upstream has no public circuit-breaker entry point; exercise the
# dispatcher helper that owns its persisted failure-limit semantics.
self.assertTrue(
kanban_db._record_spawn_failure(
self.connection,
task_id,
"worker failed",
failure_limit=1,
)
)
self.assertEqual(self.status(task_id), "blocked")
self.assertEqual(
kanban_db.recompute_ready(self.connection, failure_limit=1),
0,
)
self.assertEqual(self.status(task_id), "blocked")
hermes: fail closed on kanban created-event producer drift The sticky-block gate added in the previous commit classifies a task from the `created` event payload that upstream `create_task` writes. That producer is code we do not own, so trusting it silently was the gap: if upstream renamed the key, dropped it, or stopped deriving it from `initial_status`, the image would still build and ship a consumer that mis-classifies every task it reads. Anchor the producer contract at build time, before the regression suite runs, with three assert-only preconditions: the `initial_status="blocked"` park resolves `task_status` to `"blocked"`, every non-park creation resolves it to something else, and the `created` event carries that same variable under `"status"`. None of them rewrite the producer. Textual anchors cannot see dataflow, so add the runtime net the reviewer asked for. The suite now drives the real API: create + claim an ordinary task, trip the circuit breaker once at failure_limit=1 so it parks with a `gave_up` event (leaving its own `created` event as the most recent create/block/unblock row), then recompute at failure_limit=2 and require promotion to ready. That case is red under an unconditional-true created predicate and red under producer drift that labels every created event blocked, while the explicit block/unblock, dependency-promotion and circuit-breaker-at-current-limit cases stay green. Non-blocked and malformed created payloads are pinned as controls, and the gate now rejects non-dict payloads rather than trusting `.get`. Also make the live placement correction durable: titan-04 is cordoned after repeated kernel undervoltage and kubelet failure and titan-19 was probe/Longhorn unstable under worker load, so both join the hard NotIn list; titan-05 is healthy but sits at 3592m/3600m requested CPU, so the main hermes container gives back 50m (350m -> 300m) to schedule there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:42:18 +00:00
def test_circuit_breaker_block_is_promoted_below_the_failure_limit(self) -> None:
"""Non-sticky oracle for the created-event branch of the sticky gate.
A circuit-breaker block must still auto-recover once the effective
failure limit rises above the recorded failure count -- that is the
pre-existing upstream contract the sticky-block patch must not widen.
This task's most recent created/blocked/unblocked event is its own
``created`` event carrying ``status="ready"``, so it is exactly the
case that a mis-classifying gate strands. It fails if the gate treats
any ``created`` event as a park (over-broad consumer) and it fails if
``create_task`` drifts to labelling every ``created`` event
``"blocked"`` (over-broad producer), while the
circuit-breaker-at-current-limit case above still holds.
"""
task_id = self.block_via_circuit_breaker("recoverable worker failure")
self.assertFalse(
kanban_db._has_sticky_block(self.connection, task_id),
"a circuit-breaker block must never be classified as a park",
)
self.assertEqual(
kanban_db.recompute_ready(self.connection, failure_limit=2),
1,
)
self.assertEqual(self.status(task_id), "ready")
def test_created_event_carries_the_status_the_sticky_gate_reads(self) -> None:
"""Producer contract asserted directly against the real create_task.
The sticky gate reads ``created`` payload ``status``. Pin both poles of
that contract so producer drift is a test failure here as well as a
build failure at the Dockerfile source anchor.
"""
parked_id = self.create_task("operator parked", initial_status="blocked")
normal_id = self.create_task("ordinary work")
self.assertEqual(self.created_payload(parked_id).get("status"), "blocked")
self.assertEqual(self.created_payload(normal_id).get("status"), "ready")
def test_non_blocked_created_payloads_are_not_treated_as_a_park(self) -> None:
"""Only ``status="blocked"`` parks; every other value must promote."""
for payload_status in ("ready", "todo", "running", "triage"):
with self.subTest(payload_status=payload_status):
task_id = self.block_via_circuit_breaker(
f"breaker trip with {payload_status} created payload"
)
self.set_created_payload(
task_id, json.dumps({"status": payload_status})
)
self.assertFalse(
kanban_db._has_sticky_block(self.connection, task_id)
)
self.assertEqual(
kanban_db.recompute_ready(self.connection, failure_limit=2),
1,
)
self.assertEqual(self.status(task_id), "ready")
def test_malformed_created_payloads_fail_closed_to_not_parked(self) -> None:
"""Undecodable or non-object payloads must not park -- or crash.
``recompute_ready`` walks every blocked task in one pass, so an
exception escaping the gate would stall promotion board-wide. Falling
back to "not parked" preserves upstream's pre-patch auto-recover
default for rows the gate cannot interpret.
"""
malformed = (None, "", " ", "{not json", "[]", "null", '"blocked"', "17")
for payload in malformed:
with self.subTest(payload=payload):
task_id = self.block_via_circuit_breaker(
f"breaker trip with payload {payload!r}"
)
self.set_created_payload(task_id, payload)
self.assertFalse(
kanban_db._has_sticky_block(self.connection, task_id)
)
self.assertEqual(
kanban_db.recompute_ready(self.connection, failure_limit=2),
1,
)
self.assertEqual(self.status(task_id), "ready")
hermes: harden worker isolation and blocked-task semantics Three narrowly scoped Hermes reliability fixes backed by live evidence from the Cassandra/titan-iac proof run. Worker concurrency. Three simultaneous direct CLI workers on the 4-core hermes-agent node drove load to ~45 and made the hermes and oauth2-proxy containers fail their probes, leaving the pod 8/10 Ready; two workers stayed at 10/10. Cap HERMES_CLI_LANE_CONCURRENCY at 2 and lower the cli-lane-runner CPU limit from 3 to 2 so the dashboard and auth sidecars keep a guaranteed share of the node. Requests are unchanged: the pod still asks for 745m total, so placement does not move. Service links. Kubernetes injects a service-link variable pair for every service in the namespace, and hermes-claude-broker produces HERMES_CLAUDE_BROKER_PORT=tcp://10.43.31.76:9006 — a value the broker parses as an int. That contaminated worker and test environments even though the deployment already addresses every service by DNS name. Set enableServiceLinks: false on the hermes-agent pod spec. Blocked-task scheduling. create_task(initial_status="blocked") records a created event carrying status=blocked but never a blocked event, while _has_sticky_block() only inspects blocked/unblocked events. recompute_ready() considers blocked tasks, so an explicitly parked task with no incomplete parent auto-promoted on the next dispatcher cycle. Teach _has_sticky_block() to also recognize a created event whose payload status is blocked, which covers tasks created before this image patch without adding a persisted field. Dependency-driven promotion and the circuit-breaker failure-limit guard are untouched; unblock_task() still releases either kind of block. hermes-kanban-blocked-regression.py runs against the real upstream kanban_db API during the image build, so the build fails if any of these semantics regress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:53:22 +00:00
if __name__ == "__main__":
unittest.main(verbosity=2)