Four delivery rules for harness notices by oguzhangulturk · Pull Request #722 · HarnessMD/munder-difflin · GitHub
Skip to content

Four delivery rules for harness notices - #722

Open
oguzhangulturk wants to merge 3 commits into
HarnessMD:mainfrom
oguzhangulturk:feat/delivery-rules
Open

oguzhangulturk wants to merge 3 commits into
HarnessMD:mainfrom
oguzhangulturk:feat/delivery-rules

Conversation

@oguzhangulturk

@oguzhangulturk oguzhangulturk commented Oct 9, 2026 •

Copy link
Copy Markdown

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 four
rules, applied to harness-generated notices only:

  1. Signature dedup, persisted: the same notice (kind plus key) is delivered
    once per recipient, and a restart does not repeat it.
  2. Re-arm only once cleared: a signature is dropped only when the caller's
    condition says the cause is gone (no timer); a held notice whose cause
    cleared while it waited is dropped, never delivered as stale news.
  3. Defer while the recipient waits for input: non-urgent notices are held
    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.
  4. Nothing to dead sessions: a notice to an agent missing from the registry
    or archived is dropped and logged; god is never treated as dead.

Agent-written mail and HiveManager.send are untouched; only the call sites
listed 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

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs
  • Build / CI

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 main and once against this
branch. Same script, same data.

Before

before

main: the heartbeat reaches god twice across a restart, and the breaker
steer lands while the agent waits at a permission prompt.

After

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

  • OS: Omarchy Linux (Arch based, kernel 7.2), Node 26.8.2
  • Steps:
    • npm run typecheck: clean.
    • npm run test:focused: 987 pass, 0 fail; 23 of them new in
      test/notice-gate.test.cjs (each rule on the pure gate and on a real
      HiveManager floor in a temp home, a restart included, the WaitTracker
      classifications, and two boundary tests that hold agent mail and send
      unchanged).
    • npm run build: succeeds.
    • The evidence script above, against main and against this branch.

Credit (optional)

Discord:

X:

Checklist

  • Before and after evidence is attached above, under both headings.
  • npm run typecheck passes.
  • npm run test:focused passes.
  • npm run build succeeds.
  • This PR is one change. Unrelated fixes belong in their own PR.
  • I read the diff myself before opening this, and there is no debug output,
    commented-out code, or unrelated formatting churn in it.
  • Any new UI derives from DESIGN.md / tokens.ts, no ad-hoc colors,
    spacing, or fonts. (No UI in this change.)
  • If I added art, it's my own or compatibly licensed, and listed in
    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.

Notice Sender Recipient Produced in
Heartbeat heartbeat god src/main/index.ts (reengageGod)
Circuit breaker: steer breaker the agent src/main/index.ts (runBreakerBeat)
Circuit breaker: constrain breaker the agent src/main/index.ts (runBreakerBeat)
Worker worktree preserved ephemeral-worker god src/main/index.ts (via informGod)
Worker worktree reclaimed ephemeral-worker god src/main/index.ts (via informGod)
Worker reaped, token cap ephemeral-worker god src/main/index.ts (via informGod)
Worker reaped, idle ephemeral-worker god src/main/index.ts (via informGod)
Worker spawn rejected (2 sites) ephemeral-worker god src/main/index.ts (via informGod)

All worker and spawn notices go through informGod (src/main/index.ts).

Left out on purpose, each named:

  • Agent-authored mail: routeOnce (src/main/hive.ts) to
    routeMessage to deliver. Untouched.
  • God's spawn objective to a new worker (src/main/index.ts): that is a
    dispatch, not a notice.
  • Scheduler missions (src/main/index.ts): operator-configured recurring
    requests; a signature would kill every repeat of a standup by design.
  • Webhook and Slack inbound (src/main/index.ts, the webhook hive.send
    after addTask): a human's message.
  • Closing time (src/main/closingTime.ts): an operator
    flow that waits for ACKs.
  • ASK-ME answers and spawn requests: unchanged.
  • Condense-abort (src/main/reflect.ts): it is a log.jsonl event only
    and never reaches an inbox today, so there is nothing to gate. If it ever
    becomes a notice, it takes kind condense with key <agentId>:<reason>.

HiveManager.send (src/main/hive.ts) keeps its behaviour: the gate is a
NEW 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>" } ] }
  • An entry older than 30 days (NOTICE_LEDGER_TTL_MS) is pruned on load AND
    before 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.
  • At most 50 held notices per recipient (NOTICE_DEFER_CAP). The oldest is
    evicted and logged.
  • If the ledger is corrupt or unwritable, delivery goes on. The worst case is
    one repeated notice; an agent's message is never lost.
  • The gate follows root, so a changed harness home gets its own ledger.
  • Every verdict except deliver is logged to log.jsonl as kind notice:
    duplicate, defer, dead, evicted or rearmed, with its signature.

Signature per notice kind

The signature is <kind>:<key>, kept per recipient.

Kind Key Condition that re-arms it (rule 2) Urgent
heartbeat quiet god's inbox holds no undrained message from heartbeat no
heartbeat actionable:<sha1 of the sorted actionable ids, 16 hex>, ctx = the ids none of those ids is still in god's inbox no
breaker steer:<reason class> the breaker reports the agent healthy again no
breaker constrain:<reason class> the same yes
worker preserved:<workerId>, reclaimed:<workerId>, reaped-token-cap:<workerId>, reaped-idle:<workerId>, ctx = [workerId] a worker with that id is spawned again (liveWorkers.set) no
spawn-rejected <file basename>@<mtime ms>:<reason> (or :unparseable) never needed: a new request file has a new mtime, so it is a new attempt and a new signature; the TTL bounds the file no

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

  • Gate: NoticeGate.decide returns duplicate when the signature is on the
    ledger for that recipient or is already held for it.
    NoticeGate.markDelivered records it after the send.
  • Hook: HiveManager.sendNotice, a new method placed right after send
    (src/main/hive.ts).
  • Survives a restart: the ledger is a file. The breaker's own escalation latch
    (src/main/breaker.ts, tick) is in memory and starts healthy after a
    restart, which is where a repeat came from.

Rule 2: re-arm only once the condition is cleared

  • Gate: NoticeGate.rearm(to, kind, cleared) drops a signature only when the
    caller's predicate says its condition is gone. There is no timer.
  • The same call also drops a HELD notice of that kind whose condition cleared
    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.
  • Hooks:
    • heartbeat: in reengageGod (src/main/index.ts), before the send,
      measured against god's live inbox;
    • breaker: in runBreakerBeat, just before if (d.action === 'none') continue; (src/main/index.ts), on d.state.level === 'healthy';
    • worker: after liveWorkers.set (src/main/index.ts).

Rule 3: defer non-urgent notices while the recipient waits for input

  • "Waiting" is known in the main process already. HookServer feeds every hook
    boundary to its observer (src/main/hooks.ts). The observer now also
    gets tool_name, the only change in hooks.ts.
  • WaitTracker (new, in src/main/noticeGate.ts) marks an agent waiting
    when:
    • a Notification that classifyHook (src/main/workerWake.ts) calls
      needsHuman arrives, which is a permission prompt;
    • or a PreToolUse of AskUserQuestion arrives, which is an open question.
  • The wait clears on:
    • the next PostToolUse, UserPromptSubmit, Stop, SubagentStop,
      SessionStart or PreCompact;
    • a PreToolUse of any other tool;
    • forget at PTY teardown, beside workerWake.forget
      (src/main/index.ts);
    • a Notification of class idle. This is the exit when a
      prompt or question is closed with Esc, which sends no PostToolUse. The
      agent then sits at the idle prompt, so a held notice cannot wait for the
      next prompt or a restart.
  • The idle prompt ("waiting for your input" after a finished turn) is NOT a
    wait: that agent is free.
  • Unlike the watchdog's five-minute window (WORKER_WAKE_HITL_REARM_MS,
    src/main/workerWake.ts), the wait ends on evidence, not on a clock.
  • Wiring: waitTracker sits beside workerWake (src/main/index.ts).
    The HookServer observer feeds both.
    hive.setWaitingProbe(id => waitTracker.isWaiting(id)) connects it.
  • Release: hive.flushNotices at the top of runWorkerWakeBeat
    (src/main/index.ts). That is before the nudge pass, so a released
    notice is announced in the same beat.
  • Urgent goes anyway: only breaker constrain is urgent. It is written into the
    inbox at once. The typed wake nudge still keeps its own HITL guard
    (WorkerWakeWatchdog.decide, and the renderer's guarded queue), so nothing
    is ever typed into an open permission dialog. That guard is unchanged.
  • A held notice is sent later byte for byte as it was built (subject, body,
    sender). It gets its message id when it is sent.

Rule 4: nothing to dead sessions

  • "Dead" comes from the registry (hive.noticeFacts). The recipient is dead
    when it is missing from registry.agents or has archived: true
    (src/main/hive.ts; setArchived at teardown and on a closed tab).
  • God is never dead: god is the bounce target and the human's proxy. This
    holds for the registry's godId and for the literal god, also before any
    god is registered.
  • A dead notice is dropped and logged. It is NOT bounced to god: a notice
    about a dead agent's loop is not news for anyone.
  • A held notice whose recipient died while it waited is dropped at
    flushNotices and logged with held: true.
  • Unchanged: an agent's own mail to an unknown id still bounces to god
    (routeMessage, src/main/hive.ts).

What must not change (and the test that holds it)

  • Agent-authored mail is delivered exactly once and unchanged. The test sends
    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 routeOnce routes 0.
  • HiveManager.send stays ungated. The test sends the same request twice:
    two inbox files, and no ledger is created.
  • No change to:
    • the ASK-ME flow, spawn requests and spawn objectives;
    • scheduler missions, webhook, Slack and closing time;
    • the hop cap, the bounces and the message log.
  • The breaker's levels and actions are unchanged; only the delivery of its
    steer and constrain text is gated. A stop still kills the PTY.
  • The heartbeat cadence and back-off are unchanged. A suppressed heartbeat
    still backs off, as a sent one does.
  • Message texts are unchanged: same subjects, same bodies.

Tests

test/notice-gate.test.cjs adds 23 tests in the fork's own harness
(node --test, test/load-ts.cjs). They cover each rule on the pure gate, each
rule on a real HiveManager floor in a temp home (restart included), the
WaitTracker classifications, and the two boundary tests above. Each of the five
follow-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

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Oguzhan Gulturk and others added 3 commits October 9, 2026 11:57
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant