2.1.0 — quick-wins train: brief load, offline-durable capture, session-end verb
Three independently useful changes (IMPROVEMENT-PLANS #1/#3/#7), no schema changes: - load --brief: token-budgeted cold-start digest (facts full, last-10 observations, scope+freshness, last session's key sections, agent-log lines, inbox count; ECHO_LOAD_BUDGET ~8000 chars, lowest-priority-first trimming). SessionStart hook injects the brief form; /echo-load keeps the full dump. All load reads (+ the sessions listing, which also feeds the heartbeat fallback) fetched in parallel. - Offline capture durability: a fully-offline capture queues the WHOLE op as one semantic record; flush replays it through capture so routing/gate/aliasing re-run against the current index; a gate stop on replay is kept + flagged (needs_attention, surfaced by flush and load), never landed blind; update-path and ensure_daily_log writes ride safe_request; same-args captures dedupe. - session-end: one call, one lock — session log -> agent-log line -> reflect proposals (gate-aware) -> optional scope set -> heartbeat LAST as the commit marker; dry-run default; ECHO_NOW pins HHMM; validation runs before any write. Stop hook nudge names the command and recognizes it as reflected. - Fix: main() now catches the __main__ twin-module EchoError (via RuntimeError + .code) so helper-module errors exit with their intended codes, not tracebacks. +19 e2e checks; all suites green. Plans renumbered: MCP container is 2.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,23 @@ def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key:
|
||||
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def enqueue_capture(args: dict) -> None:
|
||||
"""Queue a whole capture as ONE semantic record (2.1.0). Byte-level replay is wrong
|
||||
for the routed capture path: the create-vs-update decision, duplicate gate, and
|
||||
aliasing must re-run against the index AS IT IS AT REPLAY TIME, so flush re-invokes
|
||||
echo_ops.capture with the recorded arguments instead of replaying stale requests.
|
||||
`args` carries body_text inline (never a temp-file path) plus `today` (the capture-
|
||||
time ECHO_TODAY) so replayed frontmatter/log dates reflect when it was said."""
|
||||
key = "capture:" + hashlib.sha1(
|
||||
json.dumps(args, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
|
||||
if any(rec.get("idem_key") == key for rec in pending()):
|
||||
return
|
||||
_ensure_dirs()
|
||||
rec = {"op": "capture", "args": args, "idem_key": key}
|
||||
with outbox_path().open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def pending() -> list[dict]:
|
||||
p = outbox_path()
|
||||
if not p.exists():
|
||||
@@ -88,6 +105,12 @@ def pending() -> list[dict]:
|
||||
return [json.loads(ln) for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
||||
|
||||
|
||||
def needs_attention() -> list[dict]:
|
||||
"""Queued records that replay could not land automatically (e.g. a duplicate-gate
|
||||
stop) — surfaced by `flush` and `load` so the operator resolves them deliberately."""
|
||||
return [rec for rec in pending() if rec.get("needs_attention")]
|
||||
|
||||
|
||||
def _rewrite(records: list[dict]) -> None:
|
||||
p = outbox_path()
|
||||
if not records:
|
||||
@@ -108,9 +131,57 @@ def _rebase(url: str) -> str:
|
||||
return echo.BASE + parts.path + (("?" + parts.query) if parts.query else "")
|
||||
|
||||
|
||||
def _replay_capture(rec: dict) -> str:
|
||||
"""Replay a queued semantic capture through echo_ops.capture against the CURRENT
|
||||
index. Returns 'ok', 'retry' (still offline), or 'gated' (duplicate gate / needs
|
||||
the operator — the record is kept and flagged, later records still replay)."""
|
||||
# Cheap reachability probe FIRST: if the vault is still down, echo_ops.capture's
|
||||
# own offline short-circuit would try to re-enqueue this very record — probe and
|
||||
# bail as 'retry' instead of recursing into the queue.
|
||||
st, _ = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
|
||||
if st == 0:
|
||||
return "retry"
|
||||
import echo_ops
|
||||
a = dict(rec.get("args") or {})
|
||||
body_text = a.pop("body_text", "") or ""
|
||||
day = a.pop("today", None)
|
||||
bodyfile = echo.temp_file(body_text.encode("utf-8")) if body_text else None
|
||||
prev = os.environ.get("ECHO_TODAY")
|
||||
try:
|
||||
if day:
|
||||
os.environ["ECHO_TODAY"] = day # replayed dates reflect when it was said
|
||||
rc = echo_ops.capture(
|
||||
a.get("kind"), a.get("title") or "(untitled)", bodyfile,
|
||||
status_v=a.get("status_v", ""), aliases=a.get("aliases") or [],
|
||||
sources=a.get("sources") or [], tags=a.get("tags") or [],
|
||||
date=a.get("date"), domain=a.get("domain", "business"),
|
||||
inbox=bool(a.get("inbox")), no_log=bool(a.get("no_log")),
|
||||
force=bool(a.get("force")), merge_into=a.get("merge_into"))
|
||||
except Exception as exc: # noqa: BLE001 — a broken record must not wedge the queue
|
||||
print(f"echo_queue: queued capture '{a.get('title')}' failed on replay ({exc}) "
|
||||
"— kept and flagged", file=sys.stderr)
|
||||
return "gated"
|
||||
finally:
|
||||
if day:
|
||||
if prev is None:
|
||||
os.environ.pop("ECHO_TODAY", None)
|
||||
else:
|
||||
os.environ["ECHO_TODAY"] = prev
|
||||
if rc == 0:
|
||||
return "ok"
|
||||
if rc == 76:
|
||||
print(f"echo_queue: queued capture '{a.get('title')}' stopped at the duplicate "
|
||||
"gate on replay — resolve with capture --merge-into <slug> or --force, "
|
||||
"then remove it from the outbox", file=sys.stderr)
|
||||
return "gated"
|
||||
|
||||
|
||||
def _replay(rec: dict) -> str:
|
||||
"""Re-issue one queued write idempotently. Returns 'ok' (landed/already-present),
|
||||
'drop' (permanent 4xx — can never succeed), or 'retry' (still offline / 5xx)."""
|
||||
'drop' (permanent 4xx — can never succeed), 'gated' (kept + flagged for the
|
||||
operator), or 'retry' (still offline / 5xx)."""
|
||||
if rec.get("op") == "capture":
|
||||
return _replay_capture(rec)
|
||||
method = rec["method"]
|
||||
url = _rebase(rec["url"])
|
||||
data = base64.b64decode(rec["body_b64"]) if rec.get("body_b64") else None
|
||||
@@ -137,21 +208,30 @@ def _replay(rec: dict) -> str:
|
||||
|
||||
def flush() -> int:
|
||||
"""Replay queued writes in order; stop at the first still-unreachable one (keeping it
|
||||
and the rest). Returns the number actually replayed. Safe to call when empty."""
|
||||
and everything after). A 'gated' record (duplicate gate / replay error) is kept and
|
||||
flagged but does NOT block later records — they are independent writes. Returns the
|
||||
number actually replayed. Safe to call when empty."""
|
||||
items = pending()
|
||||
if not items:
|
||||
return 0
|
||||
replayed = 0
|
||||
remaining: list[dict] = []
|
||||
for i, rec in enumerate(items):
|
||||
stopped = False
|
||||
for rec in items:
|
||||
if stopped:
|
||||
remaining.append(rec)
|
||||
continue
|
||||
result = _replay(rec)
|
||||
if result == "ok":
|
||||
replayed += 1
|
||||
elif result == "drop":
|
||||
continue # warned in _replay; remove from queue
|
||||
elif result == "gated":
|
||||
rec["needs_attention"] = rec.get("needs_attention") or "replay-gated"
|
||||
remaining.append(rec)
|
||||
else: # retry -> still offline; keep this and everything after, in order
|
||||
remaining = items[i:]
|
||||
break
|
||||
remaining.append(rec)
|
||||
stopped = True
|
||||
_rewrite(remaining)
|
||||
return replayed
|
||||
|
||||
|
||||
Reference in New Issue
Block a user