Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/agentos/skills/bundled/senior-unilp-manager/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> [--steps 30,60,100]
python3 "$S"/ratchet.py arm --token-id <id> --steps 30,60,100 --confirm <MANDATE_HASH>
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 <m> | list | disarm --id <m>
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <m>` for the record, then `clear-attention --id <m>` once the position is confirmed — add `--token-id <new>` 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 <m> --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 |
Expand Down
63 changes: 61 additions & 2 deletions src/agentos/skills/bundled/senior-unilp-manager/scripts/ratchet.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,13 @@
[--max-principal-per-fire <human>] [--max-fee-per-gas <wei>] [--expires-days <n>]
Dry run prints the mandate table and a MANDATE_HASH; re-run with
--confirm <MANDATE_HASH> to arm it.
tick [--id <m> | --all] [--broadcast] [--json]
tick [--id <m> | --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 <m> [--json]
list [--json]
disarm --id <m>
Expand Down Expand Up @@ -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"))

Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = (
Expand Down
Loading