fix(delegation): audit-log root follows the registry (Issue #57)
get_logs_dir(registry_dir) resolves the audit-log root as the delegate_job_logs next to the registry in use when DELEGATE_JOB_LOGS_DIR is not set; sibling_logs_dir() is the pure sibling rule. Every writer and reader that knows its registry (registry, engine, publish_event, remote, job_subscriber, reconcile.sh, the loop runner's fingerprint exclusion) passes that root instead of falling back to the workspace default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
e32c957464
commit
b8bed8124e
8 files changed
+46
-28
No files matched your search
@@ -374,13 +374,14 @@ def _flush(notifications, registry_dir) -> None:
|
|||||||
Best effort by design: the registry record is already durable, and an audit
|
Best effort by design: the registry record is already durable, and an audit
|
||||||
write must never be able to fail a transition that has committed.
|
write must never be able to fail a transition that has committed.
|
||||||
"""
|
"""
|
||||||
|
logs_dir = mqtt_common.get_logs_dir(registry_dir)
|
||||||
for note in notifications:
|
for note in notifications:
|
||||||
job_id = note["job_id"]
|
job_id = note["job_id"]
|
||||||
kind = note["kind"]
|
kind = note["kind"]
|
||||||
try:
|
try:
|
||||||
if kind == "status_changed":
|
if kind == "status_changed":
|
||||||
mqtt_common.update_logged_status(
|
mqtt_common.update_logged_status(
|
||||||
job_id, note["to"], updated_at=note.get("timestamp"),
|
job_id, note["to"], logs_dir=logs_dir, updated_at=note.get("timestamp"),
|
||||||
transition_seq=note.get("transition_seq"))
|
transition_seq=note.get("transition_seq"))
|
||||||
mqtt_common.append_event(job_id, {
|
mqtt_common.append_event(job_id, {
|
||||||
"event": "status_changed",
|
"event": "status_changed",
|
||||||
@@ -389,7 +390,7 @@ def _flush(notifications, registry_dir) -> None:
|
|||||||
"by": note.get("by"),
|
"by": note.get("by"),
|
||||||
"timestamp": note.get("timestamp"),
|
"timestamp": note.get("timestamp"),
|
||||||
"transition_seq": note.get("transition_seq"),
|
"transition_seq": note.get("transition_seq"),
|
||||||
})
|
}, logs_dir=logs_dir)
|
||||||
elif kind == "conflict_refused":
|
elif kind == "conflict_refused":
|
||||||
mqtt_common.append_event(job_id, {
|
mqtt_common.append_event(job_id, {
|
||||||
"event": "conflict_refused",
|
"event": "conflict_refused",
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ def _check_disk_fallback(
|
|||||||
job_rec = {}
|
job_rec = {}
|
||||||
if not disk_status or disk_status not in ("completed", "error", "cancelled"):
|
if not disk_status or disk_status not in ("completed", "error", "cancelled"):
|
||||||
try:
|
try:
|
||||||
disk_status = mqtt_common.read_logged_status(jid, mqtt_common.get_logs_dir())
|
disk_status = mqtt_common.read_logged_status(jid, mqtt_common.get_logs_dir(registry_dir))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -130,8 +130,10 @@ class _Watcher:
|
|||||||
|
|
||||||
def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]], expected_seqs: Dict[str, int],
|
def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]], expected_seqs: Dict[str, int],
|
||||||
expected_dispatch_tokens: Optional[Dict[str, Optional[str]]] = None,
|
expected_dispatch_tokens: Optional[Dict[str, Optional[str]]] = None,
|
||||||
expected_attempts: Optional[Dict[str, Optional[int]]] = None):
|
expected_attempts: Optional[Dict[str, Optional[int]]] = None,
|
||||||
|
logs_dir: Optional[str] = None):
|
||||||
self.events: "queue.Queue[Tuple[str, Dict[str, Any]]]" = queue.Queue()
|
self.events: "queue.Queue[Tuple[str, Dict[str, Any]]]" = queue.Queue()
|
||||||
|
self.logs_dir = logs_dir # Issue #57: the audit log next to the registry being watched
|
||||||
self.expected = set(expected_job_ids)
|
self.expected = set(expected_job_ids)
|
||||||
self.tokens = expected_tokens # job_id -> expected auth_token (or None)
|
self.tokens = expected_tokens # job_id -> expected auth_token (or None)
|
||||||
self.last_seq = dict(expected_seqs)
|
self.last_seq = dict(expected_seqs)
|
||||||
@@ -163,7 +165,7 @@ class _Watcher:
|
|||||||
"seq": got,
|
"seq": got,
|
||||||
"via": via,
|
"via": via,
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
})
|
}, logs_dir=self.logs_dir)
|
||||||
|
|
||||||
def _on_state(self, msg, payload: Dict[str, Any], jid: str) -> None:
|
def _on_state(self, msg, payload: Dict[str, Any], jid: str) -> None:
|
||||||
"""A retained `<prefix>/state` message (Issue #18): learn the job's
|
"""A retained `<prefix>/state` message (Issue #18): learn the job's
|
||||||
@@ -196,7 +198,7 @@ class _Watcher:
|
|||||||
"topic": msg.topic,
|
"topic": msg.topic,
|
||||||
"timestamp": payload.get("timestamp"),
|
"timestamp": payload.get("timestamp"),
|
||||||
"detail": "status={} last_event={}".format(payload.get("status"), payload.get("last_event")),
|
"detail": "status={} last_event={}".format(payload.get("status"), payload.get("last_event")),
|
||||||
})
|
}, logs_dir=self.logs_dir)
|
||||||
self.events.put((msg.topic, dict(payload, _renews_liveness=renews)))
|
self.events.put((msg.topic, dict(payload, _renews_liveness=renews)))
|
||||||
|
|
||||||
def on_message(self, _client, _userdata, msg) -> None:
|
def on_message(self, _client, _userdata, msg) -> None:
|
||||||
@@ -317,7 +319,7 @@ class _Watcher:
|
|||||||
if expected_disp_tok else None),
|
if expected_disp_tok else None),
|
||||||
"attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None
|
"attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None
|
||||||
else self.attempts.get(jid)),
|
else self.attempts.get(jid)),
|
||||||
})
|
}, logs_dir=self.logs_dir)
|
||||||
self.events.put((msg.topic, payload))
|
self.events.put((msg.topic, payload))
|
||||||
|
|
||||||
|
|
||||||
@@ -375,7 +377,8 @@ def _run_subscriber(args) -> int:
|
|||||||
if args.attempt_id is not None and args.job:
|
if args.attempt_id is not None and args.job:
|
||||||
attempts[args.job] = args.attempt_id
|
attempts[args.job] = args.attempt_id
|
||||||
|
|
||||||
watcher = _Watcher(expected_ids, tokens, seqs, dispatch_tokens, attempts)
|
logs_dir = mqtt_common.get_logs_dir(args.registry_dir)
|
||||||
|
watcher = _Watcher(expected_ids, tokens, seqs, dispatch_tokens, attempts, logs_dir=logs_dir)
|
||||||
|
|
||||||
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
||||||
base_job = jobs[0]
|
base_job = jobs[0]
|
||||||
@@ -418,7 +421,8 @@ def _run_subscriber(args) -> int:
|
|||||||
# what makes the retained state message arrive again at once.
|
# what makes the retained state message arrive again at once.
|
||||||
logger.warning("broker reconnected (connect #%d); re-subscribing", connects["count"])
|
logger.warning("broker reconnected (connect #%d); re-subscribing", connects["count"])
|
||||||
for jid in expected_ids:
|
for jid in expected_ids:
|
||||||
mqtt_common.append_event(jid, {"event": "reconnected", "connect_count": connects["count"]})
|
mqtt_common.append_event(jid, {"event": "reconnected", "connect_count": connects["count"]},
|
||||||
|
logs_dir=logs_dir)
|
||||||
for topic in subscribed_topics + state_topics:
|
for topic in subscribed_topics + state_topics:
|
||||||
_c.subscribe(topic, qos=1)
|
_c.subscribe(topic, qos=1)
|
||||||
logger.info("subscribed to %s", topic)
|
logger.info("subscribed to %s", topic)
|
||||||
|
|||||||
@@ -183,20 +183,29 @@ EVENTS_FILENAME = "events.ndjson"
|
|||||||
STATUS_FILENAME = "status.json"
|
STATUS_FILENAME = "status.json"
|
||||||
|
|
||||||
|
|
||||||
def get_logs_dir() -> str:
|
def sibling_logs_dir(registry_dir: str) -> str:
|
||||||
|
"""The audit-log root that pairs with ``registry_dir``: its sibling
|
||||||
|
``delegate_job_logs``. Pure; reads no environment. Issue #57."""
|
||||||
|
return os.path.join(os.path.dirname(os.path.abspath(registry_dir)), "delegate_job_logs")
|
||||||
|
|
||||||
|
|
||||||
|
def get_logs_dir(registry_dir: Optional[str] = None) -> str:
|
||||||
"""Audit-log root, resolved at call time (B-9).
|
"""Audit-log root, resolved at call time (B-9).
|
||||||
|
|
||||||
Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise
|
``DELEGATE_JOB_LOGS_DIR`` when set: it means the audit log was moved to a
|
||||||
``$WORKSPACE_ROOT/.mam/delegate_job_logs`` (Issue #15, see
|
place that cannot be derived from the registry. Otherwise the audit log
|
||||||
``default_registry_dir``); otherwise ``<cwd>/.mam/delegate_job_logs``.
|
follows the registry (Issue #57): the ``delegate_job_logs`` next to
|
||||||
Resolved per call rather than at import so a chdir after import cannot
|
``registry_dir``, or next to ``default_registry_dir()`` when the caller
|
||||||
strand the audit trail in the old tree — the same reason
|
does not know its registry. The default registry is
|
||||||
``DEFAULT_REGISTRY_DIR`` stays a relative string.
|
``$WORKSPACE_ROOT/.mam/jobs`` (Issue #15), otherwise ``<cwd>/.mam/jobs``,
|
||||||
|
so the usual roots are unchanged. Resolved per call rather than at import
|
||||||
|
so a chdir after import cannot strand the audit trail in the old tree —
|
||||||
|
the same reason ``DEFAULT_REGISTRY_DIR`` stays a relative string.
|
||||||
"""
|
"""
|
||||||
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
|
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
|
||||||
if env and env.strip():
|
if env and env.strip():
|
||||||
return env
|
return env
|
||||||
return _workspace_mam_dir("delegate_job_logs") or os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
|
return sibling_logs_dir(registry_dir or default_registry_dir())
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str): # PEP 562 (3.7+)
|
def __getattr__(name: str): # PEP 562 (3.7+)
|
||||||
@@ -516,14 +525,15 @@ def _update_job_status_legacy(job_id: str, registry_dir: str = DEFAULT_REGISTRY_
|
|||||||
_atomic_write_record(job_id, registry_dir, record)
|
_atomic_write_record(job_id, registry_dir, record)
|
||||||
if "status" in fields:
|
if "status" in fields:
|
||||||
new_status = record.get("status")
|
new_status = record.get("status")
|
||||||
update_logged_status(job_id, new_status, updated_at=record["updated_at"])
|
logs_dir = get_logs_dir(registry_dir)
|
||||||
|
update_logged_status(job_id, new_status, logs_dir=logs_dir, updated_at=record["updated_at"])
|
||||||
if old_status != new_status:
|
if old_status != new_status:
|
||||||
append_event(job_id, {
|
append_event(job_id, {
|
||||||
"event": "status_changed",
|
"event": "status_changed",
|
||||||
"from": old_status,
|
"from": old_status,
|
||||||
"to": new_status,
|
"to": new_status,
|
||||||
"timestamp": record["updated_at"],
|
"timestamp": record["updated_at"],
|
||||||
})
|
}, logs_dir=logs_dir)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ def record_outcome(prepared: PreparedEvent, registry_dir: str,
|
|||||||
"payload": prepared.payload,
|
"payload": prepared.payload,
|
||||||
"published": publish_ok,
|
"published": publish_ok,
|
||||||
"publish_error": publish_error,
|
"publish_error": publish_error,
|
||||||
})
|
}, logs_dir=mqtt_common.get_logs_dir(registry_dir))
|
||||||
|
|
||||||
if not local_event_recorded:
|
if not local_event_recorded:
|
||||||
registry.append_event(prepared.job_id, registry_dir, prepared.payload)
|
registry.append_event(prepared.job_id, registry_dir, prepared.payload)
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ def register_job(
|
|||||||
_atomic_write_record(job_id, registry_dir, record)
|
_atomic_write_record(job_id, registry_dir, record)
|
||||||
# Seed the persistent audit log (meta.json + status.json + a "registered"
|
# Seed the persistent audit log (meta.json + status.json + a "registered"
|
||||||
# event). Best-effort inside init_job_log — never blocks registration.
|
# event). Best-effort inside init_job_log — never blocks registration.
|
||||||
mqtt_common.init_job_log(job_id, meta=record)
|
mqtt_common.init_job_log(job_id, meta=record, logs_dir=mqtt_common.get_logs_dir(registry_dir))
|
||||||
logger.info("registered job %s (agent=%s session=%s)", job_id, agent, agent_session)
|
logger.info("registered job %s (agent=%s session=%s)", job_id, agent, agent_session)
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
@@ -233,7 +233,8 @@ def _pick_pending_legacy(agent_session: str, registry_dir: str = DEFAULT_REGISTR
|
|||||||
updated_at = chosen["updated_at"]
|
updated_at = chosen["updated_at"]
|
||||||
# Mirrors the pending->running transition into the audit log outside the
|
# Mirrors the pending->running transition into the audit log outside the
|
||||||
# lock. This is the shape the engine now generalises to every transition.
|
# lock. This is the shape the engine now generalises to every transition.
|
||||||
mqtt_common.update_logged_status(job_id, "running", updated_at=updated_at)
|
logs_dir = mqtt_common.get_logs_dir(registry_dir)
|
||||||
|
mqtt_common.update_logged_status(job_id, "running", logs_dir=logs_dir, updated_at=updated_at)
|
||||||
mqtt_common.append_event(job_id, {
|
mqtt_common.append_event(job_id, {
|
||||||
"event": "status_changed",
|
"event": "status_changed",
|
||||||
"from": "pending",
|
"from": "pending",
|
||||||
@@ -328,7 +329,7 @@ def get_feedback(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR,
|
|||||||
# 1) Try the unified audit log first (ndjson) since it's written synchronously by the subscriber
|
# 1) Try the unified audit log first (ndjson) since it's written synchronously by the subscriber
|
||||||
try:
|
try:
|
||||||
from lib_py.delegation import mqtt_common
|
from lib_py.delegation import mqtt_common
|
||||||
logs_dir = mqtt_common.get_logs_dir()
|
logs_dir = mqtt_common.get_logs_dir(registry_dir)
|
||||||
logged = list(mqtt_common.iter_logged_events(job_id, logs_dir))
|
logged = list(mqtt_common.iter_logged_events(job_id, logs_dir))
|
||||||
for e in reversed(logged):
|
for e in reversed(logged):
|
||||||
if e.get("source_event") in events:
|
if e.get("source_event") in events:
|
||||||
@@ -450,7 +451,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
help="summarise every job under the logs dir instead")
|
help="summarise every job under the logs dir instead")
|
||||||
p_logs.add_argument("--logs-dir", default=None,
|
p_logs.add_argument("--logs-dir", default=None,
|
||||||
help="override the audit-log root (default: $DELEGATE_JOB_LOGS_DIR "
|
help="override the audit-log root (default: $DELEGATE_JOB_LOGS_DIR "
|
||||||
"or <cwd>/.mam/delegate_job_logs)")
|
"or the delegate_job_logs next to --registry-dir)")
|
||||||
p_logs.add_argument("--tail", type=int, default=0,
|
p_logs.add_argument("--tail", type=int, default=0,
|
||||||
help="show only the last N events (0 = all)")
|
help="show only the last N events (0 = all)")
|
||||||
p_logs.add_argument("--json", action="store_true",
|
p_logs.add_argument("--json", action="store_true",
|
||||||
@@ -698,7 +699,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
|
|
||||||
def _cmd_logs(args) -> int:
|
def _cmd_logs(args) -> int:
|
||||||
"""Pretty-print one job's events.ndjson, or summarise all logged jobs."""
|
"""Pretty-print one job's events.ndjson, or summarise all logged jobs."""
|
||||||
logs_dir = args.logs_dir or mqtt_common.get_logs_dir()
|
logs_dir = args.logs_dir or mqtt_common.get_logs_dir(args.registry_dir)
|
||||||
|
|
||||||
if args.list_all:
|
if args.list_all:
|
||||||
jobs = mqtt_common.list_logged_jobs(logs_dir)
|
jobs = mqtt_common.list_logged_jobs(logs_dir)
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ def publish_dispatch(job_id: str, registry_dir: str, session: str, instructions:
|
|||||||
exceptions=(OSError, TimeoutError, ConnectionError, ValueError))(
|
exceptions=(OSError, TimeoutError, ConnectionError, ValueError))(
|
||||||
broker_config_from_job(job), topic, body, True)
|
broker_config_from_job(job), topic, body, True)
|
||||||
mqtt_common.append_event(job_id, {"event": "dispatched", "topic": topic, "session": session,
|
mqtt_common.append_event(job_id, {"event": "dispatched", "topic": topic, "session": session,
|
||||||
"attempt_id": payload["attempt_id"]})
|
"attempt_id": payload["attempt_id"]},
|
||||||
|
logs_dir=mqtt_common.get_logs_dir(registry_dir))
|
||||||
logger.info("dispatched job %s to remote session %s on %s", job_id, session, topic)
|
logger.info("dispatched job %s to remote session %s on %s", job_id, session, topic)
|
||||||
return topic
|
return topic
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from lib_py import ints
|
from lib_py import ints
|
||||||
|
from lib_py.delegation import mqtt_common
|
||||||
from lib_py.delegation import registry as job_registry
|
from lib_py.delegation import registry as job_registry
|
||||||
from lib_py.loop import checkpoint, eager, parser
|
from lib_py.loop import checkpoint, eager, parser
|
||||||
|
|
||||||
@@ -1083,7 +1084,7 @@ class LoopRunner:
|
|||||||
if not head:
|
if not head:
|
||||||
return None
|
return None
|
||||||
root = self.worktree_root.resolve()
|
root = self.worktree_root.resolve()
|
||||||
logs = Path(os.environ.get("DELEGATE_JOB_LOGS_DIR") or self.mam_dir / "delegate_job_logs")
|
logs = Path(mqtt_common.get_logs_dir(str(self.jobs_dir)))
|
||||||
exclude = []
|
exclude = []
|
||||||
for d in (self.mam_dir, self.jobs_dir, self.workspace_root / logs):
|
for d in (self.mam_dir, self.jobs_dir, self.workspace_root / logs):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ def on_message(_client, _userdata, msg):
|
|||||||
"topic": msg.topic,
|
"topic": msg.topic,
|
||||||
"timestamp": payload.get("timestamp"),
|
"timestamp": payload.get("timestamp"),
|
||||||
"detail": payload.get("detail", ""),
|
"detail": payload.get("detail", ""),
|
||||||
})
|
}, logs_dir=mqtt_common.get_logs_dir(registry_dir))
|
||||||
|
|
||||||
print(f"MQTT Monitor: recorded event {event} for job {jid} (seq={seq})", flush=True)
|
print(f"MQTT Monitor: recorded event {event} for job {jid} (seq={seq})", flush=True)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user