watcher wraps one `herdr agent wait` barrier and observes only; it never concludes. The live contract is encoded rather than described: the timeout parameter is named timeout_ms because herdr counts milliseconds, --until is always passed explicitly since omitting it silently drops `working`, and a non-zero exit is read from stderr because stdout is empty by contract. A timeout counts as healthy. The socket answered and the state simply did not change; treating that as a failure would make an idle agent look unreachable. Only a matched snapshot becomes evidence, so a timeout can never be mistaken for an observation of agent state. Backoff doubles from 1s to a 16s ceiling and drops the cached snapshot on failure. Reasoning from state captured before a gap is exactly what would make a disconnect look like a completion, which is why the contract answer there is hold_reconnect. store keeps dispatch and attribution evidence outside the registry. The job record carries none of attempt_id, agent_session_uuid or pane_process_generation, phase 1 froze its schema, and this phase may not write the registry at all. Keeping the evidence here lets shadow mode carry full attribution while the registry stays untouched; promoting these fields is a 2b decision. The execution anchor starts false because acknowledging a dispatch is not evidence that the attempt ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
110 lines
3.8 KiB
Python
110 lines
3.8 KiB
Python
"""Shadow-owned store for dispatch and attribution evidence.
|
|
|
|
Deliberately NOT the job record. Two constraints force this:
|
|
|
|
* phase 1 froze the registry record schema, and
|
|
* P2A-03 forbids this phase from writing the registry at all.
|
|
|
|
The record has none of `attempt_id`, `agent_session_uuid` or
|
|
`pane_process_generation`, so four-fold attribution has nowhere to live in it.
|
|
Keeping the evidence here means shadow mode can carry full attribution while the
|
|
registry stays byte-for-byte untouched. Whether these fields are promoted into
|
|
the record is a phase 2b decision this phase does not prejudge.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
SHADOW_DIRNAME = os.path.join(".mam", "shadow")
|
|
|
|
|
|
def shadow_root(workspace: Optional[str] = None) -> Path:
|
|
return Path(workspace or os.getcwd()) / SHADOW_DIRNAME
|
|
|
|
|
|
def attribution_path(job_id: str, workspace: Optional[str] = None) -> Path:
|
|
return shadow_root(workspace) / "attribution" / "{}.json".format(job_id)
|
|
|
|
|
|
def decisions_path(job_id: str, workspace: Optional[str] = None) -> Path:
|
|
return shadow_root(workspace) / "{}.ndjson".format(job_id)
|
|
|
|
|
|
def _atomic_write(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".{}.".format(path.name), suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
if os.path.exists(tmp):
|
|
os.unlink(tmp)
|
|
raise
|
|
|
|
|
|
def record_dispatch(job_id, attempt_id, agent_session_uuid, pane_process_generation,
|
|
dispatch_ack=True, workspace=None) -> Dict[str, Any]:
|
|
"""Persist the four identifiers at dispatch time.
|
|
|
|
The execution anchor starts false on purpose: dispatch being acknowledged is
|
|
not evidence that the attempt ran.
|
|
"""
|
|
context = {
|
|
"job_id": job_id,
|
|
"attempt_id": attempt_id,
|
|
"agent_session_uuid": agent_session_uuid,
|
|
"pane_process_generation": pane_process_generation,
|
|
"dispatch_ack": bool(dispatch_ack),
|
|
"execution_anchor_observed": False,
|
|
}
|
|
_atomic_write(attribution_path(job_id, workspace),
|
|
json.dumps(context, ensure_ascii=False, indent=2) + "\n")
|
|
return context
|
|
|
|
|
|
def mark_execution_anchor(job_id, workspace=None) -> Optional[Dict[str, Any]]:
|
|
"""Record that this attempt was actually observed executing after dispatch."""
|
|
context = load_dispatch(job_id, workspace)
|
|
if context is None:
|
|
return None
|
|
context["execution_anchor_observed"] = True
|
|
_atomic_write(attribution_path(job_id, workspace),
|
|
json.dumps(context, ensure_ascii=False, indent=2) + "\n")
|
|
return context
|
|
|
|
|
|
def load_dispatch(job_id, workspace=None) -> Optional[Dict[str, Any]]:
|
|
path = attribution_path(job_id, workspace)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def append_decision(job_id, entry: Dict[str, Any], workspace=None) -> None:
|
|
"""Append one would-complete decision. The only thing shadow mode emits."""
|
|
path = decisions_path(job_id, workspace)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def read_decisions(job_id, workspace=None) -> list:
|
|
path = decisions_path(job_id, workspace)
|
|
if not path.exists():
|
|
return []
|
|
out = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
if line.strip():
|
|
out.append(json.loads(line))
|
|
return out
|