Files
multi-agent-mux/.agents/skills/lib_py/delegation/notifier.py
T
GodopuandClaude Opus 5 cb7ed1aa38 fix(issue-5): close the phase 2b gate holes found by all three reviewers
Seven blocking items across three reviews, every one reproduced here before
being accepted. Two themes run through them: a gate whose strictness depended on
what the caller remembered to pass, and a boundary that trusted its only current
caller instead of enforcing its own rule.

Independent verification is no longer optional. Omitting the report directory
skipped the disk check entirely and handed the decision back to the collector's
own flags -- the self-certification this design exists to prevent. It is now a
refusal, as is an empty directory or a job with no recorded dispatch.

Evidence is now bound to its target. The policy compares a scenario against the
dispatch context carried inside that same scenario, which is self-consistent by
construction, so one job's evidence could complete another. The gate now checks
the job, attempt, session and pane generation against the recorded dispatch.

A reviewer with no verdict token is no longer completed. Phase 0 made this a
safety contract and run_loop reads a missing token as NOT PASS, so inferring
completion first would start a correction round against a review that was never
finished. The requirement applies to reviewers only.

Bookkeeping may write one field. A status-free request was an unrestricted merge
on a terminal record: it could rewrite the prompt and the session. Settling is
also conditional now on the exact intent that was sent, because publishing
happens outside the lock and a newer intent can legitimately land mid-flight;
clearing unconditionally dropped a notification nobody had sent. The intent's
own identity travels in the payload, since the transport sequence cannot
identify a retry of the same intent.

Side-effect evidence must be evidence. A kind with no reference names something
without evidencing it, and the CLI parser could not be the only check because
the API and hand-written manifests bypass it.

Also honours MAM_ENGINE_CAS=0, which the operations note already promised
disables auto-completion, matches backend keys exactly rather than by prefix so
a future name cannot inherit evidence never collected for it, records an
absolute report path, and imports the notifier at module scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
2026-09-06 22:25:50 +09:00

116 lines
4.7 KiB
Python

"""Durable notification intent, carried in the job record.
The contract forbids a separate outbox store, so the intent rides in the record
the transition already writes: one atomic commit records both the new state and
the fact that the world has not been told yet. Publishing then happens outside
the registry lock, and settling the intent is itself an ordinary transition.
That ordering is the point. If the process dies between the commit and the
publish, the record still says a notification is owed, and startup recovery
re-sends it. Duplicates are safe: the subscriber already drops any event whose
sequence is not greater than the highest it has accepted.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from lib_py.delegation import mqtt_common
logger = logging.getLogger("delegate_job.notifier")
FIELD = "pending_notification"
def intent_for(event: str, seq: int, timestamp: str, detail: str = "") -> Dict[str, Any]:
return {"event": event, "seq": seq, "timestamp": timestamp, "detail": detail}
def pending(job_id: str, registry_dir: str) -> Optional[Dict[str, Any]]:
try:
return mqtt_common.load_job(job_id, registry_dir).get(FIELD)
except FileNotFoundError:
return None
def jobs_with_pending(registry_dir: str) -> List[str]:
"""Records still owing a notification. The input to restart recovery."""
from lib_py.delegation import registry # local: keeps module import order flat
out = []
for record in registry.list_jobs(registry_dir):
if record.get(FIELD):
out.append(record["job_id"])
return out
def settle(job_id: str, registry_dir: str, sent_intent=None,
source: str = "notifier") -> bool:
"""Clear the intent that was actually sent, and only that one.
Goes through the transition boundary like every other record write, using a
request that carries no status: settling is about a terminal, so a request
the terminal state would refuse could never be used to settle one.
`sent_intent` is compared under the lock. Publishing happens outside it, so a
newer intent can legitimately replace the old one mid-flight; clearing
unconditionally would drop a notification nobody has sent.
"""
from lib_py.delegation import engine
result = engine.transition(job_id, "notification_settled", registry_dir=registry_dir,
fields={FIELD: None}, source=source,
expected_notification=sent_intent)
if not result.committed and "changed while it was being sent" in (result.reason or ""):
logger.info("intent for %s was replaced during send; leaving the newer one", job_id)
return result.committed
def flush(job_id: str, registry_dir: str, publish=None, attempts: int = 3) -> bool:
"""Publish an owed notification and settle it. Safe to call repeatedly.
Returns True when nothing is owed afterwards. A failure leaves the intent in
place on purpose: an unsent notification that looks sent is worse than one
that is retried.
"""
intent = pending(job_id, registry_dir)
if not intent:
return True
if publish is None:
import json as _json
from lib_py.delegation import publish_event as _pe
def publish(job, intent_, registry_dir_):
# The wire event gets a fresh registry sequence, so the intent's own
# identity is carried in the payload instead. A consumer dedupes on
# `notification_intent`, not on the transport sequence -- the
# transport number cannot identify a retry of the same intent.
identity = _json.dumps({"notification_intent": {
"seq": intent_.get("seq"), "event": intent_.get("event"),
"timestamp": intent_.get("timestamp")}})
return _pe.main(["--registry-dir", registry_dir_, "--job", job,
"--event", intent_["event"], "--detail",
intent_.get("detail", ""), "--data", identity,
"--attempts", str(attempts)])
try:
rc = publish(job_id, intent, registry_dir)
except Exception as exc:
logger.warning("notification publish failed for %s: %s", job_id, exc)
return False
if rc != 0:
logger.warning("notification for %s not accepted (rc=%s); leaving it pending",
job_id, rc)
return False
return settle(job_id, registry_dir, sent_intent=intent)
def recover(registry_dir: str, publish=None) -> Dict[str, bool]:
"""Re-send every owed notification. Called at startup after a crash."""
results = {}
for job_id in jobs_with_pending(registry_dir):
results[job_id] = flush(job_id, registry_dir, publish=publish)
return results