From 08bf28f587c6a657450f2aab03dfbf2bd9c1fd2c Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sun, 12 Apr 2026 00:26:57 +0900 Subject: [PATCH] fix(hud): stop heal_stale_state from leaking stale sessionStartTimestamp (Wave 1-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-heal path preserved sessionStartTimestamp for "audit / forensics", but resolve_duration in codingbuddy-hud.py reads the same field as a fallback when stdin lacks total_duration_ms. A manual-fix marker + week-old timestamp therefore rendered enormous durations like "322h52m" for brand-new sessions. Fix: relocate the original timestamp into _healedFromSessionStartTimestamp so forensic/debug value is preserved while the render fallback path no longer sees it. Idempotent β€” re-healing an already-healed state keeps the forensics field stable because only non-empty timestamps are moved. Reproduction (before): echo '{}' | python3 codingbuddy-hud.py β—•β€Ώβ—• CB v5.6.0 | Ready 🟒 | 322h52m | ~\$0.00 | Ctx:0% After: β—•β€Ώβ—• CB v5.6.0 | Ready 🟒 | 0m | ~\$0.00 | Ctx:0% Test coverage: - 3 new unit tests in test_hud_session.py (forensics move, no-op on empty, idempotence) - 2 new integration tests in test_hud.py (TestHealedStateDurationDoesNotLeak) - Existing test_heal_preserves_session_id_and_timestamp renamed/updated to assert the new forensics contract 1067 passed locally (full plugin test suite). --- .../hooks/lib/hud_session.py | 42 ++++++++++++--- packages/claude-code-plugin/tests/test_hud.py | 54 +++++++++++++++++++ .../tests/test_hud_session.py | 47 ++++++++++++++-- 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/packages/claude-code-plugin/hooks/lib/hud_session.py b/packages/claude-code-plugin/hooks/lib/hud_session.py index 5fad64e1..fa2bc7b9 100644 --- a/packages/claude-code-plugin/hooks/lib/hud_session.py +++ b/packages/claude-code-plugin/hooks/lib/hud_session.py @@ -103,18 +103,36 @@ def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]: Cleared fields (so the HUD renders a safe default): - - ``currentMode`` β†’ ``None`` (statusLine shows "Ready") - - ``version`` β†’ ``""`` (hud_version falls back to plugin.json) - - ``activeAgent`` β†’ ``None`` - - ``phase`` β†’ ``"ready"`` - - ``focus`` β†’ ``None`` - - ``blockerCount``β†’ ``0`` + - ``currentMode`` β†’ ``None`` (statusLine shows "Ready") + - ``version`` β†’ ``""`` (hud_version falls back to plugin.json) + - ``activeAgent`` β†’ ``None`` + - ``phase`` β†’ ``"ready"`` + - ``focus`` β†’ ``None`` + - ``blockerCount`` β†’ ``0`` + - ``sessionStartTimestamp`` β†’ ``""`` (Wave 1-B duration-leak fix) Preserved fields: - ``sessionId`` (so debugging can see what was there) - - ``sessionStartTimestamp`` (for audit / forensics) - Any other field not listed above + + Forensics field (Wave 1-B fix β€” #1326): + + - ``_healedFromSessionStartTimestamp`` β€” retains the original + ``sessionStartTimestamp`` value when one was present. Earlier + revisions preserved ``sessionStartTimestamp`` in place for + "audit / forensics", but ``resolve_duration`` in + codingbuddy-hud.py uses that same field as a fallback when + stdin lacks ``total_duration_ms``. A stale timestamp therefore + rendered huge durations like ``322h52m`` for brand-new + sessions. Relocating into a ``_healed…`` field keeps the + debug value while pulling the render-path fallback out of + the line of fire. + + Idempotence: re-healing an already-healed state keeps the + forensics field stable β€” we only move a non-empty timestamp, + so the second pass sees ``sessionStartTimestamp == ""`` and + leaves ``_healedFromSessionStartTimestamp`` alone. """ healed: Dict[str, Any] = dict(state) healed["currentMode"] = None @@ -123,6 +141,16 @@ def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]: healed["phase"] = "ready" healed["focus"] = None healed["blockerCount"] = 0 + + # Wave 1-B fix: move sessionStartTimestamp out of the render + # fallback path and into a forensics field. Only capture a + # non-empty value so repeated heals do not overwrite a + # previously preserved forensic timestamp with "". + original_ts = healed.get("sessionStartTimestamp", "") or "" + if original_ts: + healed["_healedFromSessionStartTimestamp"] = original_ts + healed["sessionStartTimestamp"] = "" + return healed diff --git a/packages/claude-code-plugin/tests/test_hud.py b/packages/claude-code-plugin/tests/test_hud.py index f48fecfa..c1b037d5 100644 --- a/packages/claude-code-plugin/tests/test_hud.py +++ b/packages/claude-code-plugin/tests/test_hud.py @@ -314,6 +314,60 @@ def test_no_data_returns_zero(self): assert hud.resolve_duration({}, {}) == "0m" +class TestHealedStateDurationDoesNotLeak: + """Wave 1-B regression (#1326): format_status_line must not render a + stale ``sessionStartTimestamp`` as duration after a self-heal. + + Prior bug reproduction: + echo '{}' | python3 codingbuddy-hud.py + β†’ β—•β€Ώβ—• CB v5.6.0 | Ready 🟒 | 322h52m | ~$0.00 | Ctx:0% + + Root cause: ``heal_stale_state`` preserved ``sessionStartTimestamp`` + for "audit / forensics", but ``resolve_duration`` read the same + field as a fallback when stdin had no ``total_duration_ms``. + Fix: timestamp is relocated into ``_healedFromSessionStartTimestamp``. + """ + + _NO_PLUGINS = "/tmp/codingbuddy-heal-test-nonexistent-plugins.json" + + def test_healed_state_renders_0m_duration(self): + # Simulate the on-disk stale state that triggered the bug: + # a manual-fix repair marker with a weeks-old timestamp. + stale = { + "sessionId": "manual-fix", + "sessionStartTimestamp": "2026-03-29T04:10:47+00:00", + "currentMode": "ACT", + "version": "5.2.0", + } + from hud_session import heal_stale_state # noqa: E402 + healed = heal_stale_state(stale) + + # stdin has no total_duration_ms, so resolve_duration must + # NOT fall back onto the old sessionStartTimestamp. + output = hud.format_status_line( + {"session_id": "brand-new"}, + healed, + plugins_file=self._NO_PLUGINS, + ) + + assert "322h" not in output, ( + f"healed state leaked stale duration into output: {output!r}" + ) + assert " 0m " in output or output.endswith(" 0m") or "| 0m |" in output, ( + f"healed state should render '0m' duration, got: {output!r}" + ) + + def test_healed_state_preserves_forensics_field(self): + # The forensics field is the mechanism we rely on β€” guard it + # with a direct assertion so a regression here is caught here, + # not in test_hud_session.py only. + from hud_session import heal_stale_state # noqa: E402 + stale = {"sessionId": "manual-fix", "sessionStartTimestamp": "2026-03-29T04:10:47+00:00"} + healed = heal_stale_state(stale) + assert healed["sessionStartTimestamp"] == "" + assert healed["_healedFromSessionStartTimestamp"] == "2026-03-29T04:10:47+00:00" + + class TestResolveAgent: def test_stdin_agent_preferred(self): stdin = {"agent": {"name": "security-reviewer"}} diff --git a/packages/claude-code-plugin/tests/test_hud_session.py b/packages/claude-code-plugin/tests/test_hud_session.py index 9a979597..248fbccf 100644 --- a/packages/claude-code-plugin/tests/test_hud_session.py +++ b/packages/claude-code-plugin/tests/test_hud_session.py @@ -154,8 +154,21 @@ def test_heal_clears_ephemeral_fields(): assert healed["blockerCount"] == 0 -def test_heal_preserves_session_id_and_timestamp(): - """heal_stale_state keeps sessionId and sessionStartTimestamp intact.""" +def test_heal_preserves_session_id_and_moves_timestamp_to_forensics(): + """heal_stale_state keeps sessionId but relocates sessionStartTimestamp. + + Historical note: an earlier version of this function preserved + ``sessionStartTimestamp`` verbatim for "audit / forensics". That + caused the Wave 1-B duration-leak bug β€” ``resolve_duration`` in + codingbuddy-hud.py uses ``sessionStartTimestamp`` as a fallback + when stdin has no ``total_duration_ms``, so a healed (but + timestamp-retaining) state rendered enormous durations + (e.g., ``322h52m``) for brand-new sessions. + + The fix: relocate the timestamp into ``_healedFromSessionStartTimestamp`` + so forensic value is preserved for debuggers/tests while the render + fallback path no longer sees it. + """ state = { "sessionId": "abc-123", "sessionStartTimestamp": "2026-04-01T00:00:00+00:00", @@ -163,7 +176,35 @@ def test_heal_preserves_session_id_and_timestamp(): } healed = hud_session.heal_stale_state(state) assert healed["sessionId"] == "abc-123" - assert healed["sessionStartTimestamp"] == "2026-04-01T00:00:00+00:00" + # Render fallback path must not see the stale timestamp + assert healed["sessionStartTimestamp"] == "" + # Forensic value is preserved for post-mortem debugging + assert healed["_healedFromSessionStartTimestamp"] == "2026-04-01T00:00:00+00:00" + + +def test_heal_without_timestamp_does_not_add_forensics_field(): + """If there is no sessionStartTimestamp to heal, no forensics field is added.""" + state = {"sessionId": "abc-123", "currentMode": "ACT"} + healed = hud_session.heal_stale_state(state) + assert healed["sessionStartTimestamp"] == "" + assert "_healedFromSessionStartTimestamp" not in healed + + +def test_heal_is_idempotent_on_forensics_field(): + """Re-healing a healed state preserves the forensics timestamp stably. + + Guards against a naive implementation that would overwrite + ``_healedFromSessionStartTimestamp`` with the empty string on + the second pass, losing the original value. + """ + stale = { + "sessionId": "manual-fix", + "sessionStartTimestamp": "2026-03-29T04:10:47+00:00", + } + once = hud_session.heal_stale_state(stale) + twice = hud_session.heal_stale_state(once) + assert twice["_healedFromSessionStartTimestamp"] == "2026-03-29T04:10:47+00:00" + assert twice["sessionStartTimestamp"] == "" def test_heal_does_not_mutate_input():