generated from coulomb/repo-seed
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:
@@ -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)")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user