feat: complete reliable coordination adapter
Some checks failed
tamq-ci / test (push) Failing after 5s
Some checks failed
tamq-ci / test (push) Failing after 5s
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
25113f463e
commit
6d2ccc7760
30 changed files with 2553 additions and 144 deletions
|
|
@ -47,6 +47,15 @@ def test_broker_collects_worker_followup_lines_until_empty(tmp_path, monkeypatch
|
|||
assert row["body"] == (
|
||||
"Review this\nContext: AUTH-WP-4.\nReply with accepted or blocked."
|
||||
)
|
||||
events = store.protocol_events()
|
||||
assert [event["event_type"] for event in events] == [
|
||||
"worker.block_started",
|
||||
"message.accepted",
|
||||
"worker.block_closed",
|
||||
]
|
||||
assert events[-1]["message_id"] == row["message_id"]
|
||||
assert events[-1]["outcome"] == "accepted"
|
||||
assert events[-1]["detail"] == '{"line_count":3,"reason":"empty_line"}'
|
||||
|
||||
|
||||
def test_new_worker_address_flushes_previous_block(tmp_path, monkeypatch):
|
||||
|
|
@ -81,6 +90,10 @@ def test_worker_cmd_is_inert_but_operator_cmd_changes_mode(tmp_path):
|
|||
broker.inspect_operator_line("cMD: MODE=TRIGGER")
|
||||
assert store.endpoint("ep")["delivery_mode"] == "trigger"
|
||||
assert notices[-1] == "From:tamq: Mode set to trigger."
|
||||
command_events = store.protocol_events(event_type="command.applied")
|
||||
assert len(command_events) == 1
|
||||
assert command_events[0]["delivery_mode"] == "trigger"
|
||||
assert command_events[0]["provenance"] == "operator_input"
|
||||
|
||||
|
||||
def test_operator_command_names_are_case_insensitive(tmp_path):
|
||||
|
|
|
|||
56
tests/test_capture.py
Normal file
56
tests/test_capture.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import json
|
||||
|
||||
from tamq.capture import render_jsonl, render_markdown
|
||||
from tamq.cli import main
|
||||
from tamq.store import Store
|
||||
|
||||
|
||||
def test_capture_renderers_expose_protocol_but_not_duplicate_message_bodies(tmp_path):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
message_id = store.add(
|
||||
"source", "target", "Please review.\nReply accepted.", provenance="worker_output"
|
||||
)
|
||||
store.record_protocol_event(
|
||||
"delivery.attempted",
|
||||
repo="target",
|
||||
peer_repo="source",
|
||||
message_id=message_id,
|
||||
provenance="worker_output",
|
||||
delivery_mode="trigger",
|
||||
outcome="attempted",
|
||||
)
|
||||
rows = store.protocol_events(repo="source")
|
||||
|
||||
markdown = render_markdown(rows, scope="repository `source`")
|
||||
assert "# TAMQ protocol capture" in markdown
|
||||
assert "Accepted messages: 1" in markdown
|
||||
assert "## Review cues" in markdown
|
||||
assert markdown.count("Please review.") == 1
|
||||
assert "does not contain unrelated pane output" in markdown
|
||||
|
||||
records = [json.loads(line) for line in render_jsonl(rows).splitlines()]
|
||||
assert records[0]["message_body"] == "Please review.\nReply accepted."
|
||||
assert "message_body" not in records[1]
|
||||
store.close()
|
||||
|
||||
|
||||
def test_capture_cli_filters_and_writes_review_file(tmp_path, monkeypatch, capsys):
|
||||
database = tmp_path / "queue.sqlite3"
|
||||
monkeypatch.setattr("tamq.cli.db_path", lambda: database)
|
||||
store = Store(database)
|
||||
store.add("one", "two", "hello", provenance="operator_input")
|
||||
store.add("three", "four", "ignore", provenance="worker_output")
|
||||
store.close()
|
||||
output = tmp_path / "review" / "protocol.md"
|
||||
|
||||
assert main(["capture", "--repo", "one", "--output", str(output)]) == 0
|
||||
assert "Wrote 1 protocol event" in capsys.readouterr().out
|
||||
report = output.read_text()
|
||||
assert "hello" in report
|
||||
assert "ignore" not in report
|
||||
|
||||
|
||||
def test_capture_cli_rejects_non_positive_limit(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setattr("tamq.cli.db_path", lambda: tmp_path / "queue.sqlite3")
|
||||
assert main(["capture", "--limit", "0"]) == 2
|
||||
assert "capture limit must be a positive integer" in capsys.readouterr().err
|
||||
|
|
@ -5,7 +5,10 @@ from tamq.cli import (
|
|||
parse_size,
|
||||
)
|
||||
from tamq.service import (
|
||||
DELIVERY_RELIABILITY_CAPABILITY,
|
||||
IDEMPOTENT_SEND_CAPABILITY,
|
||||
LINE_LIMITS_CAPABILITY,
|
||||
PROTOCOL_CAPTURE_CAPABILITY,
|
||||
PUSHY_FRAMING_CAPABILITY,
|
||||
TRIGGER_CAPABILITY,
|
||||
)
|
||||
|
|
@ -17,7 +20,7 @@ def test_parse_size():
|
|||
|
||||
|
||||
def test_manual_service_restarts_legacy_broker(monkeypatch):
|
||||
capabilities = iter([[], ["manual_delivery", PUSHY_FRAMING_CAPABILITY, LINE_LIMITS_CAPABILITY]])
|
||||
capabilities = iter([[], ["manual_delivery", PUSHY_FRAMING_CAPABILITY, LINE_LIMITS_CAPABILITY, PROTOCOL_CAPTURE_CAPABILITY, DELIVERY_RELIABILITY_CAPABILITY, IDEMPOTENT_SEND_CAPABILITY]])
|
||||
starts = []
|
||||
stops = []
|
||||
|
||||
|
|
@ -37,7 +40,7 @@ def test_manual_service_restarts_legacy_broker(monkeypatch):
|
|||
def test_output_service_requires_terminal_output_capability(monkeypatch):
|
||||
capabilities = iter([
|
||||
["manual_delivery"],
|
||||
["manual_delivery", "terminal_output", PUSHY_FRAMING_CAPABILITY, LINE_LIMITS_CAPABILITY],
|
||||
["manual_delivery", "terminal_output", PUSHY_FRAMING_CAPABILITY, LINE_LIMITS_CAPABILITY, PROTOCOL_CAPTURE_CAPABILITY, DELIVERY_RELIABILITY_CAPABILITY, IDEMPOTENT_SEND_CAPABILITY],
|
||||
])
|
||||
starts = []
|
||||
stops = []
|
||||
|
|
@ -64,6 +67,9 @@ def test_pushy_service_requires_pushy_input_capability(monkeypatch):
|
|||
PUSHY_FRAMING_CAPABILITY,
|
||||
TRIGGER_CAPABILITY,
|
||||
LINE_LIMITS_CAPABILITY,
|
||||
PROTOCOL_CAPTURE_CAPABILITY,
|
||||
DELIVERY_RELIABILITY_CAPABILITY,
|
||||
IDEMPOTENT_SEND_CAPABILITY,
|
||||
],
|
||||
])
|
||||
starts = []
|
||||
|
|
|
|||
202
tests/test_client_adapter.py
Normal file
202
tests/test_client_adapter.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tamq.client import (
|
||||
CoordinationEngineAdapter,
|
||||
TamqClient,
|
||||
TamqClientError,
|
||||
TamqProtocolError,
|
||||
TamqTargetUnavailable,
|
||||
WakeRequest,
|
||||
)
|
||||
from tamq.service import Service
|
||||
from tamq.store import Store
|
||||
|
||||
|
||||
async def _start_service(path, store):
|
||||
service = Service(path, store)
|
||||
server = await asyncio.start_unix_server(service.handle, path=str(path))
|
||||
return service, server
|
||||
|
||||
|
||||
def test_coordination_adapter_negotiates_and_deduplicates_wake(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("tamq.service.validate_targets", lambda repos: None)
|
||||
|
||||
async def run():
|
||||
path = tmp_path / "tamq.sock"
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint(
|
||||
"ep-1", 42, "tamq", ["target"], "manual",
|
||||
delivery_ack_mode="acknowledged",
|
||||
)
|
||||
service, server = await _start_service(path, store)
|
||||
adapter = CoordinationEngineAdapter(TamqClient(path))
|
||||
wake = WakeRequest(
|
||||
lease_id="lease-7",
|
||||
trigger_id="trigger-9",
|
||||
target_repo="target",
|
||||
prompt="Continue task T1.",
|
||||
)
|
||||
try:
|
||||
first = await adapter.wake(wake)
|
||||
repeated = await adapter.wake(wake)
|
||||
assert repeated.message_id == first.message_id
|
||||
assert first.deduplicated is False
|
||||
assert repeated.deduplicated is True
|
||||
row = await adapter.receipt(first.message_id)
|
||||
assert row["client_id"] == "coordination-engine"
|
||||
assert row["idempotency_key"] == "lease-7"
|
||||
assert row["correlation_id"] == "trigger-9"
|
||||
assert json.loads(row["envelope_metadata"])["trigger_id"] == "trigger-9"
|
||||
assert len(store.list()) == 1
|
||||
assert store.line_state("ep-1", "coordination-engine")["messages"] == 1
|
||||
await adapter.acknowledge(first.message_id)
|
||||
assert (await adapter.receipt(first.message_id))["state"] == "acknowledged"
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
service.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_client_reconnects_and_recovers_receipt_from_durable_store(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("tamq.service.validate_targets", lambda repos: None)
|
||||
|
||||
async def run():
|
||||
path = tmp_path / "tamq.sock"
|
||||
database = tmp_path / "queue.sqlite3"
|
||||
store = Store(database)
|
||||
store.register_endpoint("ep", 42, "tamq", ["target"], "manual")
|
||||
first_service, first_server = await _start_service(path, store)
|
||||
client = TamqClient(path)
|
||||
sent = await client.send(
|
||||
sender_repo="coordination-engine",
|
||||
target_repo="target",
|
||||
body="wake",
|
||||
endpoint_id="ep",
|
||||
idempotency_key="lease-restart",
|
||||
)
|
||||
first_server.close()
|
||||
await first_server.wait_closed()
|
||||
first_service.close()
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
reopened = Store(database)
|
||||
second_service, second_server = await _start_service(path, reopened)
|
||||
try:
|
||||
receipt = await client.message(sent["message_id"])
|
||||
assert receipt["state"] == "pending"
|
||||
repeated = await client.send(
|
||||
sender_repo="coordination-engine",
|
||||
target_repo="target",
|
||||
body="wake",
|
||||
endpoint_id="ep",
|
||||
idempotency_key="lease-restart",
|
||||
)
|
||||
assert repeated["deduplicated"] is True
|
||||
finally:
|
||||
second_server.close()
|
||||
await second_server.wait_closed()
|
||||
second_service.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_adapter_rejects_incompatible_protocol_and_disappeared_endpoint(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("tamq.service.validate_targets", lambda repos: None)
|
||||
|
||||
async def run():
|
||||
path = tmp_path / "tamq.sock"
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint("ep", 42, "tamq", ["target"], "manual")
|
||||
service, server = await _start_service(path, store)
|
||||
try:
|
||||
with pytest.raises(TamqProtocolError):
|
||||
await TamqClient(path, protocol="2.0").negotiate()
|
||||
store.disconnect_endpoint("ep")
|
||||
adapter = CoordinationEngineAdapter(TamqClient(path))
|
||||
with pytest.raises(TamqTargetUnavailable):
|
||||
await adapter.wake(
|
||||
WakeRequest("lease", "target", "wake", "trigger")
|
||||
)
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
service.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_repeated_wake_rebinds_same_message_after_endpoint_replacement(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr("tamq.service.validate_targets", lambda repos: None)
|
||||
|
||||
async def run():
|
||||
path = tmp_path / "tamq.sock"
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint("old", 41, "tamq-old", ["target"], "manual")
|
||||
service, server = await _start_service(path, store)
|
||||
adapter = CoordinationEngineAdapter(TamqClient(path))
|
||||
wake = WakeRequest("lease-rebind", "target", "wake", "trigger")
|
||||
try:
|
||||
first = await adapter.wake(wake)
|
||||
store.disconnect_endpoint("old")
|
||||
store.register_endpoint("new", 42, "tamq-new", ["target"], "manual")
|
||||
repeated = await adapter.wake(wake)
|
||||
assert repeated.message_id == first.message_id
|
||||
assert repeated.endpoint_id == "new"
|
||||
assert repeated.deduplicated is True
|
||||
assert store.message(first.message_id)["endpoint_id"] == "new"
|
||||
rebound = store.protocol_events(
|
||||
message_id=first.message_id, event_type="message.rebound"
|
||||
)
|
||||
assert len(rebound) == 1
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
service.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_idempotency_key_conflict_is_rejected(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("tamq.service.validate_targets", lambda repos: None)
|
||||
|
||||
async def run():
|
||||
path = tmp_path / "tamq.sock"
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
service, server = await _start_service(path, store)
|
||||
client = TamqClient(path)
|
||||
try:
|
||||
first = await client.send(
|
||||
sender_repo="coordination-engine",
|
||||
target_repo="one",
|
||||
body="first",
|
||||
idempotency_key="same",
|
||||
)
|
||||
with pytest.raises(TamqClientError, match="different message"):
|
||||
await client.send(
|
||||
sender_repo="coordination-engine",
|
||||
target_repo="two",
|
||||
body="second",
|
||||
idempotency_key="same",
|
||||
)
|
||||
store.db.execute(
|
||||
"UPDATE messages SET state='failed',attempt_count=4 WHERE message_id=?",
|
||||
(first["message_id"],),
|
||||
)
|
||||
store.db.commit()
|
||||
retried = await client.retry(first["message_id"])
|
||||
assert retried["state"] == "pending"
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
service.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
|
@ -12,3 +12,6 @@ def test_config_command(monkeypatch, capsys, tmp_path):
|
|||
assert result["maxout"] == 32768
|
||||
assert result["database"].endswith("tamq.sqlite3")
|
||||
assert result["policy_profile"] == "default"
|
||||
assert result["delivery_ack_mode"] == "injected"
|
||||
assert result["delivery_max_attempts"] == 4
|
||||
assert result["ack_timeout_seconds"] == 30
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
|||
bin_dir = tmp_path / "bin"
|
||||
runtime_dir = tmp_path / "runtime"
|
||||
state_dir = tmp_path / "state"
|
||||
config = tmp_path / "config.toml"
|
||||
repo_a = tmp_path / "railiance-platform"
|
||||
repo_b = tmp_path / "activity-core"
|
||||
for path in (bin_dir, runtime_dir, repo_a, repo_b):
|
||||
|
|
@ -52,6 +53,12 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
|||
)
|
||||
gita.chmod(0o755)
|
||||
agent.chmod(0o755)
|
||||
config.write_text(
|
||||
"[policy.profiles.integration]\n"
|
||||
"delivery_ack_mode='acknowledged'\n"
|
||||
"delivery_max_attempts=3\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
socket_name = f"tamq-installed-{os.getpid()}"
|
||||
env = os.environ.copy()
|
||||
|
|
@ -63,6 +70,8 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
|||
"UV_CACHE_DIR": str(tmp_path / "uv-cache"),
|
||||
"XDG_RUNTIME_DIR": str(runtime_dir),
|
||||
"TAMQ_STATE_DIR": str(state_dir),
|
||||
"TAMQ_CONFIG": str(config),
|
||||
"TAMQ_POLICY_PROFILE": "integration",
|
||||
"TAMQ_TMUX_SOCKET": socket_name,
|
||||
}
|
||||
)
|
||||
|
|
@ -202,7 +211,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
|||
).stdout
|
||||
assert inbox[-1]["sender_repo"] == "railiance-platform"
|
||||
assert inbox[-1]["body"] == "installed-message"
|
||||
assert inbox[-1]["state"] == "pending"
|
||||
assert inbox[-1]["state"] == "awaiting_ack"
|
||||
assert inbox[-1]["displayed_at"] is not None
|
||||
message_id = inbox[-1]["message_id"]
|
||||
assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,18 @@ def test_default_policy(tmp_path):
|
|||
|
||||
def test_configured_policy(tmp_path):
|
||||
path = tmp_path / "config.toml"
|
||||
path.write_text("[policy.profiles.dev]\nallow=['repo_inspect']\nrequire_human=['destructive']\nsafety_gated_max_attempts=2\n")
|
||||
path.write_text("[policy.profiles.dev]\nallow=['repo_inspect']\nrequire_human=['destructive']\nsafety_gated_max_attempts=2\ndelivery_max_attempts=3\n")
|
||||
profile = load_profile(path, "dev")
|
||||
assert profile.safety_gated_max_attempts == 2
|
||||
assert profile.delivery_max_attempts == 3
|
||||
|
||||
|
||||
def test_delivery_attempt_cap_is_bounded(tmp_path):
|
||||
path = tmp_path / "config.toml"
|
||||
path.write_text("[policy.profiles.bad]\ndelivery_max_attempts=10\n")
|
||||
try:
|
||||
load_profile(path, "bad")
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "delivery_max_attempts must be between 1 and 9"
|
||||
else:
|
||||
raise AssertionError("invalid delivery cap was accepted")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from tamq.service import PUSHY_FRAMING_CAPABILITY, Service
|
||||
from tamq.service import (
|
||||
DELIVERY_RELIABILITY_CAPABILITY,
|
||||
IDEMPOTENT_SEND_CAPABILITY,
|
||||
PROTOCOL_CAPTURE_CAPABILITY,
|
||||
PUSHY_FRAMING_CAPABILITY,
|
||||
Service,
|
||||
)
|
||||
from tamq.store import Store
|
||||
|
||||
|
||||
|
|
@ -32,6 +38,9 @@ def test_ping_advertises_non_routable_pushy_framing(tmp_path):
|
|||
await writer.drain()
|
||||
response = json.loads(await reader.readline())
|
||||
assert PUSHY_FRAMING_CAPABILITY in response["capabilities"]
|
||||
assert PROTOCOL_CAPTURE_CAPABILITY in response["capabilities"]
|
||||
assert DELIVERY_RELIABILITY_CAPABILITY in response["capabilities"]
|
||||
assert IDEMPOTENT_SEND_CAPABILITY in response["capabilities"]
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -8,16 +8,24 @@ def test_replay_reports_batch(tmp_path, monkeypatch, capsys):
|
|||
source = tmp_path / "messages.jsonl"
|
||||
source.write_text(json.dumps({"message_id": "old-1", "sender_repo": "a", "target_repo": "b", "body": "hello"}) + "\n")
|
||||
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.setattr("tamq.cli.validate_targets", lambda targets: None)
|
||||
assert main(["replay", str(source)]) == 0
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["batch_id"].startswith("replay-")
|
||||
assert output["count"] == 1
|
||||
assert output["deduplicated"] == 0
|
||||
assert main(["replay", str(source)]) == 0
|
||||
repeated = json.loads(capsys.readouterr().out)
|
||||
assert repeated["count"] == 0
|
||||
assert repeated["deduplicated"] == 1
|
||||
assert len(Store(tmp_path / "state" / "tamq.sqlite3").list()) == 1
|
||||
|
||||
|
||||
def test_replay_preserves_endpoint(tmp_path, monkeypatch, capsys):
|
||||
source = tmp_path / "messages.jsonl"
|
||||
source.write_text(json.dumps({"sender_repo": "a", "target_repo": "b", "body": "hello", "endpoint_id": "tmux-amq-42"}) + "\n")
|
||||
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.setattr("tamq.cli.validate_targets", lambda targets: None)
|
||||
assert main(["replay", str(source)]) == 0
|
||||
row = Store(tmp_path / "state" / "tamq.sqlite3").list()[0]
|
||||
assert row["endpoint_id"] == "tmux-amq-42"
|
||||
|
|
@ -27,5 +35,6 @@ def test_replay_rejects_bad_json(tmp_path, monkeypatch, capsys):
|
|||
source = tmp_path / "bad.jsonl"
|
||||
source.write_text("not-json\n")
|
||||
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.setattr("tamq.cli.validate_targets", lambda targets: None)
|
||||
assert main(["replay", str(source)]) == 2
|
||||
assert "cannot replay" in capsys.readouterr().err
|
||||
|
|
|
|||
27
tests/test_retry.py
Normal file
27
tests/test_retry.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from tamq.cli import main
|
||||
from tamq.store import Store
|
||||
|
||||
|
||||
def test_retry_cli_resets_only_terminal_failures(tmp_path, monkeypatch, capsys):
|
||||
state = tmp_path / "state"
|
||||
monkeypatch.setenv("TAMQ_STATE_DIR", str(state))
|
||||
store = Store(state / "tamq.sqlite3")
|
||||
failed = store.add("a", "b", "failed")
|
||||
pending = store.add("a", "b", "pending")
|
||||
store.db.execute(
|
||||
"UPDATE messages SET state='failed',attempt_count=4,last_failure_reason='OSError' "
|
||||
"WHERE message_id=?",
|
||||
(failed,),
|
||||
)
|
||||
store.db.commit()
|
||||
store.close()
|
||||
|
||||
assert main(["retry", failed]) == 0
|
||||
assert capsys.readouterr().out.strip() == failed
|
||||
reopened = Store(state / "tamq.sqlite3")
|
||||
assert reopened.message(failed)["state"] == "pending"
|
||||
assert reopened.message(failed)["attempt_count"] == 0
|
||||
reopened.close()
|
||||
|
||||
assert main(["retry", pending]) == 1
|
||||
assert "not failed" in capsys.readouterr().err
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import json
|
||||
|
||||
from tamq.control import PaneDisplay
|
||||
from tamq.service import Service
|
||||
from tamq.store import Store
|
||||
|
|
@ -96,6 +98,13 @@ def test_service_trigger_mode_submits_exactly_once(tmp_path, monkeypatch):
|
|||
service._deliver_once()
|
||||
assert control.submitted == [("tamq:repo-b", "From:repo-a/o: go")]
|
||||
assert control.placed == []
|
||||
lifecycle = store.protocol_events(message_id=store.list()[0]["message_id"])
|
||||
assert [event["event_type"] for event in lifecycle] == [
|
||||
"message.accepted",
|
||||
"delivery.attempted",
|
||||
"delivery.injected",
|
||||
]
|
||||
assert lifecycle[-1]["delivery_mode"] == "trigger"
|
||||
|
||||
|
||||
def test_failed_pushy_submission_remains_pending(tmp_path, monkeypatch):
|
||||
|
|
@ -111,6 +120,12 @@ def test_failed_pushy_submission_remains_pending(tmp_path, monkeypatch):
|
|||
Service(store=store, input_grace=0)._deliver_once()
|
||||
|
||||
assert store.list()[0]["state"] == "pending"
|
||||
failure = store.protocol_events(event_type="delivery.failed")[0]
|
||||
assert failure["delivery_mode"] == "pushy"
|
||||
detail = json.loads(failure["detail"])
|
||||
assert detail["reason"] == "RuntimeError"
|
||||
assert detail["attempt"] == 1
|
||||
assert detail["attempt_limit"] == 4
|
||||
|
||||
|
||||
def test_input_delivery_waits_for_new_endpoint_grace(tmp_path, monkeypatch):
|
||||
|
|
@ -126,7 +141,7 @@ def test_input_delivery_waits_for_new_endpoint_grace(tmp_path, monkeypatch):
|
|||
assert store.list()[0]["state"] == "pending"
|
||||
|
||||
|
||||
def test_service_writes_output_once_and_retains_pending_ack(tmp_path, monkeypatch):
|
||||
def test_default_injected_policy_completes_output_delivery_once(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output")
|
||||
message_id = store.add("repo-a", "repo-b", "continue")
|
||||
|
|
@ -148,7 +163,7 @@ def test_service_writes_output_once_and_retains_pending_ack(tmp_path, monkeypatc
|
|||
)
|
||||
]
|
||||
row = store.list()[0]
|
||||
assert row["state"] == "pending"
|
||||
assert row["state"] == "injected"
|
||||
assert row["displayed_at"] is not None
|
||||
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
|
||||
|
||||
|
|
@ -166,18 +181,125 @@ def test_failed_terminal_output_remains_undisplayed_and_retryable(tmp_path, monk
|
|||
raise OSError("temporary failure")
|
||||
|
||||
monkeypatch.setattr("tamq.service.write_terminal_output", fail_once)
|
||||
service = Service(store=store)
|
||||
service = Service(store=store, retry_backoff=(0,))
|
||||
|
||||
service._deliver_once()
|
||||
assert store.list()[0]["displayed_at"] is None
|
||||
store.db.execute("UPDATE leases SET expires_at=0")
|
||||
store.db.commit()
|
||||
service._deliver_once()
|
||||
|
||||
assert len(attempts) == 2
|
||||
assert store.list()[0]["displayed_at"] is not None
|
||||
|
||||
|
||||
def test_acknowledged_policy_waits_then_redelivers_same_message(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint(
|
||||
"ep", 9, "tamq", ["repo-b"], "trigger",
|
||||
delivery_ack_mode="acknowledged",
|
||||
delivery_max_attempts=3,
|
||||
ack_timeout_seconds=10,
|
||||
)
|
||||
message_id = store.add("repo-a", "repo-b", "confirm")
|
||||
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
||||
service = Service(store=store, input_grace=0, retry_backoff=(0,))
|
||||
|
||||
service._deliver_once()
|
||||
first_control = FakeControl.instances[-1]
|
||||
assert store.message(message_id)["state"] == "awaiting_ack"
|
||||
assert store.message(message_id)["attempt_count"] == 1
|
||||
assert len(first_control.submitted) == 1
|
||||
service._deliver_once()
|
||||
assert FakeControl.instances[-1].submitted == []
|
||||
|
||||
store.db.execute(
|
||||
"UPDATE messages SET next_attempt_at=0 WHERE message_id=?", (message_id,)
|
||||
)
|
||||
store.db.commit()
|
||||
service._deliver_once()
|
||||
second_control = FakeControl.instances[-1]
|
||||
assert store.message(message_id)["attempt_count"] == 2
|
||||
assert len(second_control.submitted) == 1
|
||||
assert store.acknowledge(message_id) is True
|
||||
service._deliver_once()
|
||||
assert FakeControl.instances[-1].submitted == []
|
||||
|
||||
|
||||
def test_delivery_attempt_cap_is_terminal_and_operator_retry_resets(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint(
|
||||
"ep", 9, "tamq", ["repo-b"], "pushy", delivery_max_attempts=2
|
||||
)
|
||||
message_id = store.add("repo-a", "repo-b", "fail")
|
||||
|
||||
class FailingControl(FakeControl):
|
||||
def place(self, window, text):
|
||||
raise OSError("no pane")
|
||||
|
||||
monkeypatch.setattr("tamq.service.ControlModeClient", FailingControl)
|
||||
service = Service(store=store, input_grace=0, retry_backoff=(0,))
|
||||
service._deliver_once()
|
||||
service._deliver_once()
|
||||
service._deliver_once()
|
||||
|
||||
row = store.message(message_id)
|
||||
assert row["state"] == "failed"
|
||||
assert row["attempt_count"] == 2
|
||||
assert row["last_failure_reason"] == "OSError"
|
||||
assert store.retry_message(message_id) is True
|
||||
assert store.message(message_id)["state"] == "pending"
|
||||
assert store.message(message_id)["attempt_count"] == 0
|
||||
|
||||
|
||||
def test_ack_timeout_exhaustion_is_terminal_but_late_ack_wins(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint(
|
||||
"ep", 9, "tamq", ["repo-b"], "trigger",
|
||||
delivery_ack_mode="acknowledged",
|
||||
delivery_max_attempts=2,
|
||||
ack_timeout_seconds=10,
|
||||
)
|
||||
message_id = store.add("repo-a", "repo-b", "confirm")
|
||||
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
||||
service = Service(store=store, input_grace=0, retry_backoff=(0,))
|
||||
|
||||
service._deliver_once()
|
||||
for _ in range(2):
|
||||
store.db.execute(
|
||||
"UPDATE messages SET next_attempt_at=0 WHERE message_id=?", (message_id,)
|
||||
)
|
||||
store.db.commit()
|
||||
service._deliver_once()
|
||||
|
||||
row = store.message(message_id)
|
||||
assert row["state"] == "failed"
|
||||
assert row["attempt_count"] == 2
|
||||
assert row["last_failure_reason"] == "ack_timeout"
|
||||
assert store.acknowledge(message_id) is True
|
||||
assert store.message(message_id)["state"] == "acknowledged"
|
||||
assert store.protocol_events(message_id=message_id)[-1]["outcome"] == "late_acknowledged"
|
||||
|
||||
|
||||
def test_expired_delivery_lease_consumes_attempt_and_schedules_retry(tmp_path):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
message_id = store.add("repo-a", "repo-b", "lease")
|
||||
claim = store.claim_delivery(
|
||||
message_id,
|
||||
"ep",
|
||||
attempt_limit=2,
|
||||
retry_delay=7,
|
||||
delivery_mode="output",
|
||||
ttl=1,
|
||||
now=10,
|
||||
)
|
||||
assert claim is not None
|
||||
assert store.expire_delivery_leases(now=12) == 1
|
||||
row = store.message(message_id)
|
||||
assert row["state"] == "pending"
|
||||
assert row["attempt_count"] == 1
|
||||
assert row["last_failure_reason"] == "lease_expired"
|
||||
assert row["next_attempt_at"] == 19
|
||||
|
||||
|
||||
def test_service_disconnects_disappeared_tmux_endpoint(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"])
|
||||
|
|
|
|||
|
|
@ -41,10 +41,58 @@ def test_store_migrates_legacy_endpoint_rows_to_manual_delivery(tmp_path):
|
|||
assert store.endpoints()[0]["delivery_mode"] == "manual"
|
||||
assert store.db.execute(
|
||||
"SELECT value FROM metadata WHERE key='schema_version'"
|
||||
).fetchone()[0] == "4"
|
||||
).fetchone()[0] == "6"
|
||||
assert "displayed_at" in {
|
||||
row["name"] for row in store.db.execute("PRAGMA table_info(messages)")
|
||||
}
|
||||
assert store.db.execute(
|
||||
"SELECT COUNT(*) FROM protocol_events"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_protocol_ledger_is_atomic_and_not_duplicated_on_reopen(tmp_path):
|
||||
path = tmp_path / "queue.sqlite3"
|
||||
store = Store(path)
|
||||
message_id = store.add(
|
||||
"source", "target", "first\nsecond", provenance="worker_output"
|
||||
)
|
||||
event = store.protocol_events(message_id=message_id)[0]
|
||||
assert event["event_type"] == "message.accepted"
|
||||
assert event["repo"] == "source"
|
||||
assert event["peer_repo"] == "target"
|
||||
assert json.loads(event["detail"]) == {"line_count": 2}
|
||||
store.close()
|
||||
|
||||
reopened = Store(path)
|
||||
assert len(reopened.protocol_events(message_id=message_id)) == 1
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_schema_v5_backfills_existing_message_lifecycle(tmp_path):
|
||||
path = tmp_path / "legacy-messages.sqlite3"
|
||||
store = Store(path)
|
||||
message_id = store.add("a", "b", "old")
|
||||
store.db.execute("DELETE FROM protocol_events")
|
||||
store.db.execute(
|
||||
"UPDATE messages SET displayed_at=2,injected_at=3,acknowledged_at=4 "
|
||||
"WHERE message_id=?",
|
||||
(message_id,),
|
||||
)
|
||||
store.db.commit()
|
||||
store.close()
|
||||
|
||||
migrated = Store(path)
|
||||
assert [row["event_type"] for row in migrated.protocol_events()] == [
|
||||
"delivery.displayed",
|
||||
"delivery.injected",
|
||||
"message.acknowledged",
|
||||
"message.accepted",
|
||||
]
|
||||
assert all(
|
||||
json.loads(row["detail"])["backfilled"] is True
|
||||
for row in migrated.protocol_events()
|
||||
)
|
||||
migrated.close()
|
||||
|
||||
|
||||
def test_mark_displayed_releases_lease_without_acknowledging(tmp_path):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue