Skip to content

Commit cd2c9d3

Browse files
committed
fix(hud): stop heal_stale_state from leaking stale sessionStartTimestamp (Wave 1-B)
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).
1 parent 3877014 commit cd2c9d3

3 files changed

Lines changed: 133 additions & 10 deletions

File tree

packages/claude-code-plugin/hooks/lib/hud_session.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,36 @@ def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]:
103103
104104
Cleared fields (so the HUD renders a safe default):
105105
106-
- ``currentMode`` → ``None`` (statusLine shows "Ready")
107-
- ``version`` → ``""`` (hud_version falls back to plugin.json)
108-
- ``activeAgent`` → ``None``
109-
- ``phase`` → ``"ready"``
110-
- ``focus`` → ``None``
111-
- ``blockerCount``→ ``0``
106+
- ``currentMode`` → ``None`` (statusLine shows "Ready")
107+
- ``version`` → ``""`` (hud_version falls back to plugin.json)
108+
- ``activeAgent`` → ``None``
109+
- ``phase`` → ``"ready"``
110+
- ``focus`` → ``None``
111+
- ``blockerCount`` → ``0``
112+
- ``sessionStartTimestamp`` → ``""`` (Wave 1-B duration-leak fix)
112113
113114
Preserved fields:
114115
115116
- ``sessionId`` (so debugging can see what was there)
116-
- ``sessionStartTimestamp`` (for audit / forensics)
117117
- Any other field not listed above
118+
119+
Forensics field (Wave 1-B fix — #1326):
120+
121+
- ``_healedFromSessionStartTimestamp`` — retains the original
122+
``sessionStartTimestamp`` value when one was present. Earlier
123+
revisions preserved ``sessionStartTimestamp`` in place for
124+
"audit / forensics", but ``resolve_duration`` in
125+
codingbuddy-hud.py uses that same field as a fallback when
126+
stdin lacks ``total_duration_ms``. A stale timestamp therefore
127+
rendered huge durations like ``322h52m`` for brand-new
128+
sessions. Relocating into a ``_healed…`` field keeps the
129+
debug value while pulling the render-path fallback out of
130+
the line of fire.
131+
132+
Idempotence: re-healing an already-healed state keeps the
133+
forensics field stable — we only move a non-empty timestamp,
134+
so the second pass sees ``sessionStartTimestamp == ""`` and
135+
leaves ``_healedFromSessionStartTimestamp`` alone.
118136
"""
119137
healed: Dict[str, Any] = dict(state)
120138
healed["currentMode"] = None
@@ -123,6 +141,16 @@ def heal_stale_state(state: Dict[str, Any]) -> Dict[str, Any]:
123141
healed["phase"] = "ready"
124142
healed["focus"] = None
125143
healed["blockerCount"] = 0
144+
145+
# Wave 1-B fix: move sessionStartTimestamp out of the render
146+
# fallback path and into a forensics field. Only capture a
147+
# non-empty value so repeated heals do not overwrite a
148+
# previously preserved forensic timestamp with "".
149+
original_ts = healed.get("sessionStartTimestamp", "") or ""
150+
if original_ts:
151+
healed["_healedFromSessionStartTimestamp"] = original_ts
152+
healed["sessionStartTimestamp"] = ""
153+
126154
return healed
127155

128156

packages/claude-code-plugin/tests/test_hud.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,60 @@ def test_no_data_returns_zero(self):
314314
assert hud.resolve_duration({}, {}) == "0m"
315315

316316

317+
class TestHealedStateDurationDoesNotLeak:
318+
"""Wave 1-B regression (#1326): format_status_line must not render a
319+
stale ``sessionStartTimestamp`` as duration after a self-heal.
320+
321+
Prior bug reproduction:
322+
echo '{}' | python3 codingbuddy-hud.py
323+
→ ◕‿◕ CB v5.6.0 | Ready 🟢 | 322h52m | ~$0.00 | Ctx:0%
324+
325+
Root cause: ``heal_stale_state`` preserved ``sessionStartTimestamp``
326+
for "audit / forensics", but ``resolve_duration`` read the same
327+
field as a fallback when stdin had no ``total_duration_ms``.
328+
Fix: timestamp is relocated into ``_healedFromSessionStartTimestamp``.
329+
"""
330+
331+
_NO_PLUGINS = "/tmp/codingbuddy-heal-test-nonexistent-plugins.json"
332+
333+
def test_healed_state_renders_0m_duration(self):
334+
# Simulate the on-disk stale state that triggered the bug:
335+
# a manual-fix repair marker with a weeks-old timestamp.
336+
stale = {
337+
"sessionId": "manual-fix",
338+
"sessionStartTimestamp": "2026-03-29T04:10:47+00:00",
339+
"currentMode": "ACT",
340+
"version": "5.2.0",
341+
}
342+
from hud_session import heal_stale_state # noqa: E402
343+
healed = heal_stale_state(stale)
344+
345+
# stdin has no total_duration_ms, so resolve_duration must
346+
# NOT fall back onto the old sessionStartTimestamp.
347+
output = hud.format_status_line(
348+
{"session_id": "brand-new"},
349+
healed,
350+
plugins_file=self._NO_PLUGINS,
351+
)
352+
353+
assert "322h" not in output, (
354+
f"healed state leaked stale duration into output: {output!r}"
355+
)
356+
assert " 0m " in output or output.endswith(" 0m") or "| 0m |" in output, (
357+
f"healed state should render '0m' duration, got: {output!r}"
358+
)
359+
360+
def test_healed_state_preserves_forensics_field(self):
361+
# The forensics field is the mechanism we rely on — guard it
362+
# with a direct assertion so a regression here is caught here,
363+
# not in test_hud_session.py only.
364+
from hud_session import heal_stale_state # noqa: E402
365+
stale = {"sessionId": "manual-fix", "sessionStartTimestamp": "2026-03-29T04:10:47+00:00"}
366+
healed = heal_stale_state(stale)
367+
assert healed["sessionStartTimestamp"] == ""
368+
assert healed["_healedFromSessionStartTimestamp"] == "2026-03-29T04:10:47+00:00"
369+
370+
317371
class TestResolveAgent:
318372
def test_stdin_agent_preferred(self):
319373
stdin = {"agent": {"name": "security-reviewer"}}

packages/claude-code-plugin/tests/test_hud_session.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,16 +154,57 @@ def test_heal_clears_ephemeral_fields():
154154
assert healed["blockerCount"] == 0
155155

156156

157-
def test_heal_preserves_session_id_and_timestamp():
158-
"""heal_stale_state keeps sessionId and sessionStartTimestamp intact."""
157+
def test_heal_preserves_session_id_and_moves_timestamp_to_forensics():
158+
"""heal_stale_state keeps sessionId but relocates sessionStartTimestamp.
159+
160+
Historical note: an earlier version of this function preserved
161+
``sessionStartTimestamp`` verbatim for "audit / forensics". That
162+
caused the Wave 1-B duration-leak bug — ``resolve_duration`` in
163+
codingbuddy-hud.py uses ``sessionStartTimestamp`` as a fallback
164+
when stdin has no ``total_duration_ms``, so a healed (but
165+
timestamp-retaining) state rendered enormous durations
166+
(e.g., ``322h52m``) for brand-new sessions.
167+
168+
The fix: relocate the timestamp into ``_healedFromSessionStartTimestamp``
169+
so forensic value is preserved for debuggers/tests while the render
170+
fallback path no longer sees it.
171+
"""
159172
state = {
160173
"sessionId": "abc-123",
161174
"sessionStartTimestamp": "2026-04-01T00:00:00+00:00",
162175
"currentMode": "ACT",
163176
}
164177
healed = hud_session.heal_stale_state(state)
165178
assert healed["sessionId"] == "abc-123"
166-
assert healed["sessionStartTimestamp"] == "2026-04-01T00:00:00+00:00"
179+
# Render fallback path must not see the stale timestamp
180+
assert healed["sessionStartTimestamp"] == ""
181+
# Forensic value is preserved for post-mortem debugging
182+
assert healed["_healedFromSessionStartTimestamp"] == "2026-04-01T00:00:00+00:00"
183+
184+
185+
def test_heal_without_timestamp_does_not_add_forensics_field():
186+
"""If there is no sessionStartTimestamp to heal, no forensics field is added."""
187+
state = {"sessionId": "abc-123", "currentMode": "ACT"}
188+
healed = hud_session.heal_stale_state(state)
189+
assert healed["sessionStartTimestamp"] == ""
190+
assert "_healedFromSessionStartTimestamp" not in healed
191+
192+
193+
def test_heal_is_idempotent_on_forensics_field():
194+
"""Re-healing a healed state preserves the forensics timestamp stably.
195+
196+
Guards against a naive implementation that would overwrite
197+
``_healedFromSessionStartTimestamp`` with the empty string on
198+
the second pass, losing the original value.
199+
"""
200+
stale = {
201+
"sessionId": "manual-fix",
202+
"sessionStartTimestamp": "2026-03-29T04:10:47+00:00",
203+
}
204+
once = hud_session.heal_stale_state(stale)
205+
twice = hud_session.heal_stale_state(once)
206+
assert twice["_healedFromSessionStartTimestamp"] == "2026-03-29T04:10:47+00:00"
207+
assert twice["sessionStartTimestamp"] == ""
167208

168209

169210
def test_heal_does_not_mutate_input():

0 commit comments

Comments
 (0)