feat(systemd): add and enable continuous tmux-auto-approver user daemon

This commit is contained in:
operator
2026-10-07 00:49:23 +00:00
parent 34b0ef9fe2
commit e25d2cf4cc
12 changed files with 915 additions and 62 deletions
+298 -3
View File
@@ -53,6 +53,11 @@ POLL_INTERVAL = 0.5
STABILITY_POLLS = 2
MAX_ANSWERS_PER_HOUR = 20
ANSWERED_TTL_SECONDS = 600 # identical prompt back after 10m => stuck, allow one recovery answer
RULES_FILE = os.path.join(REPO_ROOT, "muse-choices-rules.json")
HOLD_WINDOW_SECONDS = 120 # D3: short hold window, then expire to approve
NEGATIVE_KEYS = {"muse-approval": "2", "yn": "n"} # D2 deny keys
QUESTION_KINDS = frozenset({"interview", "letter", "numbered", "explicit-phrase"})
_RULES_CACHE = {"key": None, "rules": []}
LOG_MAX_BYTES = 1_000_000
HEARTBEAT_SECONDS = 60
TAIL_WINDOW = 25 # only prompts in the last N lines count (no stale scrollback)
@@ -420,7 +425,8 @@ def _find_muse_approval(window):
context = [ln.strip()[:120] for ln in window[cue_idx:no_idx + 1]]
return {"sig": _sig_for("muse-approval", context),
"kind": "muse-approval", "key": "1", "options": options,
"cue": cue, "start": cue_idx, "end": no_idx}
"cue": cue, "context": context,
"start": cue_idx, "end": no_idx}
def _find_collapsed_approval(window):
@@ -464,7 +470,7 @@ def _find_collapsed_approval(window):
return {"sig": _sig_for("muse-approval-collapsed", context),
"kind": "muse-approval-collapsed", "key": "Enter",
"enter": False, "options": options, "cue": cue,
"start": cue_idx, "end": end}
"context": context, "start": cue_idx, "end": end}
def _find_interview(window):
@@ -687,6 +693,27 @@ def pane_muse_argv(socket_path, pane_id):
MIN_APPROVAL_WIDTH = 40
MIN_APPROVAL_HEIGHT = 12
# Session naming convention (see NODES.md): <node>--<role>--<id>
# separates node runtimes on the shared stable server, e.g.
# pip--worker--01. Ad-hoc sessions carry no node and show "-".
NODE_NAMES = ("muse", "pip", "646", "opm", "def", "dev")
NODE_SESSION_RE = re.compile(r"^([A-Za-z0-9]+)--([A-Za-z0-9-]+)--([A-Za-z0-9]+)$")
def node_from_session(session_name):
"""Node owning a tmux session per the naming convention.
Returns the node name, or None for ad-hoc sessions and unknown
node prefixes. Pure function for supervision display.
"""
if not session_name:
return None
m = NODE_SESSION_RE.match(session_name)
if not m:
return None
node = m.group(1).lower()
return node if node in NODE_NAMES else None
def runtime_rows(socket_path):
"""One row per pane: identity, approval posture, live state, watcher.
@@ -720,6 +747,7 @@ def runtime_rows(socket_path):
squeezed = (pane_width is not None and pane_height is not None
and (pane_width < MIN_APPROVAL_WIDTH
or pane_height < MIN_APPROVAL_HEIGHT))
node = node_from_session(session)
is_muse = "muse-bin" in cmd or "muse-code" in cmd
posture = {"auto_approve": None, "flags": []}
if is_muse and pane_pid:
@@ -740,6 +768,7 @@ def runtime_rows(socket_path):
"socket": socket_path, "session": session,
"window": window, "pane": pane_id, "cmd": cmd,
"pid": pane_pid, "is_muse": is_muse,
"node": node,
"width": pane_width, "height": pane_height,
"squeezed": squeezed,
"auto_approve": posture["auto_approve"],
@@ -882,6 +911,214 @@ def send_answer(socket_path, pane_id, letter="A", enter=True):
return False
def _load_rules(log=None):
"""Load the D0 rules dictionary, cached by (mtime, size). Never raises:
a missing or broken file means no rules (fail open per D4)."""
global _RULES_CACHE
try:
st = os.stat(RULES_FILE)
key = (st.st_mtime, st.st_size)
except OSError:
key = "missing"
if _RULES_CACHE["key"] == key:
return _RULES_CACHE["rules"]
rules = []
if key != "missing":
try:
with open(RULES_FILE) as f:
data = json.load(f)
rules = [r for r in data.get("rules", [])
if isinstance(r, dict) and r.get("id")]
except Exception as e:
if log is not None:
try:
log.log("warn", "rules file unreadable, failing open",
error="%r" % (e,))
except Exception:
pass
rules = []
_RULES_CACHE = {"key": key, "rules": rules}
return rules
def _as_list(v):
return v if isinstance(v, list) else [v]
def _rule_matches(rule, match):
kind = rule.get("kind")
if kind is not None and match["kind"] not in _as_list(kind):
return False
token = rule.get("token")
if token is not None:
if match["kind"] != "explicit-phrase":
return False
if match["key"] not in _as_list(token):
return False
cmd = rule.get("command")
if cmd is not None:
ctx = match.get("context") or match.get("options", [])
try:
if not re.search(cmd, "\n".join(ctx)):
return False
except re.error:
return False
text = rule.get("text")
if text is not None:
hay = "\n".join(match.get("options", []) + [match.get("cue") or ""])
try:
if not re.search(text, hay):
return False
except re.error:
return False
return True
def evaluate_rules(match, log=None):
"""First matching rule wins -> (decision, rule). No match -> approve.
D2: deny rules are skipped for question kinds (questions always
resolve top-choice). Collapsed approvals have no visible No option,
so deny downgrades to hold there rather than auto-approving
something a rule flagged risky.
"""
for rule in _load_rules(log=log):
if not _rule_matches(rule, match):
continue
d = rule.get("decision", "approve")
if d not in ("approve", "deny", "hold"):
d = "approve"
if d == "deny":
if match["kind"] in NEGATIVE_KEYS:
return "deny", rule
if match["kind"] in QUESTION_KINDS:
continue
downgraded = dict(rule)
downgraded["downgraded_from"] = "deny"
return "hold", downgraded
return d, rule
return "approve", None
def holdfile_for(socket_path, pane_id):
return os.path.join(
STATE_DIR, "%s-%s-%s.held.json" % (FILE_PREFIX, slug_socket(socket_path),
clean_pane(pane_id)))
def write_hold(socket_path, pane_id, rec):
try:
with open(holdfile_for(socket_path, pane_id), "w") as f:
json.dump(rec, f)
return True
except Exception:
return False
def read_hold(socket_path, pane_id):
try:
with open(holdfile_for(socket_path, pane_id)) as f:
rec = json.load(f)
return rec if isinstance(rec, dict) and rec.get("sig") else None
except Exception:
return None
def clear_hold(socket_path, pane_id):
try:
os.remove(holdfile_for(socket_path, pane_id))
except OSError:
pass
def list_holds():
"""All holdfiles, pruning ones long expired with no watcher to own them."""
out = []
try:
names = os.listdir(STATE_DIR)
except OSError:
return out
now = time.time()
for name in names:
if not name.startswith(FILE_PREFIX + "-") or not name.endswith(".held.json"):
continue
path = os.path.join(STATE_DIR, name)
try:
with open(path) as f:
rec = json.load(f)
except Exception:
continue
if not isinstance(rec, dict) or not rec.get("sig"):
continue
try:
expired_ago = now - float(rec.get("held_until", 0))
except (TypeError, ValueError):
expired_ago = 0
if expired_ago > 300:
try:
os.remove(path)
except OSError:
pass
continue
rec["_file"] = name
out.append(rec)
return out
def _check_hold(socket_path, pane_id, state, match, now, log):
"""Rule-hold gate. Returns None (fresh: evaluate rules), "released"
(hold over: approve WITHOUT re-evaluating, else expiry would
re-hold forever), "held", or "denied".
The holdfile is the single source of truth (crash-safe,
box-visible): present + fresh => suppress; directive deny => deny
now; expired or operator-cleared => release back to approve.
"""
sig = match["sig"]
hf = read_hold(socket_path, pane_id)
if hf is None:
return None
if hf.get("sig") != sig:
clear_hold(socket_path, pane_id) # stale hold for a gone prompt
return None
if hf.get("directive") == "approve":
clear_hold(socket_path, pane_id)
log.log("info", "hold released by operator, answering", sig=sig)
return "released"
if hf.get("directive") == "deny":
neg = NEGATIVE_KEYS.get(match["kind"])
if neg is None:
hf["directive"] = None
write_hold(socket_path, pane_id, hf)
log.log("warn", "resolve-deny refused: D2 never denies questions",
sig=sig, kind=match["kind"])
return "held"
ok = send_answer(socket_path, pane_id, neg, enter=True)
clear_hold(socket_path, pane_id)
state.record_answer(sig, now)
log.log("info" if ok else "error", "denied %s (operator resolve)" % neg,
sig=sig, ok=ok, kind=match["kind"], key=neg)
audit("muse-choice-denied",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id, "sig": sig,
"ok": ok, "kind": match["kind"], "key": neg,
"via": "resolve"})
return "denied"
try:
expired = now >= float(hf.get("held_until", 0))
except (TypeError, ValueError):
expired = True
if expired:
clear_hold(socket_path, pane_id)
log.log("info", "hold expired, releasing to approve", sig=sig)
audit("muse-choice-hold-expired",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id, "sig": sig,
"kind": match["kind"], "rule": hf.get("rule")})
return "released"
return "held"
def _poll_once(socket_path, pane_id, state, log, dry_run=False):
"""Run one poll iteration. Returns an outcome string ("gone" tells the
caller to exit). Logs only state transitions, never per-poll spam."""
@@ -893,6 +1130,14 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
return "capture-failed"
match = find_choice_prompt(text)
now = time.time()
gate = _check_hold(socket_path, pane_id, state, match, now, log) \
if match is not None else None
if gate in ("held", "denied"):
if gate == "denied":
state.pending_sig = None
state.stable_count = 0
return gate
skip_eval = (gate == "released")
verdict = state.observe(match, now)
if verdict == "wait" and state.stable_count == 1:
log.log("info", "prompt seen", kind=match["kind"], key=match["key"],
@@ -915,7 +1160,56 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
sig=match["sig"], kind=match["kind"])
state.record_answer(match["sig"], now)
return "held"
if skip_eval:
decision, rule = "approve", None
via = "hold-released"
else:
decision, rule = evaluate_rules(match, log)
via = "direct"
key = match["key"]
if decision == "deny":
neg = NEGATIVE_KEYS[match["kind"]]
if dry_run:
log.log("info", "dry-run would deny %s" % neg,
sig=match["sig"], kind=match["kind"], key=neg,
rule=rule["id"] if rule else None)
state.record_answer(match["sig"], now)
return "dry-denied"
ok = send_answer(socket_path, pane_id, neg, enter=True)
state.record_answer(match["sig"], now)
log.log("info" if ok else "error", "denied %s" % neg,
sig=match["sig"], ok=ok, kind=match["kind"], key=neg,
rule=rule["id"] if rule else None)
audit("muse-choice-denied",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id,
"sig": match["sig"], "ok": ok, "kind": match["kind"],
"key": neg, "via": "rule",
"rule": rule["id"] if rule else None})
return "denied"
if decision == "hold":
until = now + HOLD_WINDOW_SECONDS
write_hold(socket_path, pane_id, {
"sig": match["sig"], "kind": match["kind"], "key": key,
"text": (match["cue"] or "")[:200],
"rule": rule["id"] if rule else None,
"reason": (rule.get("reason") if rule else None) or "",
"downgraded_from": (rule.get("downgraded_from")
if rule else None),
"held_until": until, "directive": None,
"socket": socket_path, "pane": pane_id})
log.log("info", "held by rule %s" % (rule["id"] if rule else "?"),
sig=match["sig"], kind=match["kind"],
rule=rule["id"] if rule else None,
reason=(rule.get("reason") if rule else None) or "")
if not dry_run:
audit("muse-choice-held",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id,
"sig": match["sig"], "kind": match["kind"],
"rule": rule["id"] if rule else None,
"held_until": until})
return "held"
if dry_run:
log.log("info", "dry-run would answer %s" % key,
sig=match["sig"], kind=match["kind"], key=key,
@@ -933,7 +1227,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
extra={"socket": socket_path, "pane": pane_id,
"sig": match["sig"], "ok": ok, "kind": match["kind"],
"key": key, "options": match["options"],
"cue": match["cue"]})
"cue": match["cue"], "via": via,
"rule": rule["id"] if rule else None})
return "answered"
if verdict == "capped":
if state.last_capped_sig != match["sig"]: