Skip to content

fix(frontend): reconcile activity indicator stuck-ON latch across sessions (lr-96e7da) - #398

Merged
clagentic-merger[bot] merged 6 commits into
mainfrom
fix/lr-96e7da-activity-stuck-on
Aug 19, 2026
Merged

fix(frontend): reconcile activity indicator stuck-ON latch across sessions (lr-96e7da)#398
clagentic-merger[bot] merged 6 commits into
mainfrom
fix/lr-96e7da-activity-stuck-on

Conversation

@clagentic-builder

Copy link
Copy Markdown
Contributor

What changed

Fix for lr-96e7da (MILLER diagnosis): activity indicator STUCK-ON, the inverse failure of lr-6e20f7 stuck-OFF. store.processing (the field app-favicon.js initActivityFooter subscribes to) was a client-local edge latch with no server reconciliation. Missing a single 1-\u003e0 done edge left the footer widget stuck on forever.

All four items from MILLER hard evidence fix class, one PR:

(a) SESSION-SCOPE the latch writers. app-messages.js status/done/auth_required handlers now guard on shouldApplyActivityEdge(msg.localId, activeSessionId), mirroring the existing lr-0827ba scheduled_message_* pattern. Server-side, localId is stamped centrally at the sessions.js doSendToSession/doSendAndRecord choke point (reused by project.js sendToSession/sendToSessionOthers) instead of at each of the ~15 individual send call sites -- reuse-first, one place, not per-call-site duplication.

(b) RECONCILE on snapshot. project-connection.js hydration path session_switched previously omitted isProcessing (sessions.js own switchSession() send already had it -- this was the smoking gun MILLER cited). Now both server send sites carry it. Client session_switched handler now reconciles store.processing from the authoritative sessionIsProcessing snapshot on every switch/reconnect/hydration, not only on live status/done edges.

(c) CLIENT-SIDE STALENESS BACKSTOP. A single one-shot timer (mirrors sdk-bridge.js ACTIVITY_STALE_MS = 5 min) arms on the 0-\u003e1 processing transition and disarms on 1-\u003e0 -- never a recurring poll, never more than one in-flight timer regardless of turn count. On fire it re-sends the EXISTING switch_session request (same message every sidebar click already sends), which the server answers with a fresh session_switched carrying authoritative isProcessing; item (b) reconciliation then applies it. Bound named explicitly: at most one re-request per 5-minute window per session-focus-duration, zero new wire messages, zero polling.

(d) CI: added test/activity-latch-lr-96e7da.test.js with EXECUTED behavioral transitions (not a regex) driving status:processing -\u003e done and asserting the widget is removed, plus a cross-session done leaving the focused session widget unaffected, plus a cross-session status:processing not raising the focused widget. This directly replaces the blind spot MILLER named at activity-transcript-footer-lr-6e20f7.test.js:157 (a static source-text regex that only proved the wire exists, never that it converges to false). Because app-favicon.js/app-messages.js are DOM-heavy and unimportable in a plain Node test process (a pre-existing circular-import ordering hazard between theme.js and markdown.js, unrelated to this fix and out of scope to repair), the pure decision logic (session-scoping predicate + staleness-timer state machine) was extracted into a new DOM-free module, lib/public/modules/activity-latch.js, mirroring why activity-state.js was carved out of the sidebar/hub render sites for the same reason. Source-inspection tests confirm the real DOM-driving files call into the proven pure functions rather than reimplementing the guard inline. Did NOT modify lib/session-activity.js -- the server registry is untouched, confirmed correct by MILLER.

Highest-risk item: two PRE-EXISTING test files needed source changes to accommodate the fix (not to make failing assertions pass -- both were correctly asserting the pre-existing shape and my diff legitimately changed that shape)

  1. test/activity-state-lr-66c118.test.js CI invariant fix(lr-9b32): clear vendor lock state on project switch #2 locks .isProcessing reads in app-messages.js to exactly ONE documented site (sessionIsProcessing: !!msg.isProcessing). My initial diff added a second raw msg.isProcessing read for the new processing field, which the invariant correctly caught as drift. Fixed by re-deriving processing from store.get(sessionIsProcessing) instead of a second raw read -- zero new .isProcessing occurrences, invariant unchanged, no loosening.
  2. test/processing-indicator-subagent-lr-255e.test.js asserts resetToolState() appears within a fixed 1200-char window from the done handler start. My added session-scoping comment pushed it past that window. Fixed by trimming the in-handler comment to a one-liner and moving the fuller rationale to a comment above the done: key (outside the sliced window) -- no assertion loosened, no test modified, only my own source comment length changed.

Neither existing invariant was touched, loosened, or had its pattern relaxed. Both failures were my code needing to fit the existing correct shape, not the other way around.

Trade-off named explicitly (chattiness bound)

Item (c) staleness backstop is bounded: one-shot timer, re-arms only on a fresh 0-\u003e1 transition (a genuinely new turn), never a fixed-interval poll. At most one switch_session re-request per ACTIVITY_STALE_BACKSTOP_MS (5 min) per session-focus-duration. This reuses the EXISTING switch_session wire message (same one every sidebar click sends) rather than adding a new message type, so it introduces zero new O(sessions x clients) broadcast surface -- it is a client-initiated single targeted request, not a server broadcast, so it does not interact with lr-9bcd7b transition-only broadcast bound at all.

Tests

npm test: 1429 total (varies run-to-run per project convention), 1428 pass, 0 fail, 1 skipped (test/session pre-existing flaky-under-load skip, unrelated to this diff, present on unmodified main). New file test/activity-latch-lr-96e7da.test.js confirmed EXECUTED (not silently dropped) -- all 16 of its tests appear in the TAP log with individual ok lines, and npm test exit code 0 confirms check-test-count.js per-file completion gate passed. Confirmed the new behavioral tests actually fail on unmodified pre-fix source (verified via git stash before finalizing).

TASK: lr-96e7da

…hoke point (lr-96e7da)

Server-side session-scoping backstop for the client's activity-indicator latch. status/done/auth_required broadcasts carried no session-identifying field, unlike the existing scheduled_message_* pattern, so a client had no way to reject an edge meant for a different session. Stamped centrally in sessions.js's doSendToSession/doSendAndRecord (and reused by project.js's own sendToSession/sendToSessionOthers) rather than at each of the ~15 individual send call sites.
…ion_switched/status (lr-96e7da)

The fresh-connect/reconnect hydration path's session_switched send omitted isProcessing, unlike switchSession()'s own send — a client hydrating via this path never received the authoritative reconciled activity state on the message it uses to derive the footer. Also stamps localId on the trailing status:"processing" hydration send so the client can session-scope it, matching the choke-point stamping added for the other send paths.
Extracts two DOM-free decision functions used by the activity-indicator stuck-ON fix: shouldApplyActivityEdge (session-scoping predicate for status/done/auth_required) and createActivityStaleBackstop (one-shot, never-stacking staleness timer state machine). Carved out because app-favicon.js/app-messages.js's import graph is not importable in a plain Node test process (a pre-existing circular-import ordering hazard between theme.js and markdown.js), mirroring why activity-state.js was carved out of the sidebar/hub render sites for the same reason.
…and reconcile on session_switched (lr-96e7da)

Client-side half of the activity-indicator stuck-ON fix. The status/done/auth_required handlers wrote the global 'processing' latch unconditionally regardless of which session a message belonged to -- a background session's status:"processing" could raise the focused session's dot with no matching done ever routed back to clear it. Guards now use activity-latch.js's shouldApplyActivityEdge, mirroring the existing lr-0827ba scheduled_message_* pattern. session_switched also now reconciles the footer's 'processing' field from the authoritative sessionIsProcessing snapshot on every switch/reconnect/hydration, closing the gap where connect/reconnect never corrected a stale latch.
…oter (lr-96e7da)

Mirrors sdk-bridge.js's server-side ACTIVITY_STALE_MS (5 min) sweep, which sweeps only the (already-correct) server registry and can never repair this client-local latch. A single timer arms on the 0->1 'processing' transition and disarms on 1->0 -- never a recurring poll, never more than one in-flight timer. On fire, it re-sends the existing switch_session request for the focused session, which the server answers with a fresh session_switched carrying authoritative isProcessing; the session_switched reconciliation added in the prior commit then applies it. Backstop for a trigger this fix's session-scoping/reconciliation did not anticipate, not a substitute for them.
…-96e7da)

Replaces the CI blind spot in the predecessor stuck-OFF fix's test suite, which proved only that store.subscribe(['processing']) exists via static source-text regex -- never driving a real transition, never asserting the widget clears, never exercising a missed-edge or cross-session scenario. This file drives real state through the frontend store.js + the new pure activity-latch.js module: status:processing -> done removes the widget; a cross-session done leaves the focused session's widget unaffected; a cross-session status:processing does not raise the focused session's widget. Also covers the staleness-backstop timer's arm/clear/one-shot bound, and source-inspects (the correct/limited grep use per this suite's convention) that the real DOM-driving files call into the proven pure functions rather than reimplementing the guard inline.
@clagentic-security

Copy link
Copy Markdown

BOBBIE — clean

Audited PR #398 (fix/lr-96e7da-activity-stuck-on -> main), base d14992b, head e451f29. 7 files changed, +646/-7, matches reported scope.

Specific exposure checks (per dispatch):

  • localId stamping (lib/sessions.js stampActivityLocalId, reused by lib/project.js sendToSession/sendToSessionOthers): localId is the pre-existing per-session identifier already sent client-side via session_switched.id before this PR; no path/user/session-secret disclosure. sendToSessionOthers client membership filter (ws !== sender && ws._clayActiveSession === sessionId) is unchanged by this diff. Orthogonal to lr-c4da07 (hub-level getAllProjectSessions cross-user filtering) -- this diff never touches that code path.
  • isProcessing on hydration session_switched (lib/project-connection.js:242): plain boolean, no new sensitive state; send is gated behind the pre-existing canAccessSession/presence-restore authorization chain, unchanged by this PR.
  • lib/public/modules/activity-latch.js: pure decision module (session-id equality check + timer state machine), zero DOM sinks, no string/label ever reaches innerHTML or equivalent.
  • Client staleness timer (app-favicon.js): re-sends the pre-existing, already-authenticated switch_session message over the client own WS connection for its own already-focused session id; one-shot, re-arm replaces rather than stacks, capped at one re-request per 5-minute stuck-processing window per session. Server-side switch_session handler (lib/project-sessions.js) is untouched by this diff. Not a new unauthenticated path, not amplifiable.
  • Prototype pollution: no new client-controlled bracket-write. ACTIVITY_LATCH_TYPES is a static 3-key object literal read via server-set obj.type (read-only lookup, not a dynamic-key write).

package.json / package-lock.json: no delta in base..head. bobbie.dep.* not applicable to this PR.

Scanners: gitleaks (0 leaks, 6 commits scanned), trufflehog (0 verified/unverified secrets, 27 chunks/38640 bytes), semgrep --config=auto (14 findings, all outside this PR diff hunks or false positives on trusted-literal input, e.g. test/activity-latch-lr-96e7da.test.js:77 path.join uses only hardcoded literal rel args from this same file), osv-scanner skipped (no lockfile delta).

No findings meet the Pre-Report Gate. review.status: clean.

{"reviewer": "bobbie", "review_status": "clean", "head_sha": "e451f29ad2f9241ff9ce894f0bdfc329bd572c82", "pr_number": 398}

@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — clean

  • activity-latch.js test is real behavioral (status→done→widget removed)
  • app-messages.js status/done/auth_required guarded with shouldApplyActivityEdge
  • activity-latch.js backstop one-shot timer
  • lib/sessions.js stampActivityLocalId centralized
  • lib/session-activity.js untouched
  • Brand rules clean
{"reviewer": "peaches", "review_status": "clean", "head_sha": "e451f29ad2f9241ff9ce894f0bdfc329bd572c82", "pr_number": 398}

@clagentic-merger
clagentic-merger Bot merged commit 4e6ec2d into main Aug 19, 2026
3 of 4 checks passed
@clagentic-merger

Copy link
Copy Markdown
Contributor

Merged via clagentic-loadout v0.2.0

Field Value
Gated HEAD SHA e451f29ad2f9241ff9ce894f0bdfc329bd572c82
Merged SHA e451f29ad2f9241ff9ce894f0bdfc329bd572c82
Reviews clagentic-reviewer[bot], clagentic-security[bot]
CI status no-runner-by-design (0 commit-status entries at HEAD)
task_id lr-96e7da

@clagentic-merger
clagentic-merger Bot deleted the fix/lr-96e7da-activity-stuck-on branch August 19, 2026 18:16
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.

0 participants