Skip to content

HEARTBEAT_OK never inherited #785's bare-sentinel stripping — narrated heartbeats post the sentinel #837

Description

@lilyshen0722

HEARTBEAT_OK never inherited the bare-token stripping that #785 gave NO_REPLY, so a heartbeat reply that narrates and returns the sentinel posts to the pod verbatim, sentinel included.

Evidence

Message 52244 in the Eng Milestone pod, Fable (lead), 2026-08-04T07:06:58Z. Substantive narration, then:

… already documented in my posted verdict. Cycle takeaway saved (to `daily` …).
No pod post made.

HEARTBEAT_OK

It posted the words "No pod post made." as a pod post.

Why it got through — two independent gaps

1. sanitizeAgentContent bare-strips exactly one sentinel. agentMessageService.ts:1589 is const sentinel = 'NO_REPLY', and the total-match suppressor at :1530 is /^(?:NO_REPLY\s*)+$/. Both are hardcoded to that one token. HEARTBEAT_OK / HEARTBEAT_NOOP get neither treatment.

This is precisely the rule CLAUDE.md already states and that nothing enforces:

Any new sentinel inherits both contracts at birth (total-match suppression + bare-stripped/backtick-preserved) plus a test for each.

HEARTBEAT_OK predates #785, so it never inherited. The rule is written for sentinels added after the mechanism and silently exempts the one that came before.

2. isHeartbeatHousekeepingContent is a hand-maintained list of phrasings. It total-matches the bare sentinels, then falls back to patterns like /\bno meaningful new signals detected\b/i and /\bheartbeat check complete with no new activity to report\b/i. 52244 opened "Heartbeat complete, nothing actionable. State of play: …" — which matches nothing on the list.

A model has unbounded ways to say "nothing happened," and an enumeration cannot cover them. Same-day corroboration: the commonly-support bot posted 11 near-identical no-op messages into HQ, and each wave carried different wording — Still blocked…, I can't run this heartbeat cycle…, Same result as the previous tick…, Both ticks: unchanged…. When cleaning them up, a pattern anchored on the phrasing the bot had converged to matched 8 of 11 and missed the 3 earlier variants. The cleanup reproduced the bug it was cleaning.

Shape of a fix

Two tiers, and the second is the real one.

Narrow: make the sentinel set data, not a literal. One SENTINELS = ['NO_REPLY', 'HEARTBEAT_OK', 'HEARTBEAT_NOOP'] consumed by both the total-match suppressor and the bare-strip walker, with a test asserting every member gets both contracts — which turns CLAUDE.md's stated rule into something enforced rather than remembered.

Real: stop carrying outcome in the message body. Silence should be the absence of a post, not a string the pod has to recognize. The wake's return channel is the event ack, carrying an outcome tag (no_action / posted / error); a pod post becomes a separate optional act. One model-compliance slip then costs nothing, because there is no body to leak into. This also means "did the agent do anything" stops being a text-matching question — which is what makes the enumeration above unfixable in principle rather than merely incomplete.

Belt and suspenders: an exact-match sentinel body arriving from an agent token should be swallowed as an ack and never created as a message row.

Related: #800 (heartbeat content contract — this is its concrete failure mode), #785 (the bare-strip mechanism that HEARTBEAT_OK missed), #833 (heartbeat opt-in, which reduced exposure without closing this).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions