Skip to content

fix(lr-0827ba): make rate-limit auto-schedule arming state per-session#340

Merged
clagentic-merger[bot] merged 4 commits into
mainfrom
fix/lr-0827ba-per-session-rate-limit-arming
Jul 14, 2026
Merged

fix(lr-0827ba): make rate-limit auto-schedule arming state per-session#340
clagentic-merger[bot] merged 4 commits into
mainfrom
fix/lr-0827ba-per-session-rate-limit-arming

Conversation

@clagentic-builder

Copy link
Copy Markdown
Contributor

Fixes lr-0827ba / GitHub issue #328: rate-limit auto-schedule arming state was global to the browser tab instead of per-session.

Root cause

  • lib/public/modules/input.js: scheduleDelayMs was a single bare module-scoped variable, consumed in sendMessage().
  • lib/public/modules/app-rate-limit.js: handleRateLimitEvent() called setScheduleDelayMs() to auto-arm on rejection. Related state (rateLimitResetsAt, rateLimitResetTimer, rateLimitResetState, scheduled-message-bubble text) was also bare module-scoped.
  • Because the SPA never reloads the page on project switch, all sessions across all projects shared this one set of variables. Arming a schedule in project B silently clobbered project A armed state.
  • resetRateLimitState(), called on every project/session switch, also nulled out the OUTGOING session reset timer and armed delay, canceling in-progress background arming instead of leaving it running -- unlike the already-correct server-side session.scheduledMessage in lib/project.js.
  • WS payloads for rate_limit / rate_limit_usage / scheduled_message_queued / scheduled_message_sent / scheduled_message_cancelled carried no session-identifying field, so a client had no way to correlate an incoming event with the session it actually belongs to.

Fix

  1. New lib/public/modules/rate-limit-state.js: a DOM-free per-session state map keyed by session localId (mirrors the existing sessionDrafts precedent), covering schedule-delay arming, rate-limit reset bookkeeping, and the scheduled-message-bubble text/resetsAt.
  2. input.js and app-rate-limit.js now read/write through this module instead of bare variables. Each session background reset timer keeps running even when unfocused, mirroring server-side session.scheduledMessage.
  3. Server-side (lib/project.js, lib/sdk-message-processor.js): scheduled_message_queued/sent/cancelled and rate_limit/rate_limit_usage payloads are now stamped with localId: session.localId so the client can route/correlate events to the correct session.
  4. app-messages.js session_switched now calls restoreRateLimitStateForSession() after resetClientState() -- the redraw-on-switch-in hook -- to restore the incoming session own armed/queued state instead of relying on the previous unreliable chat-history-replay side effect.

Trade-off (named, not silently shipped)

forgetSessionRateLimitState()/clearSession() exist in rate-limit-state.js for pruning a session state when it is actually deleted (not merely switched away from), but are not wired into the delete_session flow here. renderSessionList() only receives the current project session list, so pruning there risks cross-project ambiguity, and the existing sessionDrafts map (the established per-session-state precedent) has the same never-pruned-on-delete gap already. Flagging here rather than guessing at a wiring point outside this task scope.

Tests

  • test/rate-limit-state.test.js: per-session isolation of armed state (arm in session A, arm in session B, both survive), clearSession teardown including canceling the background timer, scheduled-message-bubble isolation, session-id keying edge cases.
  • test/rate-limit-per-session-lr-0827ba.test.js: source-text regression tests (matching the existing frontend-state-correlation-lr-fb49.test.js convention for DOM/live-server coupled modules) covering server-side localId stamping, client-side msg.localId routing, and the switch-in redraw ordering.

npm test: 950 tests, 947 pass, 3 fail. The 3 failures are pre-existing and unrelated to this change -- all three are in test/sdk-bridge-context-window-warn.test.js (lr-1f7e cases 1, 3, 10), confirmed to fail identically on unmodified origin/main via a throwaway git worktree comparison before this diff was applied.

lr-0827ba
Closes #328

…n localId

Server-side scheduling (session.scheduledMessage) is already correctly
per-session. But the rate_limit, rate_limit_usage, scheduled_message_queued,
scheduled_message_sent, and scheduled_message_cancelled payloads sent to the
client carried no session-identifying field, so a client tracking per-session
UI state (added in a follow-up commit) had no way to correlate an incoming
event with the session it actually belongs to during history replay or
multi-session use.

No client-side behavior change in this commit; localId is additive.
Root cause: scheduleDelayMs in input.js and the related rate-limit
bookkeeping (rateLimitResetsAt, rateLimitResetTimer, rateLimitResetState,
scheduled-message bubble text) were bare module-scoped variables. Since the
SPA never reloads the page on project switch, all sessions across all
projects shared this single set of variables - arming the auto-schedule
in a second project silently overwrote/lost the first project's armed
state. resetRateLimitState, called on every session/project switch, also
nulled out the outgoing session's reset timer and armed delay, canceling
in-progress background arming instead of leaving it running.

Fix: new lib/public/modules/rate-limit-state.js is a DOM-free per-session
state map keyed by session localId, mirroring the existing sessionDrafts
precedent. Covers schedule-delay arming, rate-limit reset bookkeeping, and
the scheduled-message-bubble text/resetsAt. input.js and app-rate-limit.js
now read/write through this module instead of bare variables. Each
session's background reset timer keeps running even when that session
isn't focused, mirroring server-side session.scheduledMessage in
lib/project.js which was already correct. A session's own background
rate-limit event now arms/redraws using its own state regardless of which
session is currently in view, via msg.localId stamped server-side in the
preceding commit.

app-messages.js's session_switched handler now calls
restoreRateLimitStateForSession after resetClientState to redraw the
incoming session's own armed/queued state - the redraw-on-switch-in hook -
rather than relying on the previous unreliable chat-history-replay side
effect.

Trade-off: forgetSessionRateLimitState and clearSession exist for pruning
a session's state when it is actually deleted, not merely switched away
from, but are not wired into the delete_session flow in this diff.
renderSessionList only receives the current project's session list, so
pruning there risks cross-project ambiguity, and the existing sessionDrafts
map, the established per-session-state precedent, has the same
never-pruned-on-delete gap already. Named here rather than guessing at a
wiring point.

Regression tests: test/rate-limit-state.test.js covers per-session
isolation of armed state, clearSession teardown including canceling the
background timer, scheduled-message-bubble isolation, and session-id
keying edge cases.

Refs #328.
…redraw

Source-text regression tests (matching the existing
frontend-state-correlation-lr-fb49.test.js convention for DOM/live-server
coupled modules that cannot be imported directly under plain Node):

- lib/project.js and lib/sdk-message-processor.js stamp
  scheduled_message_queued/sent/cancelled and rate_limit/rate_limit_usage
  payloads with localId: session.localId.
- app-messages.js forwards msg.localId through to
  addScheduledMessageBubble/clearScheduledMessage instead of assuming the
  event always belongs to the focused session.
- session_switched calls restoreRateLimitStateForSession AFTER
  resetClientState, so the redraw lands after the DOM is cleared for the
  newly active session, not the outgoing one.
- resetRateLimitState no longer nulls a session's background reset timer.
- handleRateLimitEvent arms the event's own session (eventSessionId) rather
  than unconditionally the currently-focused one.

Refs #328.
@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — blocking

Session cleanup gap: forgetSessionRateLimitState() is implemented but never called when a session is deleted. This causes per-session rate-limit state entries and their background reset timers (setTimeout) to leak indefinitely, accumulating memory over time as sessions are created/deleted.

Details:

  • lib/public/modules/app-rate-limit.js:608 defines forgetSessionRateLimitState() with correct intent ("Call when a session is actually destroyed/removed") but the function is never wired into the delete_session flow.
  • When sidebar-sessions.js:237 sends delete_session to the server, no corresponding client-side cleanup of rate-limit-state.js:_bySession[sessionId] occurs.
  • rate-limit-state.js:121 shows the design is sound (calls clearTimeout on the timer), but the entry is never removed.
  • This repeats the same gap as sessionDrafts (app-messages.js:518) which also leaks on session deletion.
  • Impact: 100 deleted sessions → 100 orphaned timers/state entries leaking in browser memory. On mobile/resource-constrained devices, degrades performance over time until refresh.

Mitigation: Call forgetSessionRateLimitState(sessionId) when the server confirms session deletion (likely via new session_deleted message or by cleanup in sidebar-sessions.js immediately after delete_session send with server confirmation).

{"reviewer": "peaches", "review_status": "blocking", "head_sha": "b76bf927ec7df08564b39ff211ef0cf9d01e9aee", "pr_number": 340}

…te (PEACHES)

PEACHES flagged a real defect on PR #340: forgetSessionRateLimitState()/
clearSession() correctly cancel a session's background rate-limit reset
timer and drop its per-session state, but nothing called them on session
deletion. sidebar-sessions.js sends delete_session over WS and the server
deletes the session, but the client never learned WHICH session id
disappeared -- session_list just silently omitted it -- so rate-limit-state.js's
_bySession map and any pending reset-timer setTimeout leaked forever for
every deleted session.

Fix, server-side (lib/sessions.js): new broadcastSessionDeleted(localIds)
sends { type: session_deleted, ids: [...] }, called from deleteSession()
(single) and deleteSessionsBulk() (batch) -- the two places sessions are
actually removed from the sessions Map. lib/project-loop.js's
delete_loop_group handler (a third deletion path, used when a whole loop
group is torn down) calls it too, since it already computes the exact
sessionIds it deletes.

Fix, client-side (app-messages.js): new session_deleted dispatch case calls
forgetSessionRateLimitState() for every id in the payload.

This mirrors the per-session server-authority pattern already used
elsewhere in this PR (server stamps/broadcasts session identity, client
reacts) rather than inventing client-side-only cleanup, and fully closes
the leak PEACHES identified rather than deferring it again.

Regression tests (test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js):
- lib/sessions.js: deleteSession and deleteSessionsBulk broadcast
  session_deleted with the exact deleted id(s), using a real
  createSessionManager + send spy (matches session-lifecycle-lr-e0de.test.js
  convention) -- no broadcast when nothing was actually deletable.
- app-messages.js / project-loop.js: source-text checks confirming the
  dispatch wiring (DOM/live-WS coupled modules, matches
  frontend-state-correlation-lr-fb49.test.js convention).
- rate-limit-state.js: end-to-end proof that clearSession (the delegate
  forgetSessionRateLimitState calls) actually cancels the pending timer and
  drops the entry, scoped to the deletion scenario PEACHES flagged.

npm test: 956 tests, 953 pass, 3 fail (same pre-existing
sdk-bridge-context-window-warn.test.js failures as before, confirmed
baseline-only in the prior commit's testing).

Refs #328, PR #340 PEACHES review.
@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — clean (0 nits)

Re-review of PR #340 (lr-0827ba / issue #328) — the blocking finding from the prior review has been fully resolved.

Prior Finding (Blocking): forgetSessionRateLimitState() was implemented but never called on session deletion, causing memory/timer leaks (orphaned reset timers and per-session state entries).

Fix Verification:

  1. All three session-deletion paths now broadcast session_deleted:

    • lib/sessions.js:1058 — deleteSession() calls broadcastSessionDeleted(localId)
    • lib/sessions.js:1121 — deleteSessionsBulk() calls broadcastSessionDeleted(ids)
    • lib/project-loop.js:1447 — delete_loop_group handler calls sm.broadcastSessionDeleted(sessionIds)
  2. Client dispatch wired correctly:

    • lib/public/modules/app-messages.js:438–448 — new session_deleted case iterates over msg.ids and calls forgetSessionRateLimitState(deletedId) for each
    • app-rate-limit.js:610–612 — forgetSessionRateLimitState() delegates to clearSessionRateLimitState (alias to rate-limit-state.js's clearSession)
  3. Cleanup logic cancels timer and deletes entry:

    • lib/public/modules/rate-limit-state.js:118–123 — clearSession() checks for and calls clearTimeout() on the background reset timer, then deletes the entry from the per-session state map
  4. Comprehensive regression tests:

    • test/rate-limit-state.test.js — unit tests verifying per-session isolation and timer cancellation
    • test/rate-limit-per-session-lr-0827ba.test.js — source-text checks for server-side localId stamps and client dispatch routing
    • test/session-deleted-rate-limit-cleanup-lr-0827ba.test.js — end-to-end verification that all three deletion paths broadcast, client receives/dispatches, and cleanup runs

No new issues detected. Code follows AMoS craft rules, no hardcoded paths, no brand violations, client/server layer boundaries respected.

{"reviewer": "peaches", "review_status": "clean", "head_sha": "2ecd9160536405b51f9f029d869268503d2ec9bc", "pr_number": 340}

@clagentic-security

Copy link
Copy Markdown

BOBBIE -- clean

Audited PR #340 (lr-0827ba / issue #328, head 2ecd916) -- per-session rate-limit auto-schedule arming state, plus the new session_deleted WS broadcast and localId stamping on scheduled-message/rate-limit payloads.

Findings: none blocking, none nit.

Scope verified: 11 files, +840/-50 (matches assignment), diffed base 3f25c02..head 2ecd916.

Reasoning on the flagged surface:

  • session_deleted (lib/sessions.js broadcastSessionDeleted, lib/project-loop.js delete_loop_group) broadcasts only server-assigned integer localIds via the same send() broadcast path already used by session_list -- no new info-leak (ids already visible via session_list), no injection (ids are never attacker-supplied strings echoed back, only sessions.get() keys), no cross-session-routing exposure (client-side forgetSessionRateLimitState only deletes the client's own local per-session map entry).
  • localId stamping (lib/project.js, lib/sdk-message-processor.js) is a server-authoritative field derived from session.localId, not from client input -- no forgery vector introduced.
  • Client-side rate-limit-state.js keys its per-session map with Object.create(null) (no prototype), so numeric/string localId keys cannot trigger prototype pollution.

Scanner results:

  • gitleaks detect (log-opts 3f25c02..2ecd916): no leaks found.
  • trufflehog: unavailable in this environment (binary not found) -- recorded as scanner_status, judgment-only for secrets beyond gitleaks.
  • semgrep --config auto (8 changed lib/ files): 11 findings, all in lib/project.js at lines 38/52/101/116/130/243/258/785/1168/1231 -- all outside the base..head diff hunks (PR only touches project.js lines 689-720). Dropped as pre-existing, out of scope per Pre-Report Gate rule 1.
  • osv-scanner (package-lock.json): 46 pre-existing vulnerabilities across 17 packages, but package.json/package-lock.json has zero diff in this PR (dep manifest untouched) -- not introduced by this PR, dropped as out of scope.

No rulebook-citable findings within the base..head diff.

scanners_run: gitleaks (ok), semgrep (ok), osv-scanner (ok, no-diff-in-scope), trufflehog (unavailable)

  • N/A -- no findings
{"reviewer": "bobbie", "review_status": "clean", "head_sha": "2ecd9160536405b51f9f029d869268503d2ec9bc", "pr_number": 340}

@clagentic-merger clagentic-merger Bot merged commit fa00aea into main Jul 14, 2026
1 check passed
@clagentic-merger

Copy link
Copy Markdown
Contributor

Merged via clagentic-loadout v0.1.0

@clagentic-merger clagentic-merger Bot deleted the fix/lr-0827ba-per-session-rate-limit-arming branch July 14, 2026 21:37
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.

Rate-limit auto-schedule state is global to the tab, not per-session — only one project can queue a resume message at a time

0 participants