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
116 lines
4.7 KiB
Python
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
|