harn-control-events
Make an agent session answer stop and steer promptly, kill what it started, and leave a record that names the control.
- License 许可
- Apache-2.0
- 所属仓库
- burin-labs/harn
- 最近更新
- 2026-11-11
- Stars
- ★ 31
- Forks
- 6
01 前言 Front Matter
- name
- harn-control-events
- short
- Stop, steer, and queue as events a session must answer, not requests it may ignore.
- description
- Make an agent session answer stop and steer promptly, kill what it started, and leave a record that names the control.
- when_to_use
- Use when adding or auditing cancel, stop, steer, interrupt, or queued-message handling, or when a stop appears to work but leaves work running or a record that does not say it happened.
03 SKILL.md 正文
Harn control events
A control event is a person telling a running session to stop or to change course. It is an event the session must answer, not a request it may finish its current plan first and then consider.
Pair this skill with [[harn-orchestration]] for where the loop drains injections, [[harn-agent]] for loop and terminal vocabulary, and [[harn-product-quality]] for what a host must show while a control is in flight.
The three things a stop has to do
A stop that does one or two of these is not a stop. Check all three separately, because the second and third fail silently while the first looks correct.
- The loop stops. No further iteration, model call, or tool dispatch after the control lands.
- What the session started dies. A cancelled agent that leaves a backgrounded command running has moved the work, not stopped it. Kill the handles owned by that session and no others.
- The record names the control. The run's terminal says a person stopped it. Without this, a stopped run and a run that merely produced nothing are the same row in every downstream report.
Point 3 is the one that rots. An accepted cancel is answered — the prompt
returns stopReason: "cancelled" — so the turn settles normally and any
tagging that fires only when a turn fails to settle never fires at all. Seal
the terminal from the observed control, not from whether the turn looked
finished.
Absence must not read as success
The dangerous shape is a control that names something that is not there.
- A cancel naming an unregistered session must be loud: warn, and emit a
ControlOutcomewithstatus: "rejected"naming the session. Consuming the frame silently makes "nothing was cancelled" indistinguishable from "everything was cancelled". - Every control decision, accepted or rejected, gets a
ControlOutcomecarrying method, outcome, status, actor, target, and reason. Read the structured record; do not grep prose for the verdict. - A record that a control arrived is not a record that the loop stopped. Assert the loop counters after the control, not the frame before it.
Delivery modes for a mid-turn message
Typing while a session works means one of three things, and a runtime that assumes one of them is wrong two-thirds of the time. Make it a setting.
| Mode | Wire value | Meaning |
|---|---|---|
| Steer | steer |
Redirect the running turn at its next iteration or tool boundary. |
| Interrupt | interrupt_immediate |
Deliver as soon as the loop will take it, ahead of the next tool. |
| Queue | queue |
Hold it and start a new turn with it once this one ends. |
Over ACP, steer and queue are session/prompt with delivery: "steer" or
"queue"; interrupt is in-VM only. A stop (session/cancel) ends only the
running turn: pending steers then open the next turn together and queued
prompts follow, one turn each. "Deliver now" is a steer followed by a stop.
Only $/cancel_request withdraws a pending prompt.
Give the setting a typed enum with one wire spelling per behavior. No aliases, and never fall back to a default in silence: a control policy that quietly reverts is one the operator believes is in force while it is not. Report the rejected value and the mode actually running.
Signals are controls too
A headless or supervised run answers SIGINT and SIGTERM by sending one
session/cancel on the running session — the same path an interactive stop
uses, because that is the path the runtime already terminates cleanly on.
- Race the signal at the frame-receive point, not around the whole drain, so a cancel does not discard the frames the turn already accumulated. The partial turn stays intact and still reports what it observed.
- Bound the post-cancel wait so a wedged engine cannot hold the process open after the operator said stop; a second signal skips the wait.
- Distinguish the two signals in the recorded reason. A supervisor winding a run down and an operator hitting Ctrl-C are different facts.
Verify
Deterministic tests for the invariants; one re-runnable end-to-end check for the claim.
- Positive: a cancel mid-loop leaves zero further iteration, model-call, and tool-dispatch events.
- Negative control, required: a cancel naming an unknown session must leave the live loop running. Without it, a fix that cancels everything passes the positive test.
- Blast radius: spawn two real background children on two sessions, cancel one, poll for that pid to die, and assert the other is still alive. Poll rather than reading once — the child is reaped a moment after the agent goes, and a single immediate reading reports a working stop as a leak.
- Identity: assert the id the background handles are keyed by equals the session id the control names. A kill on a mismatched id is a silent no-op that reads as success.
- End to end: keep a script that stops a real run and checks all three guarantees. Run it model-free with a mock provider so it stays free and anyone can re-run it.
04 同仓库更多 Skills
stream-fan-in
Stream trigger example for routing or grouping event batches.
github-stale-pr-nudger
Cron recipe for scanning repositories and nudging old pull requests.
webhook-generic-hmac
Generic webhook recipe with signed inbound requests.
slack-thread-summarizer
Slack webhook recipe with a local summarize predicate.
slack-reaction-action
Slack Events API recipe for reaction_added workflows.
slack-keyword-router
Slack webhook recipe for routing messages by keyword.