{{ message }}
Repository navigation
Four delivery rules for harness notices - #722
Open
oguzhangulturk wants to merge 3 commits into
Open
oguzhangulturk wants to merge 3 commits into
oguzhangulturk wants to merge 3 commits into
Conversation
Contributor
NoticeGate holds the rules for messages the app itself writes into an inbox: dedup by signature persisted in <hive>/notice-ledger.json, re-arm only when the caller says the condition cleared, hold non-urgent notices while the recipient waits on the human, and nothing to an archived or unknown recipient. WaitTracker derives the wait from hook events. HiveManager.sendNotice / flushNotices / rearmNotices apply it. send() and routeMessage are unchanged: agent-authored mail never passes the gate. 20 tests in test/notice-gate.test.cjs, two of them on that boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce gate - reengageGod: signature quiet or actionable:<ids>, re-armed once god drained the heartbeat or those ids. - breaker steer and constrain: signature by action and reason class, re-armed when the agent is healthy again; constrain is urgent. - informGod worker reports (preserved, reclaimed, reaped) keyed by worker id and re-armed when that id spawns again; spawn rejections keyed by the request file's name and mtime. - HookServer passes tool_name to its observer, which feeds WaitTracker; the worker wake beat flushes held notices first. Message texts, cadence, scheduler missions, webhook, closing time and the spawn objective are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An idle Notification clears a wait, so a permission prompt or a question closed with Esc (no PostToolUse) cannot hold notices until the next prompt or a restart. - rearm also drops HELD notices whose condition cleared, so a steer held while the agent waited is not delivered after it turned healthy. - An assertion that a PreToolUse of another tool clears the wait. - god is never dead, also before a god is registered; the ledger prunes past-TTL entries before every write, not only on load. notice-gate tests 20 -> 23; each new assertion goes red with its fix line removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
oguzhangulturk
force-pushed
the
feat/delivery-rules
branch
from
October 9, 2026 09:24
2b64273 to
2706822
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What & why
The harness writes notices into agents' inboxes on its own: the heartbeat to
god, circuit breaker steer and constrain, worker and spawn reports. Today the
same notice arrives again after a restart, a stale one can arrive after its
cause is gone, one can land while the agent sits at a permission prompt or an
open question, and one can go to an agent that no longer exists. This adds a
small notice gate (
src/main/noticeGate.ts,HiveManager.sendNotice) with fourrules, applied to harness-generated notices only:
once per recipient, and a restart does not repeat it.
condition says the cause is gone (no timer); a held notice whose cause
cleared while it waited is dropped, never delivered as stale news.
while an agent sits at a permission prompt or an AskUserQuestion, and
released when the wait ends on evidence (hook events), not on a clock.
Only breaker constrain is urgent and goes at once.
or archived is dropped and logged; god is never treated as dead.
Agent-written mail and
HiveManager.sendare untouched; only the call siteslisted under Scope move to
sendNotice. Message texts, the breaker's levels,the heartbeat cadence, ASK-ME, spawn requests, scheduler missions, webhook,
Slack and closing time are unchanged.
Type of change
Evidence
No visible UI, so the evidence is a terminal capture: the same five steps on a
temp floor (god plus one agent), run once against
mainand once against thisbranch. Same script, same data.
Before
main: the heartbeat reaches god twice across a restart, and the breakersteer lands while the agent waits at a permission prompt.
After
This branch: the repeated heartbeat is a
duplicate(god's inbox holds one),the steer is
deferred during the prompt and delivered once when it ends.How I tested it
npm run typecheck: clean.npm run test:focused: 987 pass, 0 fail; 23 of them new intest/notice-gate.test.cjs(each rule on the pure gate and on a realHiveManagerfloor in a temp home, a restart included, theWaitTrackerclassifications, and two boundary tests that hold agent mail and
sendunchanged).
npm run build: succeeds.mainand against this branch.Credit (optional)
Discord:
X:
Checklist
npm run typecheckpasses.npm run test:focusedpasses.npm run buildsucceeds.commented-out code, or unrelated formatting churn in it.
DESIGN.md/tokens.ts, no ad-hoc colors,spacing, or fonts. (No UI in this change.)
ATTRIBUTION.md. (No art in this change.)Design notes
Scope, state, signatures, hook points, tests
Scope
The rules bind HARNESS-GENERATED notices only: messages the app itself writes
into an inbox. They never touch a message an agent wrote by hand.
heartbeatsrc/main/index.ts(reengageGod)breakersrc/main/index.ts(runBreakerBeat)breakersrc/main/index.ts(runBreakerBeat)ephemeral-workersrc/main/index.ts(viainformGod)ephemeral-workersrc/main/index.ts(viainformGod)ephemeral-workersrc/main/index.ts(viainformGod)ephemeral-workersrc/main/index.ts(viainformGod)ephemeral-workersrc/main/index.ts(viainformGod)All worker and spawn notices go through
informGod(src/main/index.ts).Left out on purpose, each named:
routeOnce(src/main/hive.ts) torouteMessagetodeliver. Untouched.src/main/index.ts): that is adispatch, not a notice.
src/main/index.ts): operator-configured recurringrequests; a signature would kill every repeat of a standup by design.
src/main/index.ts, the webhookhive.sendafter
addTask): a human's message.src/main/closingTime.ts): an operatorflow that waits for ACKs.
src/main/reflect.ts): it is alog.jsonlevent onlyand never reaches an inbox today, so there is nothing to gate. If it ever
becomes a notice, it takes kind
condensewith key<agentId>:<reason>.HiveManager.send(src/main/hive.ts) keeps its behaviour: the gate is aNEW method beside it,
sendNotice, and only the call sites above move to it.Where the state lives
One file under the hive root:
<hive>/notice-ledger.json, written atomically(tmp file, rename). Never renderer state.
{ "version": 1, "delivered": { "<agentId>": { "<signature>": { "at": "<iso>", "ctx": ["..."] } } }, "deferred": [ { "to": "<agentId>", "sig": "...", "notice": {}, "payload": {}, "from": "breaker", "at": "<iso>" } ] }NOTICE_LEDGER_TTL_MS) is pruned on load ANDbefore every write. Until then the set stays small for two
reasons. A heartbeat or breaker signature leaves on re-arm. A worker or
spawn signature carries a worker id or a request file, so there are at most
as many as there were workers and requests in 30 days.
NOTICE_DEFER_CAP). The oldest isevicted and logged.
one repeated notice; an agent's message is never lost.
root, so a changed harness home gets its own ledger.deliveris logged tolog.jsonlas kindnotice:duplicate,defer,dead,evictedorrearmed, with its signature.Signature per notice kind
The signature is
<kind>:<key>, kept per recipient.heartbeatquietheartbeatheartbeatactionable:<sha1 of the sorted actionable ids, 16 hex>, ctx = the idsbreakersteer:<reason class>healthyagainbreakerconstrain:<reason class>workerpreserved:<workerId>,reclaimed:<workerId>,reaped-token-cap:<workerId>,reaped-idle:<workerId>, ctx = [workerId]liveWorkers.set)spawn-rejected<file basename>@<mtime ms>:<reason>(or:unparseable)The reason class is the breaker reason with its figures removed: the text
before the first colon, digits stripped, lower case (
breakerReasonClass).So "looping: 7x" and "looping: 9x" are one finding, and "error storm" is a
different one.
The four rules and their hook points
Rule 1: signature dedup, persisted
NoticeGate.decidereturnsduplicatewhen the signature is on theledger for that recipient or is already held for it.
NoticeGate.markDeliveredrecords it after the send.HiveManager.sendNotice, a new method placed right aftersend(
src/main/hive.ts).(
src/main/breaker.ts,tick) is in memory and startshealthyafter arestart, which is where a repeat came from.
Rule 2: re-arm only once the condition is cleared
NoticeGate.rearm(to, kind, cleared)drops a signature only when thecaller's predicate says its condition is gone. There is no timer.
while it waited. Example: a steer held while the agent waited on
the human, after the agent has turned healthy again. It is dropped, never
delivered as stale news.
reengageGod(src/main/index.ts), before the send,measured against god's live inbox;
runBreakerBeat, just beforeif (d.action === 'none') continue;(src/main/index.ts), ond.state.level === 'healthy';liveWorkers.set(src/main/index.ts).Rule 3: defer non-urgent notices while the recipient waits for input
boundary to its observer (
src/main/hooks.ts). The observer now alsogets
tool_name, the only change inhooks.ts.WaitTracker(new, insrc/main/noticeGate.ts) marks an agent waitingwhen:
NotificationthatclassifyHook(src/main/workerWake.ts) callsneedsHumanarrives, which is a permission prompt;PreToolUseofAskUserQuestionarrives, which is an open question.PostToolUse,UserPromptSubmit,Stop,SubagentStop,SessionStartorPreCompact;PreToolUseof any other tool;forgetat PTY teardown, besideworkerWake.forget(
src/main/index.ts);Notificationof classidle. This is the exit when aprompt or question is closed with Esc, which sends no
PostToolUse. Theagent then sits at the idle prompt, so a held notice cannot wait for the
next prompt or a restart.
wait: that agent is free.
WORKER_WAKE_HITL_REARM_MS,src/main/workerWake.ts), the wait ends on evidence, not on a clock.waitTrackersits besideworkerWake(src/main/index.ts).The HookServer observer feeds both.
hive.setWaitingProbe(id => waitTracker.isWaiting(id))connects it.hive.flushNoticesat the top ofrunWorkerWakeBeat(
src/main/index.ts). That is before the nudge pass, so a releasednotice is announced in the same beat.
breaker constrainis urgent. It is written into theinbox at once. The typed wake nudge still keeps its own HITL guard
(
WorkerWakeWatchdog.decide, and the renderer's guarded queue), so nothingis ever typed into an open permission dialog. That guard is unchanged.
sender). It gets its message id when it is sent.
Rule 4: nothing to dead sessions
hive.noticeFacts). The recipient is deadwhen it is missing from
registry.agentsor hasarchived: true(
src/main/hive.ts;setArchivedat teardown and on a closed tab).holds for the registry's
godIdand for the literalgod, also before anygod is registered.
about a dead agent's loop is not news for anyone.
flushNoticesand logged withheld: true.(
routeMessage,src/main/hive.ts).What must not change (and the test that holds it)
two outbox messages with the same subject, while god counts as waiting and a
heartbeat is held. Both arrive with their body and subject intact, and a
second
routeOnceroutes 0.HiveManager.sendstays ungated. The test sends the same request twice:two inbox files, and no ledger is created.
steer and constrain text is gated. A
stopstill kills the PTY.still backs off, as a sent one does.
Tests
test/notice-gate.test.cjsadds 23 tests in the fork's own harness(
node --test,test/load-ts.cjs). They cover each rule on the pure gate, eachrule on a real
HiveManagerfloor in a temp home (restart included), theWaitTrackerclassifications, and the two boundary tests above. Each of the fivefollow-up assertions (the Esc exit, a PreToolUse of another tool clearing the wait, the
held drop, prune before a write, god never dead) goes red when its fix line is
removed.
🤖 Generated with Claude Code