Deterministic UI Teardowns: Solving the Honeybot Banner Timing Bug

🤖 Read Raw Markdown

Setting the Stage: Context for the Curious Book Reader

This entry explores the importance of building robust systems in the Age of AI by focusing on the difference between ‘guessed’ timing and deterministic signals. It serves as an interesting look at how even small UI inconsistencies—like a banner disappearing too soon—can be corrected by moving from reliance on timers to reliance on verified completion events.


Technical Journal Entry Begins

🔗 Verified Pipulate Commits:

MikeLev.in: During the Honeybot slideshow, when we publish a new article and the “UPDATING” Figlet window appears and things go quiet, the UPDATING window disappears too soon; about half-way through. I think it’s based on guesswork timing, but I think it’s unrealistic given the variable amounts the machine might be working on all that stuff happening during one of those updates, and I think it should be deterministically closed when the new article finally starts reading. I know this introduces subtleties like failure cases allows UPDATING to just stay up. But I think there can be a strategy where it doesn’t and can’t. And even if the UPDATING window isn’t there, say between natural playlist progression, it could be a harmless attempt to always close UPDATING at that time. I’m not saying use that approach, but what I am saying is that there must be smarter ways to bound this than guessed timing.

Identifying the Race Condition

Claude Sonnet 4.6 Max: Good catch — and I think I found the actual mechanism, not just a vague “tune the timer” fix.

What’s actually happening

There are two independent timers racing each other to close the same window, and they’re set backwards.

When the standby handshake fires in stream.py:

conjure_window("card.py", duration=130.0, args=["UPDATING"])

args=["UPDATING"] only fills card.py’s sys.argv[1] (the label). It never reaches sys.argv[2]. So card.py’s own internal self-hold falls back to its default:

hold = 60.0
if len(sys.argv) > 2:
    ...

Per card.py’s own docstring, this self-hold is supposed to be “a safety net only” — the outer conjure_window actuator is supposed to own teardown via its duration. That’s true everywhere else in the codebase (station-break cards always pass a small outer duration like 5.0, well under the 60s default, so the actuator always wins).

But for UPDATING, the outer duration (130s) is longer than the self-hold default (60s) — the relationship is inverted. So card.py’s own time.sleep(60) finishes, main() returns, the process exits, and the window closes on its own at the 60s mark — roughly half of a realistic build window. The deterministic teardown you already wrote (pkill -f "card.py.*UPDATING" after check_for_updates() confirms the bell rang) never gets a chance to do anything, because there’s nothing left to kill by then.

The Mechanism of Failure

So you already built the right mechanism — polling the real completion signal instead of guessing a duration — it’s just been getting beaten to the punch by a stale internal timer.

The fix

Make every timer in this picture a generous, last-resort backstop, never the thing that actually decides when the window closes — and add the harmless idempotent close you described, so a leftover card can never survive past the start of the next cycle no matter how it got orphaned.

Target: remotes/honeybot/scripts/card.py
[[[SEARCH]]]
The shared conjure_window actuator owns teardown: it kills this process after
its `duration`, so this script just renders and idles until dismissed.

Usage:
    card.py "THE ITCH"
    card.py "THE ITCH" 6      # optional self-hold cap in seconds
"""
[[[DIVIDER]]]
The shared conjure_window actuator owns teardown: it kills this process after
its `duration`, so this script just renders and idles until dismissed. The
self-hold below is a generous LAST-RESORT safety net only — it must always be
longer than any caller's `duration`, or this script becomes the thing that
closes the window early instead of the actuator (this is exactly what bit
Honeybot once: a 130s sentinel card died at the old 60s default, well before
the real signal it was waiting on ever arrived). When in doubt, raise this
number, never lower it.

## Implementing Defense-in-Depth

Usage:
    card.py "THE ITCH"
    card.py "THE ITCH" 6      # optional self-hold cap in seconds
"""
[[[REPLACE]]]

Target: remotes/honeybot/scripts/card.py
[[[SEARCH]]]
    # Self-hold cap is a safety net only; the conjure_window actuator is the
    # real source of truth for how long the card stays up.
    hold = 60.0
    if len(sys.argv) > 2:
        try:
            hold = float(sys.argv[2])
        except ValueError:
            hold = 60.0
[[[DIVIDER]]]
    # Self-hold cap is a LAST-RESORT safety net only; the conjure_window
    # actuator (or an explicit external pkill tied to a real event) is the
    # real source of truth for how long the card stays up. Keep this large —
    # it exists purely to stop a card from hanging forever if every other
    # teardown mechanism somehow fails, not to time the card's actual life.
    hold = 900.0
    if len(sys.argv) > 2:
        try:
            hold = float(sys.argv[2])
        except ValueError:
            hold = 900.0
[[[REPLACE]]]

Target: remotes/honeybot/scripts/stream.py
[[[SEARCH]]]
def perform_show(script):
    """Reads the sheet music list and executes it."""
    # Define the environment for the browser once
    env = os.environ.copy()
    env["DISPLAY"] = ":10.0"
[[[DIVIDER]]]
def perform_show(script):
    """Reads the sheet music list and executes it."""
    # Idempotent safety net: whenever we start performing ANY fresh script —
    # normal playlist progression, a breaking-news restart, anything — make
    # sure no stale "UPDATING" sentinel card is still lingering on screen.
    # This is a no-op the vast majority of the time, since the deploy-standby
    # handshake below already tears the card down deterministically the
    # instant the completion bell rings. It only does real work if that
    # earlier teardown somehow failed to land — which is exactly the failure
    # case this exists to bound: the card cannot outlive more than one cycle.
    try:
        subprocess.run(["pkill", "-f", "card.py.*UPDATING"], check=False)
    except Exception:
        pass

    # Define the environment for the browser once
    env = os.environ.copy()
    env["DISPLAY"] = ":10.0"
[[[REPLACE]]]

Target: remotes/honeybot/scripts/stream.py
[[[SEARCH]]]
            if check_standby():
                narrator.interrupt()  # cut current audio + flush the backlog
                narrator.say("Receiving updates. Things will go quiet for a few moments. Then I'll start reading again. Please stand by.")
                conjure_window("card.py", duration=130.0, args=["UPDATING"])
                try:
                    subprocess.run(["pkill", "firefox"], check=False)
                except Exception:
                    pass
                # Hold narration until the deploy finishes (completion bell rings)
                # or we time out gracefully, then lead the next cycle with the new article.
                deadline = time.time() + 120
                while time.time() < deadline:
                    if check_for_updates():
                        break
                    time.sleep(2)
                # Tear down the dead-air cover right before handing off to the newest article
                try:
                    subprocess.run(["pkill", "-f", "card.py.*UPDATING"], check=False)
                except Exception:
                    pass
                return "BREAKING"
[[[DIVIDER]]]
            if check_standby():
                narrator.interrupt()  # cut current audio + flush the backlog
                narrator.say("Receiving updates. Things will go quiet for a few moments. Then I'll start reading again. Please stand by.")
                # The outer `duration` here is a generous LAST-RESORT ceiling, not
                # the thing that's supposed to close this window. The deterministic
                # close is the pkill below, fired the instant check_for_updates()
                # proves the deploy is actually done — never a guessed elapsed time.
                conjure_window("card.py", duration=270.0, args=["UPDATING"])
                try:
                    subprocess.run(["pkill", "firefox"], check=False)
                except Exception:
                    pass
                # Hold narration until the deploy finishes (completion bell rings)
                # or we time out gracefully, then lead the next cycle with the new article.
                # This deadline is a fail-safe ceiling for a deploy that never finishes,
                # not the expected case — builds vary, and check_for_updates() polling
                # below is what actually decides when to stop waiting.
                deadline = time.time() + 240
                while time.time() < deadline:
                    if check_for_updates():
                        break
                    time.sleep(2)
                # Tear down the dead-air cover deterministically, right here, the
                # moment we know one way or another (bell rang, or we gave up waiting).
                # perform_show() also retries this same pkill on every future cycle
                # (see the top of this function) as a harmless, idempotent backstop in
                # case this particular teardown attempt ever fails to land.
                try:
                    subprocess.run(["pkill", "-f", "card.py.*UPDATING"], check=False)
                except Exception:
                    pass
                return "BREAKING"
[[[REPLACE]]]

Why this addresses the subtleties you flagged

  • Primary signal stays the real one: the window only closes “for real” when check_for_updates() confirms the post-receive hook actually wrote a fresh .reading_trigger — i.e., the build genuinely finished. That’s unchanged.
  • “Harmless attempt during normal progression”: the new pkill at the top of perform_show() fires on every cycle, breaking-news or not, and is a no-op if there’s nothing to kill — exactly the “always safe to try” behavior you described.
  • Bounded failure case, not unbounded: if the deploy genuinely hangs and the bell never rings, you still get a deterministic chain of escalating, generous backstops: 240s polling ceiling → 270s actuator hard-kill → 900s process self-hold → next cycle’s idempotent sweep. Nothing in that chain is “guessed” in the sense of trying to predict build time — they’re all just increasingly generous fences around a primary mechanism that’s driven by the real event.

MikeLev.in: Wow, even the lower-model Claude’s like Sonnet 4.6 versus Opus 4.8 can be mighty impressive with its patch batting averages, like this:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'remotes/honeybot/scripts/card.py'.
(nix) pipulate $ d
diff --git a/remotes/honeybot/scripts/card.py b/remotes/honeybot/scripts/card.py
index 75e190cd..d4e96061 100644
--- a/remotes/honeybot/scripts/card.py
+++ b/remotes/honeybot/scripts/card.py
@@ -6,7 +6,13 @@ Renders a large Figlet banner (e.g. "THE ITCH") centered on a transient
 Alacritty overlay. Used as the leading *label* brush of a station-break bead.
 
 The shared conjure_window actuator owns teardown: it kills this process after
-its `duration`, so this script just renders and idles until dismissed.
+its `duration`, so this script just renders and idles until dismissed. The
+self-hold below is a generous LAST-RESORT safety net only — it must always be
+longer than any caller's `duration`, or this script becomes the thing that
+closes the window early instead of the actuator (this is exactly what bit
+Honeybot once: a 130s sentinel card died at the old 60s default, well before
+the real signal it was waiting on ever arrived). When in doubt, raise this
+number, never lower it.
 
 Usage:
     card.py "THE ITCH"
(nix) pipulate $ m
📝 Committing: chore: Clarify Honeybot card duration and self-hold behavior
[main a3355c03] chore: Clarify Honeybot card duration and self-hold behavior
 1 file changed, 7 insertions(+), 1 deletion(-)
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'remotes/honeybot/scripts/card.py'.
(nix) pipulate $ d
diff --git a/remotes/honeybot/scripts/card.py b/remotes/honeybot/scripts/card.py
index d4e96061..9ec7150a 100644
--- a/remotes/honeybot/scripts/card.py
+++ b/remotes/honeybot/scripts/card.py
@@ -59,14 +59,17 @@ def render(label: str):
 def main():
     label = sys.argv[1] if len(sys.argv) > 1 else "Station"
 
-    # Self-hold cap is a safety net only; the conjure_window actuator is the
-    # real source of truth for how long the card stays up.
-    hold = 60.0
+    # Self-hold cap is a LAST-RESORT safety net only; the conjure_window
+    # actuator (or an explicit external pkill tied to a real event) is the
+    # real source of truth for how long the card stays up. Keep this large —
+    # it exists purely to stop a card from hanging forever if every other
+    # teardown mechanism somehow fails, not to time the card's actual life.
+    hold = 900.0
     if len(sys.argv) > 2:
         try:
             hold = float(sys.argv[2])
         except ValueError:
-            hold = 60.0
+            hold = 900.0
 
     render(label)
     time.sleep(hold)
(nix) pipulate $ m
📝 Committing: chore: Update self-hold cap in card.py
[main 35bc4ad7] chore: Update self-hold cap in card.py
 1 file changed, 7 insertions(+), 4 deletions(-)
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'remotes/honeybot/scripts/stream.py'.
(nix) pipulate $ d
diff --git a/remotes/honeybot/scripts/stream.py b/remotes/honeybot/scripts/stream.py
index c6c25024..b572cf05 100644
--- a/remotes/honeybot/scripts/stream.py
+++ b/remotes/honeybot/scripts/stream.py
@@ -515,6 +515,19 @@ def run_station_break(env, profile_dir):
 
 def perform_show(script):
     """Reads the sheet music list and executes it."""
+    # Idempotent safety net: whenever we start performing ANY fresh script —
+    # normal playlist progression, a breaking-news restart, anything — make
+    # sure no stale "UPDATING" sentinel card is still lingering on screen.
+    # This is a no-op the vast majority of the time, since the deploy-standby
+    # handshake below already tears the card down deterministically the
+    # instant the completion bell rings. It only does real work if that
+    # earlier teardown somehow failed to land — which is exactly the failure
+    # case this exists to bound: the card cannot outlive more than one cycle.
+    try:
+        subprocess.run(["pkill", "-f", "card.py.*UPDATING"], check=False)
+    except Exception:
+        pass
+
     # Define the environment for the browser once
     env = os.environ.copy()
     env["DISPLAY"] = ":10.0"
(nix) pipulate $ m
📝 Committing: chore: Add idempotency safety net for card.py UPDATING
[main d32dd6a7] chore: Add idempotency safety net for card.py UPDATING
 1 file changed, 13 insertions(+)
(nix) pipulate $ patch
(nix) pipulate $ cat patch | app
✅ DETERMINISTIC PATCH APPLIED: Successfully mutated 'remotes/honeybot/scripts/stream.py'.
(nix) pipulate $ d
diff --git a/remotes/honeybot/scripts/stream.py b/remotes/honeybot/scripts/stream.py
index b572cf05..f0452401 100644
--- a/remotes/honeybot/scripts/stream.py
+++ b/remotes/honeybot/scripts/stream.py
@@ -565,19 +565,30 @@ def perform_show(script):
             if check_standby():
                 narrator.interrupt()  # cut current audio + flush the backlog
                 narrator.say("Receiving updates. Things will go quiet for a few moments. Then I'll start reading again. Please stand by.")
-                conjure_window("card.py", duration=130.0, args=["UPDATING"])
+                # The outer `duration` here is a generous LAST-RESORT ceiling, not
+                # the thing that's supposed to close this window. The deterministic
+                # close is the pkill below, fired the instant check_for_updates()
+                # proves the deploy is actually done — never a guessed elapsed time.
+                conjure_window("card.py", duration=270.0, args=["UPDATING"])
                 try:
                     subprocess.run(["pkill", "firefox"], check=False)
                 except Exception:
                     pass
                 # Hold narration until the deploy finishes (completion bell rings)
                 # or we time out gracefully, then lead the next cycle with the new article.
-                deadline = time.time() + 120
+                # This deadline is a fail-safe ceiling for a deploy that never finishes,
+                # not the expected case — builds vary, and check_for_updates() polling
+                # below is what actually decides when to stop waiting.
+                deadline = time.time() + 240
                 while time.time() < deadline:
                     if check_for_updates():
                         break
                     time.sleep(2)
-                # Tear down the dead-air cover right before handing off to the newest article
+                # Tear down the dead-air cover deterministically, right here, the
+                # moment we know one way or another (bell rang, or we gave up waiting).
+                # perform_show() also retries this same pkill on every future cycle
+                # (see the top of this function) as a harmless, idempotent backstop in
+                # case this particular teardown attempt ever fails to land.
                 try:
                     subprocess.run(["pkill", "-f", "card.py.*UPDATING"], check=False)
                 except Exception:
(nix) pipulate $ m
📝 Committing: chore: Adjust `conjure_window` duration and pkill command
[main 9ed27cc5] chore: Adjust `conjure_window` duration and pkill command
 1 file changed, 14 insertions(+), 3 deletions(-)
(nix) pipulate $ git push
Enumerating objects: 30, done.
Counting objects: 100% (30/30), done.
Delta compression using up to 48 threads
Compressing objects: 100% (20/20), done.
Writing objects: 100% (24/24), 3.16 KiB | 1.58 MiB/s, done.
Total 24 (delta 16), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (16/16), completed with 5 local objects.
To github.com:pipulate/pipulate.git
   d988ea8c..9ed27cc5  main -> main
(nix) pipulate $

So this is another one of those articles that just seems done. One ask, one patch, one new article. I’m going to article, preview, publish "Update banner timing fix" --reboot this, which means I won’t really see the change until the publish after this one. I’ll assume it worked. Should I, Claude?

Claude Sonnet 4.6 Max: Yes, you should. Here’s why this particular patch is unusually safe to ship blind:

The bug was a simple numeric invariant violation (60 < 130, wrong relationship) and the fix is purely raising ceilings. You can’t make it worse by raising 60→900 and 130→270, because all you’ve done is ensure the self-hold can never beat the actuator to the punch. The worst case if something unexpected happens is the banner stays up longer than you’d like — which is the boring failure mode, not the embarrassing one.

The three changes form a defense-in-depth stack with no single point of failure:

  1. Primary: check_for_updates() polling fires the pkill when the real bell rings
  2. Fallback: the 240s ceiling catches a hung deploy
  3. Backstop: perform_show() sweeps orphaned cards on every cycle start
  4. Last resort: the 900s self-hold catches anything that slipped through all three

The only scenario where you’d notice a problem after this publish is if the deploy that carries this very article triggers the standby handshake — which would immediately test whether the fix works. And if it does malfunction, it’ll manifest as the banner staying up too long (acceptable) rather than disappearing mid-build (the bug you fixed).

Verification and Deployment

Ship it. The --reboot will validate the full cycle for you automatically.

MikeLev.in: Sounds good to me. Ship it!


Book Analysis

Ai Editorial Take

What strikes me most is the ‘idempotent sweep’ pattern. Most developers try to solve race conditions by tightening the logic in one specific location, but the approach of making the cleanup process ‘harmlessly repeatable’ at the start of every cycle is a highly underrated architecture pattern. It acknowledges that software is inherently messy and that systems shouldn’t just be ‘correct’—they should be ‘self-correcting’.

🐦 X.com Promo Tweet

Stop guessing when your UI tasks finish. I updated Honeybot's banner system to use deterministic event-polling instead of arbitrary timers. A case study in building more resilient, self-healing automation. Read the details here: https://mikelev.in/futureproof/deterministic-ui-teardowns/ #automation #python #programming

Title Brainstorm

  • Title Option: Deterministic UI Teardowns: Solving the Honeybot Banner Timing Bug
    • Filename: deterministic-ui-teardowns.md
    • Rationale: Clear, professional, and highlights the technical solution and the specific project context.
  • Title Option: Moving Beyond Timers: Event-Driven UI Logic
    • Filename: event-driven-ui-logic.md
    • Rationale: Positions the article as a methodology lesson rather than just a bug report.
  • Title Option: Resilience in the Age of AI: The Honeybot Case
    • Filename: resilience-in-ai-age.md
    • Rationale: Frames the article within the broader theme of the development tapestry.

Content Potential And Polish

  • Core Strengths:
    • Excellent use of real-world shell transcripts to prove the fix.
    • Demonstrates a sophisticated ‘defense-in-depth’ engineering mindset.
    • Clear explanation of why certain patterns (like inverted timer relationships) fail.
  • Suggestions For Polish:
    • Ensure the distinction between the ‘actuator’ and the ‘self-hold’ is highlighted as a foundational pattern for future components.
    • Emphasize the ‘no-op’ nature of the idempotent pkill to reassure readers it is safe for high-frequency execution.

Next Step Prompts

  • Review the current codebase for other instances where internal process ‘self-holds’ might be fighting external ‘actuator’ durations.
  • Explore how this event-driven cleanup pattern could be generalized into a reusable decorator for other Honeybot UI components.