diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e710e..04b5df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.6.0] - 2026-10-13 + +### Added + +- The daily health check reports Cue speed, timeouts, lost and unavailable Cue moments per harness and kind, server time, budget cuts, unsettled replies and tool calls with no pre-tool row from Agent Observer, and files a rate only when it got worse (#57). + ## [0.5.7] - 2026-10-13 ### Changed diff --git a/README.md b/README.md index c3837f7..cda1841 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,31 @@ repository that owns it. It reads only stored values, read-only: - **Heartbeats.** An expected harness with no moments reads `no calls, coverage unknown` while the heartbeat is fresh, and `server stale` when it is not. +- **Cue, per harness and moment kind (#57).** For user messages, tool + results and tool failures with a Cue status: *unavailable* + (`not_called` or `connect_failed`) over all scored moments, then + *timeout* and *lost* over the available ones, and the p50/p90 round trip + of `ok` rows. Round trips leave out timeouts, so they understate the slow + tail. +- **Server time and budget cuts.** p50/p90/max of the server's own time per + kind, `over_budget` moments per harness, and served (200) replies the hook + never settled. +- **Tool calls with no pre-tool row.** Agent Observer's ledger + (`--observer-db`, default as for `report`), read-only, over the window two + hours back. It judges tool calls (Codex: completed shell execs) between + their session's first and last logged moment, in sessions whose every + Observer source was imported at or after that session's served activity in + the window (its last moment, or the window's end while it goes on) with no + unread tail. A session with no Observer source is not judged. A call whose pre-tool row is `over_budget` is a server cut; + a call with no pre-tool row is reported with its cause unknown. Calls it + cannot judge are counted, never counted as lost. An unreadable ledger + fails the run and keeps the stored window. + +A Cue or tool-call rate is marked `*` at 10 or more events over 2%. It files +only when it got worse than the window of equal length just before: it +crossed 2%, or rose 5 points. Each harness and kind is judged on its own. +`cue/timeout` and `cue/lost` file in toolboxmd/chromeria, `cue/unavailable` +and `moment/unlogged` here. Each Issue carries a `ContextMD-Class:` line. A later window adds a comment. Each post is identified by machine, class and window, so the same diff --git a/VERSION b/VERSION index d3532a1..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.7 +0.6.0 diff --git a/contextmd/serve/__main__.py b/contextmd/serve/__main__.py index 20bde47..ba4923e 100644 --- a/contextmd/serve/__main__.py +++ b/contextmd/serve/__main__.py @@ -52,6 +52,8 @@ def main(argv=None) -> int: hp.add_argument("--config", type=Path, help="health configuration (default ~/.config/contextmd/health.json)") hp.add_argument("--state", type=Path, help="stored window (default ~/.local/state/contextmd/health.json)") hp.add_argument("--gh", default="gh", help="the GitHub CLI") + hp.add_argument("--observer-db", type=Path, help="Agent Observer's ledger, read-only" + " (default: $AGENT_OBSERVER_DB, else Observer's default path)") hp.add_argument("--now", help=argparse.SUPPRESS) cp = sub.add_parser("card") cp.add_argument("--base", required=True, type=Path) @@ -145,7 +147,7 @@ def main(argv=None) -> int: def _health(ns, base: Path) -> int: from datetime import datetime, timezone - from . import health, report + from . import health, measures, report try: now = report.parse_since(ns.now) if ns.now else datetime.now(timezone.utc).timestamp() start, end = report.parse_since(ns.start), report.parse_since(ns.end) @@ -162,6 +164,16 @@ def _health(ns, base: Path) -> int: if not sliced: start, end = health.window(state, db, now), now result = health.measure(db, start, end, cfg["expected_harnesses"], now) + if not result.unreadable: + try: + current = measures.measure(db, ns.observer_db, start, end) + previous = measures.measure(db, ns.observer_db, 2 * start - end, start) + except measures.ObserverError as exc: + print(health.report(result, cfg["expected_harnesses"], cfg["machine"])) + print(f"contextmd-serve health: Agent Observer's ledger is unreadable: {exc}") + return 1 + result.measures = measures.render(current, previous) + result.findings += [health.Finding(cls, rows) for cls, rows in measures.findings(current, previous)] print(health.report(result, cfg["expected_harnesses"], cfg["machine"])) try: actions = health.file(result, health.GitHub(ns.gh, ns.dry_run), cfg["machine"], cfg["seed"]) diff --git a/contextmd/serve/health.py b/contextmd/serve/health.py index 5efd944..72a44a5 100644 --- a/contextmd/serve/health.py +++ b/contextmd/serve/health.py @@ -1,7 +1,8 @@ """The daily serving health check (#40): rate one machine's serving log and file defects at their owners. It reads only stored values, read-only: ``moments.status`` per harness, ``candidates.outcome`` per -harness, and ``heartbeats.at``. No model, no transcript text. +harness, and ``heartbeats.at``. No model, no transcript text. Cue speed, server time and tool calls +with no pre-tool row (#57) are ``measures``; their rows file through the same Issues. - **Window:** from the end of the last successful run to now (first run: the last 24 hours). The end is stored only after a successful run, so a spike repaired between runs is still seen. An @@ -47,6 +48,7 @@ **{f"moment/{s}": CONTEXTMD for s in ("bad_event", "bad_request", "oversize", "unauthorized", "bad_config")}, **{f"moment/{s}": CHROMERIA for s in ("unjoined", "ambiguous", "unmapped")}, **{f"candidate/{o}": CHROMERIA for o in FILED_OUTCOMES}, + "cue/timeout": CHROMERIA, "cue/lost": CHROMERIA, "cue/unavailable": CONTEXTMD, "moment/unlogged": CONTEXTMD, } CONFIG = "~/.config/contextmd/health.json" STATE = "~/.local/state/contextmd/health.json" @@ -71,6 +73,7 @@ class Result: harnesses: dict = field(default_factory=dict) findings: list[Finding] = field(default_factory=list) unreadable: str | None = None + measures: list[str] = field(default_factory=list) # the Cue and lost-call lines (#57) def utc(ts: float) -> str: @@ -143,6 +146,7 @@ def report(result: Result, expected: list[str], machine: str) -> str: if h["outcomes"]: parts.append("outcomes " + ", ".join(f"{o} {n}" for o, n in sorted(h["outcomes"].items()))) lines.append(". ".join(parts)) + lines += result.measures if not result.findings: lines.append("Nothing to file.") return "\n".join(lines) @@ -158,7 +162,10 @@ def section(finding: Finding, result: Result, machine: str) -> str: for r in finding.rows: rate = f"{r['rate']:.1%}" if "rate" in r else "n/a" kinds = ", ".join(f"{k} {n}" for k, n in sorted((r.get("kinds") or {}).items())) or "n/a" - count = (f"{r['count']} of {r['total']} moments" if finding.cls.startswith("moment/") + if "previous" in r: + kinds = f"previous window {r['previous']:.1%}" + count = (f"{r['count']} of {r['total']} {r['unit']}" if "unit" in r + else f"{r['count']} of {r['total']} moments" if finding.cls.startswith("moment/") else f"{r['count']} candidate rows, of {r['total']} moments") lines.append(f"| {r['harness']} | {count} | {rate} | {kinds} |") ids = [i for r in finding.rows for i in r["ids"]][:MAX_IDS] @@ -178,8 +185,8 @@ def window_mark(finding: Finding, result: Result, machine: str) -> str: def body(finding: Finding, result: Result, machine: str) -> str: return "\n".join([ f"**What:** The serving health check found `{finding.cls}` on {machine} over its threshold.", - "**Why:** The daily check (toolboxmd/contextmd#40) rates the serving log's stored statuses and outcomes;" - " this class is a defect this repository owns.", + "**Why:** The daily check (toolboxmd/contextmd#40, #57) rates the serving log's stored statuses, outcomes" + " and Cue results, and tool calls Agent Observer saw; this class is a defect this repository owns.", "**So what:** An agent finds the cause and fixes it here; the check comments with each later window" " over the threshold.", "", diff --git a/contextmd/serve/measures.py b/contextmd/serve/measures.py new file mode 100644 index 0000000..4be8cff --- /dev/null +++ b/contextmd/serve/measures.py @@ -0,0 +1,278 @@ +"""Cue speed and lost tool calls for the daily health check (#57), read-only. + +From the serving log, per harness and moment kind: + +- **Cue:** moments of kind ``user_message``, ``tool_result`` and ``tool_failure`` with a ``cue_status`` + other than ``started`` (a pre-tool moment only starts a request). The buckets are exclusive: + *unavailable* (``not_called``, ``connect_failed``: the server could not reach Cue) is rated over all + scored moments; *timeout*, *lost*, *ok* and *other* over the available ones. Round trip is the + nearest-rank p50/p90 of ``cue_ms`` over ``ok`` rows only, so it understates the slow tail. +- **Server duration:** nearest-rank p50/p90/max of the server's own time, stored in seconds + (``server_ms``, or ``server_seconds`` once chromeria#250 renames it). +- **Budget cuts:** ``over_budget`` moments (chromeria#251), and served (200) replies the hook never + settled. + +From Agent Observer's ledger, read ``mode=ro``, over the health window two hours back: tool calls +(Codex: completed shell execs, the ``exec-`` tool results its hook names) inside their session's +first-to-last logged moment, in sessions whose every Observer source was imported at or after that +session's served activity in the window (its last moment, or the window's end while the session goes on) +with no unread tail; a session with no Observer source is not judged. A call whose pre-tool row is +``over_budget`` is a server cut; a call with no pre-tool row of any status is *no pre-tool row*, +cause unknown. Everything not judged is counted and printed, never counted as lost. + +A rate files only on a regression, per harness and kind, never pooled: at least ``FLOOR`` events, +over ``BAR``, and the previous window of equal length at or under ``BAR`` or ``RISE`` lower. +""" + +from __future__ import annotations + +import math +import sqlite3 +from fractions import Fraction +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +KINDS = {"user_message": "user message", "tool_result": "tool result", "tool_failure": "tool failure"} +SERVER_KINDS = {"pre_tool": "pre-tool", "tool_result": "tool result", "user_message": "user message"} +UNAVAILABLE = ("not_called", "connect_failed") +BAR, FLOOR, RISE = Fraction(2, 100), 10, Fraction(5, 100) # exact, so 15% - 10% is 5 points +OBSERVER_LAG = 2 * 3600 # Observer's window trails the health window, so the ledger has caught up +PRE_TOOL_LOOKBACK = 24 * 3600 # a Codex exec's result can come long after its pre-tool moment +MAX_IDS = 10 + + +class ObserverError(Exception): + """Agent Observer's ledger did not open read-only, or its schema is not one this reads.""" + + +@dataclass +class Measures: + start: float + end: float + has_cue: bool = False + cue: dict = field(default_factory=dict) # (harness, kind) -> counts, ids and round trip + server: dict = field(default_factory=dict) # (harness, kind) -> (p50, p90, max) ms + over_budget: Counter = field(default_factory=Counter) # (harness, kind or None) -> cuts + replies: dict = field(default_factory=dict) # harness -> (unconfirmed, served) + observer: dict = field(default_factory=dict) # harness -> judged, cut, gap, ids + excluded: Counter = field(default_factory=Counter) + idless: int = 0 + + +def nearest_rank(values: list[float], q: float) -> float | None: + values = sorted(values) + return values[max(0, math.ceil(q * len(values)) - 1)] if values else None + + +def measure(db_path: Path, ledger: Path | None, start: float, end: float) -> Measures: + from .report import Observer + observer = Observer(ledger) + con = observer.connect() + if con is None: + raise ObserverError(observer.error) + db = sqlite3.connect(f"{db_path.absolute().as_uri()}?mode=ro", uri=True) + try: + m = Measures(start, end) + _serving(db, m) + _observed(db, con, m) + return m + finally: + db.close() + + +def _columns(db: sqlite3.Connection, table: str) -> set[str]: + return {r[1] for r in db.execute(f"PRAGMA table_info({table})")} + + +def _serving(db: sqlite3.Connection, m: Measures) -> None: + cols = _columns(db, "moments") + window = (m.start, m.end) + if "cue_status" in cols: + rows = db.execute(f"""SELECT id, harness, kind, cue_status, cue_ms FROM moments WHERE received_at >= ? + AND received_at < ? AND cue_status IS NOT NULL AND cue_status != 'started' + AND kind IN ({",".join("?" * len(KINDS))})""", (*window, *KINDS)).fetchall() + m.has_cue = bool(rows) + for mid, harness, kind, status, ms in rows: + c = m.cue.setdefault((harness or "unknown", kind), {"scored": 0, "unavailable": 0, "timeout": 0, "lost": 0, + "ok": 0, "other": 0, "ms": [], "ids": defaultdict(list)}) + bucket = ("unavailable" if status in UNAVAILABLE else status if status in ("timeout", "lost", "ok") + else "other") + c["scored"] += 1 + c[bucket] += 1 + c["ids"][bucket].append(mid) + if status == "ok" and ms is not None: + c["ms"].append(ms) + seconds = "server_seconds" if "server_seconds" in cols else "server_ms" + durations: dict = defaultdict(list) + for harness, kind, s in db.execute(f"""SELECT harness, kind, {seconds} FROM moments WHERE received_at >= ? + AND received_at < ? AND {seconds} IS NOT NULL""", window): + if kind in SERVER_KINDS: + durations[(harness or "unknown", kind)].append(s * 1000) + m.server = {k: (nearest_rank(v, 0.5), nearest_rank(v, 0.9), max(v)) for k, v in durations.items()} + m.over_budget = Counter((h or "unknown", kind) for h, kind in db.execute( + "SELECT harness, kind FROM moments WHERE status = 'over_budget' AND received_at >= ? AND received_at < ?", + window)) + if "settled_at" in _columns(db, "deliveries"): + for harness, unconfirmed, served in db.execute( + """SELECT harness, sum(stage != 'settled'), count(*) FROM deliveries WHERE status = 200 + AND created_at >= ? AND created_at < ? GROUP BY harness""", window): + m.replies[harness] = (unconfirmed, served) + + +def _observed(db: sqlite3.Connection, ledger: sqlite3.Connection, m: Measures) -> None: + start, end = m.start - OBSERVER_LAG, m.end - OBSERVER_LAG + spans = {(h, s): (a, b) for h, s, a, b in db.execute( + "SELECT harness, session, min(received_at), max(received_at) FROM moments WHERE session IS NOT NULL" + " GROUP BY harness, session")} + last = {(h, s): b for h, s, b in db.execute( + """SELECT harness, session, max(received_at) FROM moments WHERE session IS NOT NULL AND received_at >= ? + AND received_at < ? GROUP BY harness, session""", (start, end))} + pre: dict = defaultdict(set) + for h, s, call, status in db.execute( + """SELECT harness, session, tool_use_id, status FROM moments WHERE kind = 'pre_tool' AND tool_use_id + IS NOT NULL AND received_at >= ? AND received_at < ?""", (start - PRE_TOOL_LOOKBACK, end + 3600)): + pre[(h, s, call)].add(status) + m.idless = db.execute("""SELECT count(*) FROM moments WHERE status = 'over_budget' AND tool_use_id IS NULL + AND received_at >= ? AND received_at < ?""", (start, end)).fetchone()[0] + try: + sources = {s: (imported, tail) for s, imported, tail in ledger.execute( + """SELECT session_id, min(imported_at), max(read_offset < size_bytes) FROM sources + WHERE session_id IS NOT NULL GROUP BY session_id""")} + events = ledger.execute( + """SELECT id, session_key, family, native_id, ts FROM events WHERE ts >= ? AND ts < ? + AND family IN ('tool_call', 'tool_result')""", (start, end)).fetchall() + except sqlite3.Error as exc: + raise ObserverError(str(exc)) from exc + m.excluded["sourceless"] = sum(1 for (_, s) in last if s not in sources) + stale = set() + + def synced(harness: str, session: str, span: tuple) -> bool: + """Every Observer source of the session imported after its served activity in the window, fully read.""" + imported, tail = sources.get(session, (None, None)) + return imported is not None and not tail and imported >= min(span[1], end) + for eid, key, family, call, ts in events: + harness, _, session = key.partition(":") + wanted = (family == "tool_result" and call.startswith("exec-")) if harness == "codex" else family == "tool_call" + if not wanted: + continue + span = spans.get((harness, session)) + if span is None: + m.excluded["no_moments"] += 1 + continue + if not span[0] <= ts <= span[1]: + m.excluded["outside"] += 1 + continue + if not synced(harness, session, span): + stale.add((harness, session)) + m.excluded["not_synced"] += 1 + continue + o = m.observer.setdefault(harness, {"judged": 0, "cut": 0, "gap": 0, "ids": []}) + o["judged"] += 1 + statuses = pre.get((harness, session, call)) + if not statuses: + o["gap"] += 1 + o["ids"].append(eid) + elif statuses == {"over_budget"}: + o["cut"] += 1 + m.excluded["stale_sessions"] = len(stale) + + +def rates(m: Measures) -> dict: + """(class, row label) -> (count, total, ids), for every filed rate.""" + out = {} + for (harness, kind), c in m.cue.items(): + label, available = f"{harness} {KINDS[kind]}", c["scored"] - c["unavailable"] + out[("cue/unavailable", label)] = (c["unavailable"], c["scored"], c["ids"]["unavailable"]) + out[("cue/timeout", label)] = (c["timeout"], available, c["ids"]["timeout"]) + out[("cue/lost", label)] = (c["lost"], available, c["ids"]["lost"]) + for harness, o in m.observer.items(): + out[("moment/unlogged", harness)] = (o["gap"], o["judged"], o["ids"]) + return out + + +def over_bar(count: int, total: int) -> bool: + return total > 0 and count >= FLOOR and Fraction(count, total) > BAR + + +def regressed(now: tuple, before: tuple | None) -> bool: + count, total, _ = now + if not over_bar(count, total) or before is None or before[1] == 0: + return False + rate, previous = Fraction(count, total), Fraction(before[0], before[1]) + return previous <= BAR or rate - previous >= RISE + + +def _comparable(previous: Measures) -> dict: + """The previous window's rates, without Cue rates from before Cue was logged.""" + before = rates(previous) + return before if previous.has_cue else {k: v for k, v in before.items() if not k[0].startswith("cue/")} + + +def findings(current: Measures, previous: Measures) -> list[tuple[str, list[dict]]]: + """Each class with the rows that regressed, per harness and kind.""" + now, before = rates(current), _comparable(previous) + by_class: dict = defaultdict(list) + for (cls, label), value in sorted(now.items()): + if regressed(value, before.get((cls, label))): + count, total, ids = value + prev = before[(cls, label)] + by_class[cls].append({"harness": label, "count": count, "total": total, "rate": count / total, + "unit": "tool calls" if cls == "moment/unlogged" else "Cue moments", + "previous": prev[0] / prev[1], "ids": ids[:MAX_IDS]}) + return sorted(by_class.items()) + + +def _rate(count: int, total: int) -> str: + text = f"{count} ({count / total:.1%})" if total else f"{count}" + return text + (" *" if over_bar(count, total) else "") + + +def _previous(names: list[str], label: str, before: dict, why: str) -> str: + """The previous window's rates; ``-`` for a rate with no moments then, ``why`` when none has any.""" + prevs = [before.get((name, label)) for name in names] + if not any(p and p[1] for p in prevs): + return f"previous unavailable: {why}" + return "previous: " + ", ".join(f"{name.split('/')[1]} {p[0] / p[1]:.1%}" if p and p[1] + else f"{name.split('/')[1]} -" for name, p in zip(names, prevs)) + + +def render(current: Measures, previous: Measures) -> list[str]: + before = _comparable(previous) + cue_why = ("no Cue moments of this kind in the previous window" if previous.has_cue + else "no Cue statuses in the previous window") + lines = [] + for (harness, kind), c in sorted(current.cue.items()): + available = c["scored"] - c["unavailable"] + rt = (f"round trip p50/p90 {nearest_rank(c['ms'], 0.5):.0f}/{nearest_rank(c['ms'], 0.9):.0f} ms" + if c["ms"] else "round trip -") + other = f", other {c['other']}" if c["other"] else "" + label = f"{harness} {KINDS[kind]}" + lines.append( + f"Cue {label}: scored {c['scored']}, unavailable {_rate(c['unavailable'], c['scored'])}, available" + f" {available}, timeout {_rate(c['timeout'], available)}, lost {_rate(c['lost'], available)}, ok" + f" {c['ok']}{other}; {rt}; {_previous(['cue/unavailable', 'cue/timeout', 'cue/lost'], label, before, cue_why)}") + if current.cue: + lines.append("Round trips are over ok rows only, so they understate the slow tail.") + for (harness, kind), (p50, p90, top) in sorted(current.server.items()): + lines.append(f"Server {harness} {SERVER_KINDS[kind]}: p50/p90/max {p50:.0f}/{p90:.0f}/{top:.0f} ms") + cuts: dict = defaultdict(list) + for (harness, kind), n in sorted(current.over_budget.items(), key=lambda kv: (kv[0][0], kv[0][1] is None, + kv[0][1] or "")): + cuts[harness].append(f"{({**KINDS, **SERVER_KINDS}.get(kind, kind) if kind else 'unknown kind')} {n}") + for harness, parts in sorted(cuts.items()): + lines.append(f"Over budget {harness}: {', '.join(parts)}") + if current.replies: + lines.append("Served replies not confirmed by the hook's settle: " + ", ".join( + f"{h} {u} of {n}" for h, (u, n) in sorted(current.replies.items()))) + for harness, o in sorted(current.observer.items()): + unit = "completed shell execs" if harness == "codex" else "tool calls" + lines.append(f"Observer {harness}: judged {o['judged']} {unit}, server cut {o['cut']}, no pre-tool row" + f" {_rate(o['gap'], o['judged'])}; {_previous(['moment/unlogged'], harness, before, 'no judged calls in the previous window')}") + x = current.excluded + lines.append(f"Not judged: {x['no_moments']} calls in sessions with no moments (coverage unknown)," + f" {x['outside']} outside their session's first-to-last moment, {x['not_synced']} in" + f" {x['stale_sessions']} sessions not synced, {x['sourceless']} served sessions with no Observer" + f" source. over_budget rows without ids: {current.idless}") + lines.append("* at least 10 events and over 2%; filed only when it got worse than the previous window.") + return lines diff --git a/contextmd/serve/report.py b/contextmd/serve/report.py index 94f12c0..64a4bbb 100644 --- a/contextmd/serve/report.py +++ b/contextmd/serve/report.py @@ -38,9 +38,9 @@ class LogError(Exception): class Observer: """Agent Observer's ledger, opened read-only, one query per session, cached. - Observer's ``trace`` command opens the ledger read-write (it may create, migrate or write - schema rows), so the report reads the ledger itself with SQLite ``mode=ro``: it never - creates, migrates or changes it. Only the schema it was written for is read. + The report reads the ledger itself with SQLite ``mode=ro``: it never creates, migrates or + changes it, and it does not depend on Observer's CLI. Only the schema it was written for is + read. """ SCHEMAS = ("3",) # Agent Observer 1.0.1 diff --git a/deploy/health/README.md b/deploy/health/README.md index c83ee86..439b0c1 100644 --- a/deploy/health/README.md +++ b/deploy/health/README.md @@ -1,7 +1,7 @@ # Daily serving health check: deployment `contextmd-serve health` (toolboxmd/contextmd#40) runs once a day on each -machine that serves, as a user launchd job. This file is the canonical +machine that serves, as a user launchd job on macOS or a systemd user timer on Linux. This file is the canonical source for that job under AgentsMD ADR 0001 (persistent host automation). The machine's own record (which commit is deployed, when, and the first run's output) belongs in that machine's private runbook. @@ -12,6 +12,7 @@ run's output) belongs in that machine's private runbook. | --- | --- | --- | --- | | `bin/` and `contextmd/` at commit `` | `~/.local/share/contextmd/deployed//` (a copy from `git archive`, never a link into a checkout) | user, `0755` directories, `0644` files, `0755` for `bin/contextmd-serve` | none; each run starts a new process | | `deploy/health/com.toolboxmd.contextmd-health.plist.template` | `~/Library/LaunchAgents/com.toolboxmd.contextmd-health.plist`, with the placeholders filled | user, `0644` | `launchctl bootout` and `bootstrap` of this label only | +| `deploy/health/contextmd-health.service.template` and `contextmd-health.timer.template` (Linux) | `~/.config/systemd/user/contextmd-health.service` and `.timer`, with the placeholders filled | user, `0644` | `systemctl --user` on these two units only; never `contextmd-cue.service` | | configuration (no secrets): `{"machine": "