Skip to content

feat: reliable echo via final-id resolution + supervise beepertexts - #13

Merged
hamr0 merged 4 commits into
masterfrom
feat/echo-id-resolution-and-supervision
Jun 15, 2026
Merged

feat: reliable echo via final-id resolution + supervise beepertexts#13
hamr0 merged 4 commits into
masterfrom
feat/echo-id-resolution-and-supervision

Conversation

@hamr0

@hamr0 hamr0 commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Two asks from multis (beeperbox's first consumer). Ask B(1) — the stale-Xvfb-lock restart fix — already shipped in #12; this PR is Ask A + Ask B(2).

Ask A — reliable echo via id resolution (mcp/server.js)

Problem: a send returns a pendingMessageID, but Beeper swaps it for the real bridge id on ack. The echo-guard recorded the pending id and matched read-backs by exact id → always missed → every match degraded to the fragile 15-minute text fallback.

Fix (grounded in Beeper's docs): send_message / note_to_self now resolve the pending id to the final bridge id — GET .../messages/{pendingMessageID} retried until the id swaps (bounded, best-effort, never throws) — and record both ids in the ledger. A read-back then matches by exact id whether or not the swap happened. The content fallback survives only as a last-ditch net and only for sends whose final id couldn't be resolved; once a send is resolved, its text fallback is retired.

Acceptance criteria (all covered by unit tests):

  • ✅ exact-id tagging via the resolved bridge id
  • ✅ two identical-text sends both caught (each by its own id)
  • ✅ a human re-typing identical text after a resolved send is not mis-tagged (stays external)
  • → lets multis drop its _isLooping backstop

Additive return fields on both send tools: pending_message_id, resolved. message_id is the final bridge id when resolved: true, else the pending id. No field removed. Tunable via BEEPERBOX_RESOLVE_RETRIES (default 4) / BEEPERBOX_RESOLVE_DELAY_MS (default 250); set retries 0 to disable.

Ask B(2) — supervise beepertexts (entrypoint.sh)

Problem: beepertexts ran unattended. When its API process crashed while the Electron launcher lingered, the container looked "up" and the MCP layer kept answering, but every tool call returned -32603 fetch failed because :23373 was gone — half-dead, never self-heals.

Fix: the entrypoint now supervises — relaunch if the process dies, recycle if the API stays down after having been up (watches both the PID and :23373/v1/spec). An API_WAS_UP gate protects first-run login: until the user enables the API it's down by design, so the loop never recycles before the API has come up once. Clean docker stop shutdown preserved. Reversible/tunable: BEEPERBOX_SUPERVISE (default 1, 0 = old behavior), BEEPERBOX_SUPERVISE_INTERVAL (10s), BEEPERBOX_SUPERVISE_API_GRACE (6 ≈ 60s).

Verification — AGENT_RULES (POC + test-must-fail)

  • Unit suite: 26 pass (was 21). New: read-back tagged by resolved id; the human-retype acceptance test; two-identical-sends; unresolved-entry safety net; addResolvedId persist/reload round-trip. The existing 21 pass unchanged → legacy sent_id back-compat holds.
  • POC spikes (not committed) proved the load-bearing claims and that the tests can fail: the prior matcher mis-tags the human re-type; an ungated supervisor would recycle pre-login.

Honest limitations (unverified in CI — no live Beeper account)

  • The pendingMessageID → final id resolution depends on Beeper's live ack behavior. multis proved it against a real account (id 908); it is not exercised in CI. Validate against your own account before relying on it for a high-stakes auto-responder.
  • The supervisor's kill/relaunch integration needs a live container — only its decision logic is POC-covered here.
  • Resolution adds up to ~750ms (default) to a send while it waits for the ack; tune down or disable if latency-sensitive.

Docs

CHANGELOG (Ask A + supervision under [Unreleased]), beeperbox.context.md (send returns + rewritten echo-guard caveat), docs/GUIDE.md (send-return row + self-heal troubleshooting note), docs/PRD.md (supervised-backend operational property).

🤖 Generated with Claude Code

hamr0 and others added 4 commits June 15, 2026 22:50
Two asks from multis (beeperbox's first consumer).

Ask A — reliable echo via id resolution (mcp/server.js):
A send returns a pendingMessageID, but Beeper swaps it for the real bridge
id on ack, so the echo-guard's exact-id match always missed and degraded to
fragile 15-min text matching. send_message / note_to_self now resolve the
pending id to the final bridge id (GET .../messages/{pendingMessageID} until
it swaps, bounded + best-effort) and record BOTH ids, so a read-back matches
by exact id whether or not the swap happened. The text fallback survives only
as a last-ditch net AND only for sends whose final id couldn't be resolved —
so a human re-typing identical text is no longer mis-tagged as the agent's
own and dropped. Additive return fields: pending_message_id, resolved.
Tunable via BEEPERBOX_RESOLVE_RETRIES / BEEPERBOX_RESOLVE_DELAY_MS.

Ask B(2) — supervise beepertexts (entrypoint.sh):
beepertexts was launched unattended; when its API process crashed while the
Electron launcher lingered, the container looked up and the MCP layer kept
answering but every tool call failed (half-dead, never self-heals). The
entrypoint now supervises: relaunch on process death, recycle if the API
(:23373) stays down after having been up. An API_WAS_UP gate protects
first-run login (API is down by design until enabled). Tunable/reversible
via BEEPERBOX_SUPERVISE*. (Ask B(1), the stale-Xvfb-lock restart fix, already
shipped in #12.)

Verification (AGENT_RULES: POC + test-must-fail):
- mcp/server.test.js: 26 tests (was 21). New: read-back tagged by resolved
  id; the acceptance test that a human re-typing identical text after a
  resolved send stays external; two identical-text sends matched by their own
  ids; unresolved entry still caught by the text net; addResolvedId persist/
  reload round-trip. Existing 21 pass unchanged (legacy sent_id back-compat).
- POC spikes proved the load-bearing claims AND that the tests can fail: the
  prior matcher mis-tags the human re-type, and an ungated supervisor would
  recycle pre-login.

Honest limits (unverified in CI — no live Beeper account): the pending->final
id resolution depends on Beeper's live ack behavior (proven against a real
account by multis, id 908; not exercised in CI). The supervisor's kill/
relaunch integration needs a live container; only its decision logic is
POC-covered.

Docs: CHANGELOG (Ask A + supervision under [Unreleased]), beeperbox.context.md
(send returns + echo-guard caveat), docs/GUIDE.md (send row + self-heal note),
docs/PRD.md (supervised-backend operational property).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… findings)

Two robustness fixes from the /security pass on this branch, both validated
empirically before fixing:

- entrypoint.sh: a non-numeric BEEPERBOX_SUPERVISE_INTERVAL / _API_GRACE made
  `sleep`/`-ge` fail instantly, spinning the supervisor loop at 100% CPU (and
  hammering the API probe); 0 was a no-wait spin too. Confirmed `sleep abc`
  exits 1 instantly. Now sanitized to a positive integer, falling back to the
  documented default.
- mcp/server.js: a typo like BEEPERBOX_RESOLVE_RETRIES=four parsed to NaN and
  silently passed the loop guards, DISABLING id resolution (echo-guard quietly
  degrades to text-only, no error). Confirmed NaN -> 0 loop iters -> null. Now
  clamped via envIntNonNeg(): malformed -> default, 0 stays an intentional
  disable.

Not fixed: the send->backend GET amplification finding is the pre-existing
MCP-layer rate-limit gap (mitigated by the loopback bind), out of scope for
this diff. Unit suite still 26/26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…anch, shutdown race)

From the /code-review pass on this branch. Validated each finding against the
code before fixing; non-issues confirmed and left alone.

mcp/server.js:
- I-1 (Important): resolveSentId's beeperFetch calls had no timeout, so a hung
  Beeper API could stall every send unbounded. Added opt-in beeperFetch
  timeoutMs (AbortController; existing 19 callers unchanged) and a per-attempt
  RESOLVE_TIMEOUT_MS (default 3000, env-tunable). Worst-case added send latency
  is now bounded at retries × (timeout + delay). Proven: a hung server aborts
  in ~300ms instead of hanging.
- M-1 (Minor): dropped the speculative list-shape branch in resolveSentId. On a
  by-id GET it took items[0] of an unexpected list response and could store the
  WRONG final id, mis-tagging an unrelated read-back. Now trusts only the
  single-object shape.
- I-2 (defensive): resolveSentId early-returns on empty chatID (in practice
  chatID is always set — send_message validates chat_id, note_to_self uses the
  resolved note chat — but the guard makes the function self-safe).

entrypoint.sh:
- M-4 (Minor): re-check SHUTTING_DOWN before relaunch in the process-death
  branch, so a SIGTERM landing in that window doesn't spawn a fresh Beeper just
  to immediately stop it during docker stop.
- M-5: comment noting the `|| true` / `if`-guard discipline is load-bearing
  under `set -e`.

Confirmed NON-issues (no change): the entry mutation after LEDGER_MAX slicing
is never lost (push keeps the reference; eviction is from the front); the trap
targets the current PID after relaunch (single-quoted body expands at delivery);
I-3 — resolution does engage for note_to_self (chatID always valid).

Tests: +1 legacy {sent_id} back-compat test (id-match AND text-fallback). 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The RESOLVE_* and SUPERVISE_* knobs were documented in prose but missing from
the §5.3 env var table. Add them so the configuration surface is complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hamr0
hamr0 merged commit 1ad498d into master Jun 15, 2026
3 checks passed
@hamr0
hamr0 deleted the feat/echo-id-resolution-and-supervision branch June 15, 2026 21:08
@hamr0 hamr0 mentioned this pull request Jun 15, 2026
hamr0 added a commit that referenced this pull request Jun 15, 2026
)

Cuts 0.6.0 (MINOR) so :latest carries Ask A/B before multis Phase 2 ships
(Phase 2 hard-depends on the exact-id echo-guard — no text fallback left).
Bundles everything under [Unreleased]: poll_messages watch primitive + source
echo-guard (#11), docker-restart stale-lock fix (#12), reliable echo via
final-id resolution + supervised backend (#13).

- CHANGELOG: [Unreleased] -> [0.6.0] — 2026-06-15 with a release summary.
- mcp/server.js: serverInfo.version 0.5.0 -> 0.6.0 (was stale).
- docs/PRD.md: status stamp -> v0.6.0; release-history table corrected (the
  stale "(unreleased)" row is 0.5.1) + 0.6.0 row; example tag bumped.
- beeperbox.context.md / README.md: version stamps + pin example -> 0.6.0.

Tag v0.6.0 after merge triggers the gated publish (:0.6.0/:0.6/:0/:latest,
old :latest -> :previous). Live id-resolution round-trip validated by multis;
CI gate still runs the server standalone (no account).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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