diff --git a/src/agentos/skills/bundled/senior-unilp-manager/SKILL.md b/src/agentos/skills/bundled/senior-unilp-manager/SKILL.md index 8496e8de..faa24f5e 100644 --- a/src/agentos/skills/bundled/senior-unilp-manager/SKILL.md +++ b/src/agentos/skills/bundled/senior-unilp-manager/SKILL.md @@ -469,7 +469,7 @@ would buy the token back if the price came down, and this deliberately gives tha ```bash python3 "$S"/ratchet.py arm --token-id [--steps 30,60,100] python3 "$S"/ratchet.py arm --token-id --steps 30,60,100 --confirm -python3 "$S"/ratchet.py tick --all --broadcast --json # what cron runs +python3 "$S"/ratchet.py tick --all --broadcast --json --alert-only # what cron runs python3 "$S"/ratchet.py status --id | list | disarm --id ``` @@ -614,6 +614,25 @@ cron(action="add", schedule={"kind": "every", "every_seconds": 600}, job_kind="script", script="ratchet-tick.sh", session_target="isolated") ``` +Write `--alert-only` into that script, alongside `--json`. Without it every tick delivers the +full result of every mandate, which on a healthy ratchet is a block of JSON saying nothing +happened, every ten minutes, until nobody reads it any more. With it a tick that found +nothing still prints the whole payload — the run history keeps it — but ends on the line +`{"wakeAgent": false}`, which the cron runner reads as "no news" and delivers nothing: + +```bash +#!/bin/sh +exec python3 ~/.agentos/skills/senior-unilp-manager/scripts/ratchet.py \ + tick --all --broadcast --json --alert-only +``` + +A tick that fired, adopted a landed fire, halted, was rejected, expired, or built a plan on a +dry run prints a one-line summary above that JSON and is delivered as usual. So does a +mandate sitting in `NEEDS_ATTENTION`, on **every** tick until a human clears it: that state +reports the same `noop` as a healthy mandate, and going quiet on it would be the one silence +that costs money. `--alert-only` only changes `--json` output; run by hand without it and the +command prints exactly what it always did. + A script job runs the file itself and never starts an agent turn, so it takes **no** `tool_policy` — `tool_policy.elevated` belongs to the `agent_turn` shape above and is rejected here. Both shapes need an interactive CLI or Web caller; neither can be scheduled @@ -658,6 +677,7 @@ different branches at every layer, and a hook can answer differently on each. | `mandate … does not hash to its own filename` | The state file was edited or truncated. Do not repair it by hand — `disarm` and arm a fresh mandate | | ratchet state `NEEDS_ATTENTION` | It refused to resolve an ambiguity alone. `status --id ` for the record, then `clear-attention --id ` once the position is confirmed — add `--token-id ` if the note says the replacement could not be identified | | ratchet action `deferred` | The node could not be read, so nothing was decided and nothing changed. Normal on a flaky RPC; investigate only if it repeats for hours | +| a `--alert-only` cron job has said nothing for hours | Expected — that is the flag working. The runs are still there: Cron → Run History holds the full JSON of every tick. A job that had actually stopped would show no runs at all, not quiet ones | | `journal holds an unreplayed … record` | The log has an event this build does not know. Almost always a downgrade — run the version that wrote it | | `… line N is corrupt … not a torn tail` | The write-ahead log was damaged mid-file. `status --id --json` still reads the mandate; the log needs a human before the runner will move | | `… is not a mandate id` | `--id` takes the 32 hex characters `list` prints, nothing else | diff --git a/src/agentos/skills/bundled/senior-unilp-manager/scripts/ratchet.py b/src/agentos/skills/bundled/senior-unilp-manager/scripts/ratchet.py index b9108a46..7160dac4 100644 --- a/src/agentos/skills/bundled/senior-unilp-manager/scripts/ratchet.py +++ b/src/agentos/skills/bundled/senior-unilp-manager/scripts/ratchet.py @@ -94,9 +94,13 @@ [--max-principal-per-fire ] [--max-fee-per-gas ] [--expires-days ] Dry run prints the mandate table and a MANDATE_HASH; re-run with --confirm to arm it. - tick [--id | --all] [--broadcast] [--json] + tick [--id | --all] [--broadcast] [--json] [--alert-only] Reconcile against the chain and fire any milestone that is due. Without --broadcast this is a full dry run of the transaction, and sends nothing. + --alert-only (with --json) ends a tick that found nothing on the gate line + {"wakeAgent": false}, so a cron script job records the run but delivers + no message. A tick that fired, halted, or needs a human prints a summary + above the JSON and is delivered as usual. status --id [--json] list [--json] disarm --id @@ -168,6 +172,42 @@ def _emit(payload: dict) -> None: print(json.dumps(payload, indent=2, default=str)) +# Actions that mean the mandate is exactly where it was left. A watchdog that +# reports these is reporting that it has nothing to report. +QUIET_ACTIONS = frozenset({"noop", "skipped", "waiting", "deferred"}) + + +def is_notable(outcome: dict) -> bool: + """Whether one tick outcome is worth waking a human for. + + NEEDS_ATTENTION is checked apart from the action on purpose: it is a terminal + state, so every tick after the halt reports a plain ``noop`` while the mandate + sits there waiting for a person. Filtering on the action alone would silence + the one alarm that must never go quiet. + """ + if outcome.get("state") == STATE_NEEDS_ATTENTION: + return True + return outcome.get("action") not in QUIET_ACTIONS + + +def tick_summary_line(outcome: dict) -> str: + """One human-readable line for a notable mandate, for a chat alert. + + The JSON below it carries everything; this is what someone reads on a phone. + """ + parts = [f"{str(outcome.get('action', '?')).upper()} {outcome['mandateId'][:8]}"] + state = outcome.get("state") + if state and state != STATE_ARMED: + parts.append(f"state={state}") + if outcome.get("tokenId"): + parts.append(f"#{outcome['tokenId']}") + if outcome.get("txHash"): + parts.append(f"tx {outcome['txHash']}") + detail = outcome.get("reason") or outcome.get("note") or "" + line = " ".join(parts) + return f"{line} — {detail}" if detail else line + + def _client(chain: dict, args: dict) -> RpcClient: return RpcClient(chain, args.get("rpc")) @@ -1248,7 +1288,26 @@ def cmd_tick(client, chain: dict, args: dict, signer: dict) -> None: results.append(outcome) if args.get("json"): - _emit({"chain": chain["key"], "broadcast": broadcast, "results": results}) + payload = {"chain": chain["key"], "broadcast": broadcast, "results": results} + if not args.get("alert-only"): + _emit(payload) + return + notable = [outcome for outcome in results if is_notable(outcome)] + payload["notable"] = len(notable) + payload["quiet"] = len(results) - len(notable) + if notable: + # Summary first, JSON last: the cron gate reads the final line, and an + # alert that must be delivered has to end in something that is not one. + # Pretty-printed JSON ends in "}", which no gate reader mistakes for a gate. + for outcome in notable: + print(tick_summary_line(outcome)) + print() + _emit(payload) + else: + _emit(payload) + # The run record keeps the whole payload for whoever goes looking; this + # last line is what keeps a tick with no news off the channel. + print(json.dumps({"wakeAgent": False})) return if not idents: diff --git a/src/agentos/skills/bundled/senior-unilp-manager/scripts/selftest.py b/src/agentos/skills/bundled/senior-unilp-manager/scripts/selftest.py index 3c63b843..d94f2c59 100644 --- a/src/agentos/skills/bundled/senior-unilp-manager/scripts/selftest.py +++ b/src/agentos/skills/bundled/senior-unilp-manager/scripts/selftest.py @@ -2217,6 +2217,57 @@ def ids(): shutil.rmtree(root, ignore_errors=True) +def _tier7_alert_gate(r) -> None: + """Which tick outcomes reach a channel under ``--alert-only``. + + Getting this wrong is silent in both directions: too loud and the watchdog is + ignored, too quiet and a mandate waiting for a human waits forever. The + NEEDS_ATTENTION row is the one that matters — it reports ``noop`` like every + other terminal state, and only the state tells it apart. + """ + import ratchet + + quiet = [ + ("a disarmed mandate", {"action": "noop", "state": "DISARMED"}), + ("a completed mandate", {"action": "noop", "state": "COMPLETE"}), + ("an armed mandate below its next threshold", + {"action": "noop", "state": "ARMED", "reason": "no milestone due"}), + ("a fire still waiting on a receipt", {"action": "waiting", "state": "FIRE_SENT"}), + ("a tick that lost the lock", {"action": "skipped", "state": None}), + ("a tick that could not read the node", {"action": "deferred", "state": "FIRE_SENT"}), + ] + for label, outcome in quiet: + r.check(f"alert gate stays silent for {label}", ratchet.is_notable(outcome), False) + + loud = [ + ("a mandate waiting for a human", {"action": "noop", "state": "NEEDS_ATTENTION"}), + ("a milestone that fired", {"action": "fired", "state": "ARMED"}), + ("a landed fire reconciled", {"action": "adopted", "state": "ARMED"}), + ("a mandate that halted", {"action": "halted", "state": "NEEDS_ATTENTION"}), + ("a plan the mandate refused", {"action": "rejected", "state": "ARMED"}), + ("a milestone due on a dry run", {"action": "dry-run", "state": "ARMED"}), + ("a mandate that expired", {"action": "expired", "state": "EXPIRED"}), + ("a mandate that vanished", {"action": "missing"}), + ("a tick that raised", {"action": "error"}), + ] + for label, outcome in loud: + r.check(f"alert gate speaks up for {label}", ratchet.is_notable(outcome), True) + + line = ratchet.tick_summary_line({ + "mandateId": "7857cce24384e26fb6bdcad73e68ee9a", "action": "fired", + "state": "ARMED", "tokenId": 476498, "txHash": "0xdead", + }) + r.check("a summary line names the mandate, position and transaction", + ("7857cce2" in line and "#476498" in line and "0xdead" in line + and line.startswith("FIRED")), True) + # The gate reads the last line of stdout; a summary that ended in one would + # silence the very alert it is announcing. + r.check("a summary line is not itself a gate line", + ratchet.tick_summary_line({"mandateId": "a" * 32, "action": "noop", + "state": "NEEDS_ATTENTION", + "note": "check the position"}).startswith("{"), False) + + def tier7(g: dict, r: Results) -> None: try: import lp_write # noqa: F401 @@ -2234,6 +2285,7 @@ def tier7(g: dict, r: Results) -> None: _tier7_state_machine(r) _tier7_duplicate_arm(r) _tier7_arm_is_idempotent(r) + _tier7_alert_gate(r) TIERS = (