refactor(usage): split compact/reset buttons, fix 5 blockers - #223
refactor(usage): split compact/reset buttons, fix 5 blockers#223BCDel89 wants to merge 2 commits into
Conversation
TerrysPOV
left a comment
There was a problem hiding this comment.
Thanks for taking this on — the JSONL path fix and the resetSessionById cache-coherence fix are both real bugs worth squashing, and the modal is a nice UX upgrade on window.confirm().
I'd like a few things addressed before I approve. I ran the in-tree 5-agent review plus an independent Codex review and they triangulated on the same set of concerns, so I'm fairly confident these are real.
Blockers
1. Version bumps not run. .claude-plugin/plugin.json and .claude-plugin/marketplace.json are both still at 1.0.38; master is at 1.1.2. Both plugin-version-guard and marketplace-version-guard will fail. CLAUDE.md has the note — run:
bun run bump:plugin-version
bun run bump:marketplace-version
2. Cancel doesn't actually cancel the server-side work. activeController.abort() aborts the client fetch, but src/ui/server.ts keeps awaiting resetSessionById, which still calls compactCurrentThreadSession → removeThreadSession. So if a user clicks Cancel mid-compact, the server can still finish compacting and delete the session — the UI just doesn't see the result. Either plumb an AbortSignal all the way through (accept it in resetSessionById, kill the spawned compact process in runCompact, skip removeThreadSession after abort), or remove the cancellation promise and grey out Cancel once the request is in flight. As written the UI lies about what Cancel does.
3. Compact failures silently become destructive reset success. compactCurrentThreadSession(session.threadId).catch(() => {}) swallows everything, then removeThreadSession() runs unconditionally. runCompact() in src/runner.ts:994 returns {success: false, message: ...} for failed compacts rather than throwing — so even a clean failure path leads straight to deletion with no signal to the user. The modal title says "Compact & Reset" and the status messages say "Compacting conversation history…" but if compact silently fails the conversation history is just dropped. Either bubble the compact result back to the client (let the user choose "compact failed — proceed with reset anyway?") or refuse to reset if compact failed.
4. Web session path skips compact while the modal says "Compact & Reset". For the global/web session (no threadId) the compact step is skipped entirely, but the modal title and status messages claim compacting. Either compact the web session too, or branch the modal copy so it reads "Reset" (not "Compact & Reset") when there's no threadId.
5. Concurrent resets bypass the per-thread enqueue. runCompact() calls runClaudeOnce(...) directly, not through the enqueue(fn, threadId) serial queue at src/runner.ts:262. Two browser tabs hitting reset on the same session can both pass listThreadSessions before either removes the row, then run concurrent /compact --resume against one Claude session. Worth routing through the existing queue so the existing concurrent-resume protection covers this path.
Secondary asks (would like in this PR)
-
Listener accumulation on modal re-open. Each click on a Reset button calls
addEventListener('click', onOk/onCancel)without first removing any prior listeners.cleanup()removes them at the end, but if a user opens the modal from row A, then opens it from row B without clicking anything in between, you'll stack listeners. Track them on the elements and clean before re-binding. -
Cache-Control: no-storeis only on the success response. The error path goes through the genericjson()helper without the header — a caching proxy could serve a stale error. Apply consistently. -
findJSONLPathshould filter to directories.readdir(nowithFileTypes) plusexistsSyncwill follow symlinks. If a symlink in~/.claude/projects/points to a directory containing<sessionId>.jsonl, you could pick up the wrong project's data for that UUID. Usereaddir(..., { withFileTypes: true })anddirent.isDirectory(). -
Unbounded fan-out scan. On cold
/api/usagethe worst case isactive_sessions × all ~/.claude/projects subdirs+ full JSONL reads. The 60s cache mitigates but degrades with project history size. Worth either bounding by name prefix (only dirs starting with-Users-…-claudeclaw-) or memoising thesessionId → dirmap across requests. -
No tests. Reset destroys data — at least a failure-path test (compact fails → does reset still happen / not happen?) would catch the silent-destruction concern in #3 if you change the behaviour.
Context
- The cache-coherence bug you're fixing is the same class flagged P1 in PR #147 for fallback sessions; routing through
removeThreadSession()is exactly the pattern that review recommended. Good precedent following. - PR #222 (auto-compact by turn count, currently CHANGES_REQUESTED) doesn't modify
removeThreadSessionorcompactCurrentThreadSessionsignatures, so there's no hard merge conflict — just a soft semantic risk that an auto-compact-by-turns could fire on the same turn as a manual compact triggered by reset. Worth a thought once #222 lands.
Vision: aligned. Complexity: moderate (5 files, +298 lines, frontend + service layer + endpoint). Once 1–5 land I'll do another pass.
TerrysPOV
left a comment
There was a problem hiding this comment.
Thanks for the thorough rework — all 5 prior blockers cleared. Re-running the 5-agent review against a8faff6 plus an independent Codex pass surfaced 2 new blockers introduced by the refactor, plus the version bump is no longer ahead of master (PR #226 just landed and brought master to 1.0.39).
Code review
Found 3 issues:
-
onCompactCancelguard is inverted — the early-return fires precisely when the operation is in-flight, which is the only time a user would click Cancel. The comment above it says "close the modal and unblock the button", but the guard returns beforecleanupCompact()runs, so the modal stays open and the Cancel button is effectively inert during the long-running compact. Either drop the guard or invert it (e.g.if (!inFlightSessions.has(sessionId))would be the no-op path).claudeclaw/src/ui/page/script.ts
Lines 1696 to 1710 in a8faff6
-
Web-session reset in
resetSessionByIdonly renamessession.jsonto a backup but doesn't clear the in-memory session cache (currentinsrc/sessions.ts). The runner readsgetSession()and resumes from the cachedcurrentunlessresetSession()is called — so after a web-session reset, a still-running runner process keeps using the oldsessionId. The Discord branch correctly routes throughremoveThreadSession. CallingresetSession()(or an equivalent in-memory invalidation) in the web branch would mirror that.claudeclaw/src/ui/services/usage.ts
Lines 166 to 184 in a8faff6
-
Version conflict — both
plugin.jsonandmarketplace.jsonbump to1.0.39, but master is already at1.0.39after PR #226 merged this morning. Re-runbun run bump:plugin-version+bun run bump:marketplace-versionso the bump lands ahead of master (1.0.40) and the guard CI checks pass.claudeclaw/.claude-plugin/plugin.json
Lines 1 to 5 in a8faff6
Smaller follow-ups (non-blocking, mention if convenient):
.usage-compact-btnclass is referenced in the rendered HTML but has no rule instyles.ts— the button will inherit defaults rather than match the.usage-reset-btnstyling.compactSessionByIdhas no server-side concurrency guard; the client-sideinFlightSessionsset is the only thing preventing concurrent compact subprocess spawn on the same session. A logged-in attacker (or runaway client loop) with a valid bearer token can bypass it.findJSONLPathuses syncexistsSyncinside an async loop. Minor consistency nit with the rest of the file.
Once (1), (2), and (3) are addressed I'll approve and merge.
a8faff6 to
fdf7162
Compare
TerrysPOV
left a comment
There was a problem hiding this comment.
Code review
Big improvement — all prior blockers from both earlier rounds are resolved in code. Verified the two round-2 ones directly: the inverted onCompactCancel guard is gone (cancel now just runs cleanupCompact()), and web reset now clears the in-memory cache (resetSession() + invalidateUsageCache(), with thread sessions routed through removeThreadSession). The inFlightSessions lock is balanced across success/error/network-failure paths, and the findSessionJsonlPath integration is correct.
Four items remain before this can merge:
-
Version is stale again — this branch bumps to 1.0.41, but master is now 1.0.43, and that's the only thing making the PR conflict (the usage/session code merges clean). Rebase on master and re-bump both
.claude-plugin/*.jsonto 1.0.44. -
compactSessionByIddoesn't invalidate the usage cache, unlikeresetSessionById. After a successful compact, the 60s in-memoryusageCacheserves pre-compact data, so the compact looks like a no-op in the dashboard for up to a minute. One-line fix mirroring the reset path:
claudeclaw/src/ui/services/usage.ts
Lines 145 to 153 in fdf7162
Compare the reset path, which invalidates on every branch:
claudeclaw/src/ui/services/usage.ts
Lines 164 to 177 in fdf7162
-
POST /api/usage/:id/compacthas no server-side concurrency guard. It spawnsclaude -p /compact --resume <sessionId>with noisMainBusy/session-level lock, so an HTTP compact can fire while a Discord turn is actively resuming the same sessionId — two processes resuming one session id, risking JSONL corruption or a forked session. The clientinFlightSessionslock is per-tab and can't see the runner. Partly inherent to the existingcompactCurrentThreadSession, but this PR makes it triggerable on demand — worth a guard (or an explicit acknowledgement of the race). -
No tests. Reset renames/deletes session state, so at minimum a
resetSessionByIdtest (global + thread branches, and the deny-on-missing case) would lock in the destructive path. This was raised in earlier rounds and is still open across the 7 changed files.
Non-blocking nits: modal handlers addEventListener on each open without pre-removal, so opening one row's modal then another stacks listeners (cleanup only runs on click); and Cache-Control: no-store is set only on the /api/usage success path, not the error path.
Adds a session-usage dashboard action to compact (Discord only) or reset (all sessions) a running session, with separate confirm modals, proper failure propagation, and per-session concurrency locking. Addresses two rounds of review feedback: - Version bumps, Cancel disabled during in-flight ops, compact failure surfaced to the client, Compact restricted to Discord sessions, per-session concurrency lock - Inverted onCompactCancel guard fixed (Cancel now hides instead of a broken disabled-check), missing .usage-compact-btn styling added, web-session reset now clears sessions.ts's in-memory `current` cache via resetSession() so a still-running runner can't resume a stale sessionId after reset
…acking, error Cache-Control - compactSessionById now invalidates the usage cache like resetSessionById does, so the dashboard reflects a compact immediately instead of serving stale data for up to 60s. - compactCurrentThreadSession now runs through the same per-thread queue (enqueue) that serializes normal Discord/Telegram message runs, closing a race where an HTTP-triggered compact could run concurrently with a live turn resuming the same session. - Guard both compact/reset modals against opening a second instance while one is already open, which previously stacked duplicate click listeners on the shared confirm buttons. - /api/usage's error response now sets Cache-Control: no-store like the success path already does. - Add src/__tests__/usage.test.ts covering resetSessionById's global session, thread session, not-found, and invalid-id paths.
fdf7162 to
38d858e
Compare
|
Addressed all 4 + both nits:
Nits:
|
Summary
POST /api/usage/:sessionId/compactendpoint; reset no longer triggers compact firstFixes from Terry's review
disabled = trueon start, restored in cleanupcompactSessionByIdreturns{success, message}; client alerts on failureinFlightSessionsSet prevents double-submit on same session