feat(WARDEN-WP-0021): T3-T5 — visibility, approve loop, runbook (scheduled worker complete)

T4 (review→send loop): conservative tick persists structured drafts to
state_dir/worker-drafts.json; `warden worker drafts` lists them, `warden worker approve
<id> [--body …]` sends the reviewed draft as the reply + marks read + drops it. Escalated
plans persist no draft. Live-verified end-to-end.

T3 (visibility): `warden worker status` (pending drafts, triage count, last digest, timer
state); best-effort notify-send nudge in the tick when drafts are pending.

T5: wiki/playbooks/scheduled-worker.md (enable/disable, the approve loop, failure modes,
conservative-only posture) + SCOPE note.

WARDEN-WP-0021 finished: the conservative worker now runs on a systemd --user timer
(enabled, every 15 min), triages new inbox messages into drafts you approve with one
command, degrades gracefully, and stops with one command. 249 tests, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 15:24:10 +02:00
parent 9dc1db0162
commit a10bbd2162
7 changed files with 253 additions and 6 deletions

View File

@@ -1212,3 +1212,42 @@ def worker_run(
else:
console.print("[green]Conservative triage[/green] — drafting; nothing sent to other agents.")
console.print(run_conservative(plans, hub, topic_id=topic_id))
@worker_app.command("drafts")
def worker_drafts() -> None:
"""List the worker's pending drafted replies (from the conservative tier)."""
from warden.worker import list_drafts
console.print(list_drafts())
@worker_app.command("approve")
def worker_approve(
message_id: Annotated[str, typer.Argument(help="Message id to send the drafted reply for")],
body: Annotated[
Optional[str], typer.Option("--body", help="Override the drafted reply text before sending")
] = None,
) -> None:
"""Send a reviewed draft as the reply and mark the message read."""
from warden.worker import HubClient, approve_draft
try:
console.print(approve_draft(message_id, HubClient(), body_override=body))
except Exception as e: # noqa: BLE001 — surface transport errors cleanly
err.print(f"[red]Approve failed:[/red] {e}")
raise typer.Exit(1)
@worker_app.command("status")
def worker_status_cmd() -> None:
"""Show worker state: pending drafts, triage count, last digest, timer status."""
import subprocess
from warden.worker import worker_status
console.print(worker_status())
try:
st = subprocess.run(
["systemctl", "--user", "is-active", "ops-warden-worker.timer"],
capture_output=True, text=True, timeout=5,
).stdout.strip()
console.print(f"timer : {st or 'unknown'}")
except Exception: # noqa: BLE001 — systemd may be absent (cron/other host)
console.print("timer : (systemd not available)")

View File

@@ -364,6 +364,92 @@ def save_seen(state_dir: Path, seen: set) -> None:
(state_dir / "worker-seen.json").write_text(_json.dumps(sorted(seen)))
def _re_subject(subject: str) -> str:
return subject if subject.lower().startswith("re:") else f"Re: {subject}"
def _draftable_body(plan: WorkerPlan) -> Optional[str]:
"""The reply text a plan would send, if any (route_answer or reply with a body)."""
for a in plan.actions:
if a.risk != "safe":
continue
if a.kind == "route_answer" and a.payload.get("answer"):
return a.payload["answer"]
if a.kind == "reply" and a.payload.get("body"):
return a.payload["body"]
return None
def load_drafts(state_dir: Path) -> dict:
import json as _json
p = state_dir / "worker-drafts.json"
if not p.exists():
return {}
try:
d = _json.loads(p.read_text())
return d if isinstance(d, dict) else {}
except (ValueError, OSError):
return {}
def save_drafts(state_dir: Path, drafts: dict) -> None:
import json as _json
(state_dir / "worker-drafts.json").write_text(_json.dumps(drafts, indent=2))
def list_drafts(state_dir: Optional[Path] = None) -> str:
drafts = load_drafts(state_dir or default_state_dir())
if not drafts:
return "no pending drafts."
lines: List[str] = []
for mid, d in drafts.items():
lines.append(f"{mid}{d.get('to_agent')}: {d.get('subject')}")
body = (d.get("body") or "").replace("\n", " ")
lines.append(f" {body[:140]}{'' if len(body) > 140 else ''}")
return "\n".join(lines)
def approve_draft(
message_id: str, hub: HubClient, *, state_dir: Optional[Path] = None,
body_override: Optional[str] = None,
) -> str:
"""Send a reviewed draft as the reply + mark the message read, then drop the draft."""
state_dir = state_dir or default_state_dir()
drafts = load_drafts(state_dir)
d = drafts.get(message_id)
if not d:
return f"no pending draft for {message_id} (try `warden worker drafts`)."
hub.send_reply(
to_agent=d["to_agent"], subject=d["subject"],
body=body_override if body_override is not None else d["body"],
thread_id=d.get("thread_id"),
)
hub.mark_read(message_id)
drafts.pop(message_id, None)
save_drafts(state_dir, drafts)
return f"sent reply to {d['to_agent']} ({d['subject']}) and marked read."
def worker_status(state_dir: Optional[Path] = None) -> str:
"""Operator-facing state of the worker: drafts, triage count, digest location."""
import datetime as _dt
state_dir = state_dir or default_state_dir()
drafts = load_drafts(state_dir)
seen = load_seen(state_dir)
digest = state_dir / "worker-digest.md"
when = ""
if digest.exists():
when = _dt.datetime.fromtimestamp(digest.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
return "\n".join([
f"pending drafts : {len(drafts)} (warden worker drafts | approve <id>)",
f"triaged (seen) : {len(seen)}",
f"last digest : {when} {digest}",
])
def build_digest(plans: List[WorkerPlan]) -> str:
"""Human-reviewable digest of proposed actions + drafted replies. Sends nothing."""
if not plans:
@@ -403,6 +489,18 @@ def run_conservative(
new = [p for p in plans if p.message_id and p.message_id not in seen]
digest = build_digest(new)
(state_dir / "worker-digest.md").write_text(digest + "\n")
# Persist structured drafts so `warden worker approve` can send a reviewed one.
drafts = load_drafts(state_dir)
for p in new:
if p.escalated:
continue
body = _draftable_body(p)
if body:
drafts[p.message_id] = {
"to_agent": p.from_agent, "subject": _re_subject(p.subject),
"body": body, "thread_id": p.raw.get("thread_id") or p.message_id,
}
save_drafts(state_dir, drafts)
if new:
n_esc = sum(1 for p in new if p.escalated)
try: