feat(main-loop): digest response protocol — actionable digests, reply verbs, closure metrics

Session: sidechat/main-loop-protocol
This commit is contained in:
operator-main
2026-10-05 00:48:38 +00:00
parent 664b4da952
commit 86f0082ffc
3 changed files with 461 additions and 22 deletions
+71 -14
View File
@@ -4,8 +4,9 @@ response-harvester.py — Fleet agent readback and response harvesting daemon.
Monitors Chromebox agents (muse, pip, 646, opm), harvests incoming messages from Monitors Chromebox agents (muse, pip, 646, opm), harvests incoming messages from
Main Chat and registered sidechats, maintains persistent watermarks, appends to Main Chat and registered sidechats, maintains persistent watermarks, appends to
chat-history.jsonl, resolves pending follow-ups, and records [RESULT] completions chat-history.jsonl, resolves pending follow-ups (verb-aware: [ACK|CLAIM] ->
in job-log.jsonl. acknowledged, [RESULT|DECLINE|NO-ACTION] -> resolved, outcome recorded), and
records [RESULT] completions in job-log.jsonl.
Features: Features:
- Direct CDP over host veth interfaces (fast, no sudo needed). - Direct CDP over host veth interfaces (fast, no sudo needed).
@@ -83,6 +84,20 @@ def iter_result_markers(text):
yield m.group(1).strip(), m.group(2).strip() yield m.group(1).strip(), m.group(2).strip()
# Verb markers for the digest response protocol:
# [ACK|CLAIM|RESULT|DECLINE|NO-ACTION <job_id>].
# ACK/CLAIM acknowledge a digest (nudge-suppressed, NOT closed);
# RESULT/DECLINE/NO-ACTION close the digest. Every verb match records
# outcome=<verb> on the followup record.
VERB_RE = re.compile(r"\[(ACK|CLAIM|RESULT|DECLINE|NO-ACTION)\s+([A-Za-z0-9_-]+)\]")
def iter_verb_markers(text):
"""Yield (verb, job_id) for every [VERB <job_id>] marker in text."""
for m in VERB_RE.finditer(text or ""):
yield m.group(1), m.group(2).strip()
def utcnow(): def utcnow():
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
@@ -366,7 +381,8 @@ def harvest_main_feed(cdp, agent, watermarks, followups, dry_run=False):
if author == "assistant": if author == "assistant":
markers = list(iter_result_markers(text)) markers = list(iter_result_markers(text))
if markers: verbs = list(iter_verb_markers(text))
if markers or verbs:
for job_id, result_text in markers: for job_id, result_text in markers:
is_fail = is_fail_result(result_text) is_fail = is_fail_result(result_text)
job_results += 1 job_results += 1
@@ -385,7 +401,12 @@ def harvest_main_feed(cdp, agent, watermarks, followups, dry_run=False):
append_jsonl(JOB_LOG, job_record) append_jsonl(JOB_LOG, job_record)
trigger_chain_next(job_id, result_text, success=not is_fail) trigger_chain_next(job_id, result_text, success=not is_fail)
clear_matching_followups(followups, agent, "main", mid, text, clear_matching_followups(followups, agent, "main", mid, text,
dry_run, job_id=job_id) dry_run, job_id=job_id, verb="RESULT")
for verb, job_id in verbs:
if verb == "RESULT":
continue # resolved via the result-marker path above
clear_matching_followups(followups, agent, "main", mid, text,
dry_run, job_id=job_id, verb=verb)
else: else:
clear_matching_followups(followups, agent, "main", mid, text, dry_run) clear_matching_followups(followups, agent, "main", mid, text, dry_run)
@@ -487,7 +508,8 @@ def harvest_agent_thread(cdp, agent, thread_info, watermarks, followups, dry_run
if author == "assistant": if author == "assistant":
markers = list(iter_result_markers(text)) markers = list(iter_result_markers(text))
if markers: verbs = list(iter_verb_markers(text))
if markers or verbs:
for job_id, result_text in markers: for job_id, result_text in markers:
is_fail = is_fail_result(result_text) is_fail = is_fail_result(result_text)
job_results += 1 job_results += 1
@@ -507,7 +529,12 @@ def harvest_agent_thread(cdp, agent, thread_info, watermarks, followups, dry_run
# Trigger pipeline chaining or next job if configured # Trigger pipeline chaining or next job if configured
trigger_chain_next(job_id, result_text, success=not is_fail) trigger_chain_next(job_id, result_text, success=not is_fail)
clear_matching_followups(followups, agent, thread_id, mid, text, clear_matching_followups(followups, agent, thread_id, mid, text,
dry_run, job_id=job_id) dry_run, job_id=job_id, verb="RESULT")
for verb, job_id in verbs:
if verb == "RESULT":
continue # resolved via the result-marker path above
clear_matching_followups(followups, agent, thread_id, mid, text,
dry_run, job_id=job_id, verb=verb)
else: else:
clear_matching_followups(followups, agent, thread_id, mid, text, dry_run) clear_matching_followups(followups, agent, thread_id, mid, text, dry_run)
@@ -611,7 +638,7 @@ def trigger_chain_next(job_id, result_text, success=True):
def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=False, def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=False,
job_id=None): job_id=None, verb=None):
"""Resolve follow-up records if an assistant message is detected in the thread. """Resolve follow-up records if an assistant message is detected in the thread.
Matches on thread identity (thread_uuid or target='main') OR on job_id Matches on thread identity (thread_uuid or target='main') OR on job_id
@@ -619,13 +646,27 @@ def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=Fal
thread_uuid or target, fixing ghost followups with null thread_uuid. thread_uuid or target, fixing ghost followups with null thread_uuid.
Also matches a main-chat reply when the sweeper recorded Also matches a main-chat reply when the sweeper recorded
final_nudge_target='main' (final nudge routed to main chat). final_nudge_target='main' (final nudge routed to main chat).
When verb is given ([ACK|CLAIM|RESULT|DECLINE|NO-ACTION <job_id>]),
matching is job_id-scoped for non-RESULT verbs (a verb marker names the
digest it answers, so it must not touch unrelated pending followups that
merely share the thread); RESULT keeps the historical thread-or-job
matching. Every verb match records outcome=<verb>. ACK/CLAIM set status
'acknowledged' (nudge-suppressed, NOT closed) instead of 'resolved', and
also match already-'acknowledged' records so an ACK -> RESULT lifecycle
closes correctly.
""" """
if not followups: if not followups:
return return
modified = False modified = False
for f_id, f_rec in followups.items(): for f_id, f_rec in followups.items():
if f_rec.get("status") != "pending": # Verb replies can follow an ACK (ACK -> RESULT lifecycle), so verbs
# also match 'acknowledged' records; plain replies only match pending.
if verb:
if f_rec.get("status") not in ("pending", "acknowledged"):
continue
elif f_rec.get("status") != "pending":
continue continue
if f_rec.get("recipient") != agent: if f_rec.get("recipient") != agent:
continue continue
@@ -642,17 +683,33 @@ def clear_matching_followups(followups, agent, thread_id, mid, text, dry_run=Fal
# sidechat. # sidechat.
match_thread = True match_thread = True
# Match by job_id (from [RESULT <job_id>]) -- works regardless of # Match by job_id (from [RESULT <job_id>] or [VERB <job_id>]) --
# thread_uuid or target. This is an ADDITIONAL path, not a replacement. # works regardless of thread_uuid or target. This is an ADDITIONAL
# path, not a replacement.
match_job = False match_job = False
if job_id and f_rec.get("job_id") and f_rec.get("job_id") == job_id: if job_id and f_rec.get("job_id") and f_rec.get("job_id") == job_id:
match_job = True match_job = True
# Non-RESULT verbs are job-scoped: they must not acknowledge/resolve
# unrelated pending followups that merely share the thread. RESULT
# keeps the historical thread-or-job matching.
if verb and verb != "RESULT" and not match_job:
continue
if match_thread or match_job: if match_thread or match_job:
f_rec["status"] = "resolved" if verb:
f_rec["resolved_at"] = utcnow() f_rec["outcome"] = verb
f_rec["resolved_by_mid"] = mid if verb in ("ACK", "CLAIM"):
f_rec["resolved_snippet"] = text[:150] # Acknowledged: sweeper nudges stop (status != pending), but
# the digest is NOT closed until a closing verb arrives.
f_rec["status"] = "acknowledged"
f_rec["acknowledged_at"] = utcnow()
f_rec["acknowledged_by_mid"] = mid
else:
f_rec["status"] = "resolved"
f_rec["resolved_at"] = utcnow()
f_rec["resolved_by_mid"] = mid
f_rec["resolved_snippet"] = text[:150]
modified = True modified = True
if modified and not dry_run: if modified and not dry_run:
+177 -8
View File
@@ -37,6 +37,7 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import time
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -44,11 +45,20 @@ BASE = "/home/super/Projects/NetVM"
BIN = os.path.join(BASE, "bin") BIN = os.path.join(BASE, "bin")
if BIN not in sys.path: if BIN not in sys.path:
sys.path.insert(0, BIN) sys.path.insert(0, BIN)
# Deterministic per-agent jittered sleeps — de-correlates within-run
# traffic across the shared egress IP (see rate_limiter.py).
try:
from rate_limiter import effective_interval as _effective_interval
HAS_RATE_LIMITER = True
except ImportError:
HAS_RATE_LIMITER = False
BOX_CHAT = os.path.join(BIN, "box-chat.py") BOX_CHAT = os.path.join(BIN, "box-chat.py")
DM_PY = os.path.join(BIN, "dm.py") DM_PY = os.path.join(BIN, "dm.py")
STATE_FILE = os.path.join(BASE, "self-main-loop-watermark.json") STATE_FILE = os.path.join(BASE, "self-main-loop-watermark.json")
LOCK_FILE = os.path.join(BASE, "self-main-loop.lock") LOCK_FILE = os.path.join(BASE, "self-main-loop.lock")
STATE_LOCK_FILE = os.path.join(BASE, "self-main-loop-state.lock") STATE_LOCK_FILE = os.path.join(BASE, "self-main-loop-state.lock")
FOLLOWUPS_FILE = os.path.join(BASE, "followups.json")
DEFAULT_AGENTS = ["muse", "pip", "646", "opm"] DEFAULT_AGENTS = ["muse", "pip", "646", "opm"]
# Prompting sidechat per agent — mirrors box-ctl.py NOTIFY_SIDECHATS. # Prompting sidechat per agent — mirrors box-ctl.py NOTIFY_SIDECHATS.
@@ -63,6 +73,7 @@ READ_LIMIT = 30
READ_TIMEOUT = 120 READ_TIMEOUT = 120
SEND_TIMEOUT = 180 SEND_TIMEOUT = 180
DIGEST_MAX = 600 # well under dm.py's 1000-char non-raw truncation DIGEST_MAX = 600 # well under dm.py's 1000-char non-raw truncation
INTER_NODE_SLEEP_BASE = 7.0 # s between per-agent passes; jittered per agent
PREVIEW_MAX = 120 PREVIEW_MAX = 120
# Word-boundary "operator" that does NOT match hyphenated identities like # Word-boundary "operator" that does NOT match hyphenated identities like
@@ -239,10 +250,69 @@ def get_monitored_sidechats(agent):
return monitored return monitored
def classify_digest(digest):
"""Return (actionable, urgent) for a composed digest.
Actionable = digest carries a (?) question or (!) operator-needed marker.
Urgent = digest carries the (!) marker.
"""
urgent = " (!)" in digest
actionable = urgent or " (?)" in digest
return actionable, urgent
def make_digest_id(agent):
"""Stable digest ID: ml-<agent>-<YYYYMMDD-HHMMSS> (UTC)."""
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
return "ml-%s-%s" % (agent, ts)
# In-band response-contract footer for ACTIONABLE digests. The verbs are matched
# by response-harvester.py to resolve followups: ACK/CLAIM acknowledge (nudge
# suppression), RESULT/DECLINE/NO-ACTION close. ~94 chars, well under budget.
CONTRACT_FOOTER = ("Reply: [ACK id] seen | [CLAIM id] mine | "
"[RESULT id] done | [DECLINE id] | [NO-ACTION id]")
def send_prompt(sender, agent, sidechat, digest): def send_prompt(sender, agent, sidechat, digest):
"""Post the digest to the agent's prompting sidechat. """Post the digest to the agent's prompting sidechat.
Tries fast direct gateway send via muse_hybrid first, falling back to dm.py.
Actionable digests (carrying (?) or (!)) go via dm.py with --expect-reply
so the followup machinery tracks them; the digest gets a [JOB <id>] tag
which dm.py auto-extracts into the followup record. Informational digests
try the fast gateway first, falling back to dm.py without followup flags.
Returns (sent, detail, digest_id, actionable).
""" """
actionable, urgent = classify_digest(digest)
digest_id = make_digest_id(agent) if actionable else None
if actionable:
# Embed [JOB id] for dm.py's job_id extraction -> followup record,
# and append the response-contract footer. Reserve space for both so
# the tagged digest stays within DIGEST_MAX and dm.py never truncates
# the footer (or the job id).
head = "[JOB %s]\n" % digest_id
room = DIGEST_MAX - len(head) - len(CONTRACT_FOOTER) - 1
body = digest if len(digest) <= room else digest[:room].rstrip()
tagged = head + body + "\n" + CONTRACT_FOOTER
timeout = 1800 if urgent else 3600
cmd = [sys.executable, DM_PY, "send",
"--agent", sender, "--to", agent, "--target", sidechat,
"--expect-reply", "--reply-timeout", str(timeout),
"--reply-nudges", "2", tagged]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=SEND_TIMEOUT)
except subprocess.TimeoutExpired:
return False, "dm.py send timed out after %ss" % SEND_TIMEOUT, digest_id, True
except OSError as e:
return False, "could not exec dm.py: %s" % e, digest_id, True
if r.returncode != 0:
tail = ((r.stderr or "") + (r.stdout or "")).strip()[-300:]
return False, "dm.py send failed rc=%d: %s" % (r.returncode, tail), digest_id, True
return True, "sent (dm.py, expect-reply)", digest_id, True
# Informational: fast gateway first, dm.py fallback without followup flags.
target_uuid = sidechat target_uuid = sidechat
try: try:
import dm import dm
@@ -257,7 +327,7 @@ def send_prompt(sender, agent, sidechat, digest):
send_node = agent if agent in DEFAULT_AGENTS else sender send_node = agent if agent in DEFAULT_AGENTS else sender
res, err = muse_hybrid.send_message(send_node, digest, thread_id=target_uuid, wait=0) res, err = muse_hybrid.send_message(send_node, digest, thread_id=target_uuid, wait=0)
if res and not err: if res and not err:
return True, "sent (gateway)" return True, "sent (gateway)", None, False
log("Gateway send fallback for %s/%s due to: %s" % (agent, sidechat, err or res)) log("Gateway send fallback for %s/%s due to: %s" % (agent, sidechat, err or res))
except Exception as e: except Exception as e:
log("Gateway send exception for %s/%s: %s" % (agent, sidechat, e)) log("Gateway send exception for %s/%s: %s" % (agent, sidechat, e))
@@ -268,13 +338,13 @@ def send_prompt(sender, agent, sidechat, digest):
try: try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=SEND_TIMEOUT) r = subprocess.run(cmd, capture_output=True, text=True, timeout=SEND_TIMEOUT)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return False, "dm.py send timed out after %ss" % SEND_TIMEOUT return False, "dm.py send timed out after %ss" % SEND_TIMEOUT, None, False
except OSError as e: except OSError as e:
return False, "could not exec dm.py: %s" % e return False, "could not exec dm.py: %s" % e, None, False
if r.returncode != 0: if r.returncode != 0:
tail = ((r.stderr or "") + (r.stdout or "")).strip()[-300:] tail = ((r.stderr or "") + (r.stdout or "")).strip()[-300:]
return False, "dm.py send failed rc=%d: %s" % (r.returncode, tail) return False, "dm.py send failed rc=%d: %s" % (r.returncode, tail), None, False
return True, "sent (dm.py)" return True, "sent (dm.py)", None, False
def check_sidechats(agent, cfg, wm, threads_meta): def check_sidechats(agent, cfg, wm, threads_meta):
@@ -472,7 +542,17 @@ def do_check(only_agent=None):
import muse_hybrid import muse_hybrid
first_agent = True
for agent in agents: for agent in agents:
if not first_agent:
# Stagger per-node passes: timer staggering doesn't help within
# a run. Deterministic per-agent jitter keeps runs reproducible
# while drifting each node's phase apart.
sleep_s = (INTER_NODE_SLEEP_BASE * _effective_interval(agent, 1.0)
if HAS_RATE_LIMITER else INTER_NODE_SLEEP_BASE)
log("%s: inter-node sleep %.1fs" % (agent, sleep_s))
time.sleep(sleep_s)
first_agent = False
if not enabled.get(agent, True): if not enabled.get(agent, True):
results[agent] = {"ok": True, "new": 0, "disabled": True} results[agent] = {"ok": True, "new": 0, "disabled": True}
continue continue
@@ -519,9 +599,12 @@ def do_check(only_agent=None):
errors += 1 errors += 1
continue continue
sent, detail = send_prompt(cfg["sender"], agent, sidechat, digest) sent, detail, digest_id, actionable = send_prompt(cfg["sender"], agent, sidechat, digest)
if sent: if sent:
prompted += 1 prompted += 1
if digest_id:
wm["last_digest_id"] = digest_id
wm["last_digest_actionable"] = actionable
log("%s: prompted %s with %d new (%s)" % (agent, sidechat, total_new, detail)) log("%s: prompted %s with %d new (%s)" % (agent, sidechat, total_new, detail))
results[agent] = {"ok": True, "new": total_new, "prompted": sidechat, "detail": detail} results[agent] = {"ok": True, "new": total_new, "prompted": sidechat, "detail": detail}
else: else:
@@ -571,6 +654,91 @@ def _set_enabled(agent, value):
return {"ok": True, "enabled": {a: enabled.get(a, True) for a in targets}} return {"ok": True, "enabled": {a: enabled.get(a, True) for a in targets}}
# Digest protocol job-id prefix: actionable main-loop digests embed
# [JOB ml-<agent>-<YYYYMMDD-HHMMSS>]. Informational digests create no
# followup at all, so every ml- followup is actionable by construction
# and informational digests are excluded from all rates.
DIGEST_JOB_PREFIX = "ml-"
# Reply verbs that acknowledge without closing (nudge-suppressed).
ACK_VERBS = {"ACK", "CLAIM"}
# Reply verbs that close the digest.
CLOSE_VERBS = {"RESULT", "DECLINE", "NO-ACTION"}
def _followup_job_id(rec):
tags = rec.get("tags") or {}
return tags.get("job_id") or rec.get("job_id") or ""
def _followup_is_stale(rec, now):
if rec.get("status") == "escalated":
return True
if rec.get("status") == "pending":
try:
dl = datetime.fromisoformat(
(rec.get("deadline") or "").replace("Z", "+00:00"))
return dl < now
except (ValueError, TypeError):
return False
return False
def digest_health():
"""Digest loop-closure metrics from followups.json.
Counts only actionable digests (job_id starting with 'ml-').
Reads the existing followups.json - no new state file, keeping
self-main-loop-watermark.json as the single status source.
"""
now = datetime.now(timezone.utc)
health = {"delivered": 0, "acked": 0, "closed": 0, "stale": 0,
"closure_rate": None, "by_agent": {}}
try:
with open(FOLLOWUPS_FILE) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, ValueError):
return health
records = data.values() if isinstance(data, dict) else data
for rec in records:
if not isinstance(rec, dict):
continue
job_id = _followup_job_id(rec)
if not job_id.startswith(DIGEST_JOB_PREFIX):
continue # not a main-loop digest: excluded from all rates
agent = rec.get("recipient") or "?"
per = health["by_agent"].setdefault(
agent, {"delivered": 0, "acked": 0, "closed": 0, "stale": 0})
health["delivered"] += 1
per["delivered"] += 1
outcome = (rec.get("outcome") or "").upper()
status = rec.get("status") or ""
is_acked = status == "acknowledged" or outcome in ACK_VERBS
# resolved counts as closed unless the outcome was only an ACK/CLAIM
# (legacy pre-protocol resolutions have no outcome field).
is_closed = status == "resolved" and outcome not in ACK_VERBS
if is_acked:
health["acked"] += 1
per["acked"] += 1
if is_closed:
health["closed"] += 1
per["closed"] += 1
if _followup_is_stale(rec, now):
health["stale"] += 1
per["stale"] += 1
if health["delivered"]:
health["closure_rate"] = round(
health["closed"] / health["delivered"], 4)
for per in health["by_agent"].values():
per["closure_rate"] = (round(per["closed"] / per["delivered"], 4)
if per["delivered"] else None)
return health
def do_status(): def do_status():
st = load_state() st = load_state()
cfg = get_config(st) cfg = get_config(st)
@@ -580,7 +748,8 @@ def do_status():
"enabled": cfg.get("enabled") or {}, "enabled": cfg.get("enabled") or {},
"watermark": agents_state, "watermark": agents_state,
"last_run": st.get("last_run"), "last_run": st.get("last_run"),
"last_result": st.get("last_result")} "last_result": st.get("last_result"),
"digest_health": digest_health()}
def main(argv): def main(argv):
+213
View File
@@ -0,0 +1,213 @@
# Digest Protocol Runbook
How the main loop sends digests and how agents close them.
**Status:** spec (2026-10-04). Implemented in `self_main_loop.py` + `response-harvester.py`;
no new machinery — the protocol wires digests into the existing `dm.py --expect-reply`
/ `followups.json` / harvester response path.
---
## 1. Why this protocol exists
Main-loop digests (`[main-loop] N new in <agent> main chat: ...`) were being sent
with no response contract: no stable ID, no declared timeout, no nudge/escalation
policy, and no way to distinguish "seen and closed" from "ignored". Across 43
digests there were zero RESULT replies and zero measurable outcomes — not because
agents ignored them, but because nothing defined how to answer.
This protocol closes that wiring gap without inventing a new system:
- digest composer → `dm.py send --expect-reply` (followup record, timeout/nudges
declared at send time)
- agent replies in-band with a verb: `[ACK|CLAIM|RESULT|DECLINE|NO-ACTION <id>]`
- `response-harvester.py` matches by `job_id` and records the outcome in
`followups.json`
- the sweeper nudges/escalates only unresolved followups, per the declared policy
## 2. Digest classes
Not every digest deserves a reply demand (notification fatigue; and the standing
rule that the human only sees real decisions and escalations). The composer
already marks `[?]` (question) and `[!]` (operator-needed / urgent).
| Class | Marker | Response required? | Followup record? | Measured by |
|---|---|---|---|---|
| **ACTIONABLE** | carries `[?]` or `[!]` | **yes** | yes | delivered → acked → closed |
| **INFORMATIONAL** | neither | no | no | delivered only |
Informational digests are sent exactly as today. They are **excluded from every
reply-rate metric** — an informational digest is measured as *delivered*, full stop.
Judging informational FYI traffic by answer rate is structurally misleading.
## 3. Digest ID
Every ACTIONABLE digest gets a stable ID:
```
ml-<agent>-<YYYYMMDD-HHMMSS>
```
Example: `ml-646-20261004-213000`.
The ID is embedded visibly in the digest as `[JOB ml-...]`. This marker survives
`extract_trailing_tags` (only known canonical keys are stripped) and is picked up
by `dm_send`'s job_id regex (`\[JOB\s+([A-Za-z0-9_-]+)\]`) anywhere in the message,
so it lands in `tags["job_id"]` → the followup record. **No new dm.py flags are
needed for the ID.**
Replies match on this ID, so attribution games with `[from:X]` can never break
resolution.
## 4. Reply verbs
Printed in the contract footer of every ACTIONABLE digest (Section 7).
| Verb | Syntax | Meaning | Followup effect |
|---|---|---|---|
| `ACK` | `[ACK <id>]` | Seen, noted | status `acknowledged` — stops nudges, **not closed** |
| `CLAIM` | `[CLAIM <id>]` | I'm handling it | status `acknowledged` — stops nudges, **not closed** |
| `RESULT` | `[RESULT <id>] <result text>` | Done, result attached | status `resolved`, outcome `RESULT` — **closed** |
| `DECLINE` | `[DECLINE <id>]` | Won't act (reason optional) | status `resolved`, outcome `DECLINE` — **closed** |
| `NO-ACTION` | `[NO-ACTION <id>]` | Reviewed, nothing needed | status `resolved`, outcome `NO-ACTION` — **closed** |
Semantics:
- `ACK` vs `CLAIM`: both acknowledge. `CLAIM` signals ownership; `ACK` is just
"seen". Use `CLAIM` when you're picking up the work so the human knows who's on it.
- `NO-ACTION` is a real answer, not a dodge. It means "I reviewed this and there
is genuinely nothing to do." It closes the digest so it stops being counted as open.
- `DECLINE` should carry a reason when one exists (`[DECLINE ml-646-...] blocked:
chromebox flapping`), but a bare DECLINE is still a valid close.
- `RESULT` closes only when the result text is present. An empty RESULT is a malformed
reply — the harvester treats it as unrecognized.
Nudge interaction: `ACK`/`CLAIM` suppress further nudges but leave the followup open
until a closing verb arrives. This matches the real workflow — "I'm on it" shouldn't
trigger nags, but the loop isn't closed until there's a result.
## 5. Timeout policy
Declared at send time (standing rule: policy is part of the send, not decided later).
| Digest class | Timeout | Nudges |
|---|---|---|
| normal (marked `[?]`) | 60 min | 2 |
| urgent (marked `[!]`) | 30 min | 2 |
Implemented as `--reply-timeout {3600|1800} --reply-nudges 2` on the `dm.py send`
invocation for ACTIONABLE digests. The timeout and nudge count are stored in the
followup record; the sweeper honors them.
## 6. Nudge / escalation policy
- The sweeper nudges only followups with `status == "pending"`. Anything
`acknowledged` or `resolved` is never re-nudged (ACKed digests getting nagged
trains agents to ignore the protocol — verify this when wiring the sweeper).
- After the declared nudges are exhausted and the timeout passes, the followup is
**escalated**, not retried forever:
- **Phase 1:** the sweeper's existing escalation path (DM to the escalate target
declared at send time via `--reply-escalate`).
- **Phase 2:** route to the registered `main-loop brain` sidechat for triage.
The brain is registered in `job-sidechats.json` as `main-loop brain` and is
name-resolvable, so no hardcoded UUIDs are needed.
- `stale` = still `pending` past timeout + nudges → escalated.
Digest routing must use sidechat **names** (`646 tasks`, `main-loop brain`) via the
existing name resolution — never hardcoded thread UUIDs (see the stale-UUID lesson
in AGENTS.md).
Note: the harvester only monitors threads registered in `job-sidechats.json`.
`opm/heartbeat` is not registered (digests there resolve via fuzzy search), so
digest replies there would never resolve. **Route opm's digests to the registered
`main-loop brain` sidechat instead of `heartbeat`** — cleaner separation
(brain = triage workspace, heartbeat = heartbeats) and zero new registrations.
## 7. In-band contract footer
The digest budget is 600 chars, so the footer is compact. Actionable digests trim
message previews from 5 to 3 to fit it:
```
[main-loop] [JOB ml-646-20261004-213000] 3 new in 646 main chat — reply needed:
- human: <preview> [?]
Reply: [ACK id] seen | [CLAIM id] mine | [RESULT id] done | [DECLINE id] | [NO-ACTION id]
```
The footer is the primary contract. This doc is the reference.
## 8. Example exchange
```
# opm → 646 tasks (ACTIONABLE, --expect-reply --reply-timeout 3600 --reply-nudges 2)
[from:opm] [id:a6579a8d] [main-loop] [JOB ml-646-20261004-213000] 3 new in 646 main chat — reply needed:
- human: should we archive the stale pipe-7d2896 thread? [?]
- opm: propose yes, it's superseded by the brain channel [?]
Reply: [ACK id] seen | [CLAIM id] mine | [RESULT id] done | [DECLINE id] | [NO-ACTION id]
# 646 picks it up
[from:646] [CLAIM ml-646-20261004-213000] reviewing now
→ followup status: acknowledged (nudges suppressed, loop still open)
# 646 finishes
[from:646] [RESULT ml-646-20261004-213000] archived pipe-7d2896 via muse-cli; brain channel is canonical
→ followup status: resolved, outcome: RESULT (loop closed)
```
A shorter close:
```
# pip sees an informational-with-a-question, reviews, nothing to do
[from:pip] [NO-ACTION ml-pip-20261004-214500] reviewed — nothing needed, timer stagger already handles it
→ followup status: resolved, outcome: NO-ACTION (loop closed)
```
## 9. Metrics
Source of truth: `followups.json` (`outcome` field, written by the harvester).
| Metric | Definition |
|---|---|
| **delivered** | `dm.py` reported `verified:true` with placement confirmed for the digest message (see dm-log) |
| **acked** | outcome ∈ {`ACK`, `CLAIM`} |
| **closed** | outcome ∈ {`RESULT`, `DECLINE`, `NO-ACTION`} |
| **stale** | still `pending` past timeout + nudges → escalated |
| **closure_rate** | `closed / actionable_delivered` |
Rules:
- Informational digests are excluded from every rate. They are measured as
delivered, full stop.
- Actionable digests are measured as closed. A digest that is ACKed but never
resolved counts as open until a closing verb arrives.
- `delivered` requires the placement confirmation, not just the `verified:true`
flag (see the AGENTS.md placement-blindness lesson).
Reporting: `self_main_loop.py` `status` exposes `digest_health()` counts
(delivered/acked/closed/stale), surfaced through the existing Box
`main-loop/status` API. No new endpoint needed in phase 1.
## 10. Implementation notes
- `dm.py` — **no changes.** `--expect-reply`, job_id extraction from `[JOB …]`,
followup registration, and placement verification already exist and are tested.
- `response-harvester.py` — extend the reply regex to verb-aware matching:
`\[(ACK|CLAIM|RESULT|DECLINE|NO-ACTION)\s+([A-Za-z0-9_-]+)\]`; on match, resolve
by job_id and record `outcome`. `ACK`/`CLAIM` → `acknowledged`;
`RESULT`/`DECLINE`/`NO-ACTION` → `resolved` + outcome.
- `self_main_loop.py` — `compose_digest` classifies actionable vs informational;
actionable embeds `[JOB ml-…]` + footer; `send_prompt` passes
`--expect-reply --reply-timeout {3600|1800} --reply-nudges 2` for actionable only.
- Sweeper — verify it nudges only `status == "pending"` (no code change expected).
All changes follow review-then-commit. No new daemons, no new state files beyond
the existing `followups.json`.
---
*Companion docs: `DM-SPEC.md` (DM format), `THREAD-BOOKKEEPING.md` (pin/archive),
`WARP-EGRESS-FIX.md` (partition handling).*