Files
multi-agent-mux/.agents/skills/lib_py/delegation/notifier.py
T
GodopuandClaude Opus 5 803ba43884 feat(issue-5): auto-completion gate and durable pending notification
The gate does not re-decide anything. Phase 2a already decides, fail-closed and
against contract-pinned vectors; this supplies real evidence to that decision and
commits only if three independent gates agree: the global switch is on, the
backend has observed evidence that it reaches a quiescent state, and the job
itself opted in. Default is off.

The backend allowlist is evidence-based rather than optimistic. Inferring
"finished" from stillness requires stillness to have been observed, and only
three of six backends have that. An operator may narrow the list; a name that
was never observed is dropped rather than trusted, so it cannot be widened by
configuration.

Everything else holds. An unreadable manifest, a report rewritten after
finalization, missing evidence for a declared side effect, a policy that raises:
all refuse. "Could not decide" is never "done". A late explicit terminal wins,
because the boundary refuses the inferred one -- that is the boundary working,
not an error in the gate.

Notification intent is committed in the same atomic write as the state, so a
crash between committing and publishing leaves a record that still says the
world has not been told. Recovery re-sends; duplicates are already dropped by
sequence. A failed send deliberately leaves the intent pending, because an
unsent notification that looks sent is worse than one that is retried.

Two fixes this surfaced. Settling used a request that terminal records refuse,
which made an owed notification impossible to ever settle; settling is now a
status-free bookkeeping request, valid in any state. And the phase 2a guard
against shell references was matching hashlib.sha256 as a substring, so it now
requires a word boundary.

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

98 lines
3.6 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, source: str = "notifier") -> bool:
"""Clear the intent once the event is on the wire.
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.
"""
from lib_py.delegation import engine
result = engine.transition(job_id, "notification_settled", registry_dir=registry_dir,
fields={FIELD: None}, source=source)
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:
from lib_py.delegation import publish_event as _pe
def publish(job, intent_, registry_dir_):
return _pe.main(["--registry-dir", registry_dir_, "--job", job,
"--event", intent_["event"], "--detail",
intent_.get("detail", ""), "--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)
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