Skip to content

feat(space): maintain sessions.visible_message_count counter [#2336] - #2358

Open
lsm wants to merge 2 commits into
devfrom
space/2336-maintain-sessions-visible-message-count-counter
Open

feat(space): maintain sessions.visible_message_count counter [#2336]#2358
lsm wants to merge 2 commits into
devfrom
space/2336-maintain-sessions-visible-message-count-counter

Conversation

@lsm

@lsm lsm commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Replaces the correlated COUNT(*) over sdk_messages that spaceSessions.bySpace ran for every session on every 150ms-debounced re-evaluation (~92ms warm for dev-neokai) with a maintained sessions.visible_message_count column that the query reads directly.

What changed

  • schema: visible_message_count INTEGER NOT NULL DEFAULT 0 on sessions.
  • migration 170: ALTER TABLE + one-time backfill using the badge visibility predicate. (Renumbered from 169 — dev shipped 169 for the message_subtype_norm generated column in perf(daemon): make sdk_messages subtype filters sargable (#2330) #2346, which this branch rebased onto.)
  • SDKMessageRepository: increments on visible inserts (saveSDKMessage / saveUserMessage / saveHyperNeoActionMessage); recomputes affected sessions on send_status transitions, rewind deletes (deleteMessagesAt/After), and pending-message removal. The visibility predicate (top-level rows, non-deferred user rows, non-hidden subtypes) is centralized in isVisibleBadgeRow so the counter cannot drift from the predicate it replaces.
  • spaceSessions.bySpace: selects s.visible_message_count instead of the per-session subquery.

Maintenance no-ops gracefully when the column/table is absent, so unit-test harnesses with a partial schema are unaffected.

Verification

  • spaceSessions.bySpace no longer runs a per-session COUNT(*) (test asserts the badge is decoupled from sdk_messages).
  • New repo tests cover visible/invisible inserts, send_status flips, action messages, rewind recompute, and an anti-drift cross-check (counter == fresh COUNT(*)) across a mixed sequence.
  • New migration-170 test covers backfill correctness, idempotency, and missing-table guards.
  • bun run check (lint/typecheck/knip/db-schema-parity/session-guards) green; storage/agent/session tests pass.

Note: check:test-quality flags one pre-existing issue in provider-registry.test.ts (unmodified by this PR) — not introduced here. One unrelated flaky test (model-service-provider-routing) times out intermittently regardless of this change.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lsm has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Replace the correlated COUNT(*) over sdk_messages that spaceSessions.bySpace
ran for every session on every 150ms-debounced re-evaluation (~92ms warm for
dev-neokai) with a maintained sessions.visible_message_count column read
directly.

- schema: add visible_message_count INTEGER NOT NULL DEFAULT 0 to sessions
- migration 170: ALTER + one-time backfill using the badge predicate
  (renumbered from 169 because dev shipped 169 for message_subtype_norm in #2346)
- SDKMessageRepository: increment on visible inserts (saveSDKMessage,
  saveUserMessage, saveHyperNeoActionMessage); recompute affected sessions on
  send_status transitions, rewind deletes, and pending-message removal
- spaceSessions.bySpace: select s.visible_message_count instead of the
  per-session subquery

Counter maintenance no-ops gracefully when the column/table is absent (unit
test harnesses), keeping the existing suite green. The visibility predicate
(top-level rows, non-deferred user rows, non-hidden subtypes) is centralized in
isVisibleBadgeRow so the maintained counter cannot drift from the predicate it
replaces; a cross-check test asserts it equals a fresh COUNT(*) across a mixed
sequence of inserts, status flips, and rewinds.
@lsm
lsm force-pushed the space/2336-maintain-sessions-visible-message-count-counter branch from 3afafb3 to 721c5cf Compare August 3, 2026 06:05

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lsm has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Comment thread packages/daemon/src/storage/repositories/sdk-message-repository.ts Outdated

@lsm lsm left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review by glm-5.1[1m] (GLM / Zhipu)

Model: glm-5.1[1m] | Client: NeoKai | Provider: GLM (Zhipu)

Recommendation: REQUEST_CHANGES (one P3 nit; everything else is solid).

What I verified

  • The perf fix lands correctly. spaceSessions.bySpace now reads s.visible_message_count directly — the correlated per-session COUNT(*) is gone. The new "messageCount is decoupled from sdk_messages" test proves adding message rows can no longer move the badge; only an explicit update to the maintained column does.
  • Predicate parity is exact across all three sites — INSERT (isVisibleBadgeRow), RECOMPUTE (recomputeVisibleMessageCount), and the migration-170 backfill. I checked every dimension: parent_tool_use_id IS NULL, user rows gated on send_status IN (consumed, failed) with NULL→consumed, subtype not in HIDDEN_SYSTEM_SUBTYPES + thinking_tokens, and non-user rows always visible. NULL/empty-string handling for message_subtype and send_status is identical in all three. messages.bySession matches on every badge-relevant dimension (its two extra filters are transcript-rendering only, intentionally excluded from the badge).
  • Every runtime sdk_messages mutation is covered. All writes funnel through SDKMessageRepository; the 6 structural methods all maintain the counter (3 inserts bump, 2 rewinds + pending-delete + status-flip recompute). Session archive preserves messages (counter stays correct); hard-delete FK-cascades and the session row — and the counter — go together, so no drift. No clear/reset/import/fork bulk-insert path exists.
  • Transactions on the hot paths. saveSDKMessage/saveUserMessage wrap insert + bump in this.db.transaction(...) — atomic. ✓
  • Migration is robust: idempotent (recompute-on-rerun), missing-table guards, correct backfill. Tested.
  • No-op guard (supportsVisibleMessageCount) makes maintenance a safe no-op for partial-schema test harnesses — good design.
  • Tests are comprehensive: visible/invisible inserts, status flips, action messages, rewind recompute, an anti-drift cross-check (counter == fresh COUNT(*)) across a mixed sequence, plus migration backfill/idempotency/guards. Relocating the visibility-predicate coverage from the LQH test to the repo test is the right call.

Verification run locally

check:db-schema-parity ✅ · 4-space-storage shard 1893/1893 ✅ · live-query-handlers test 108/108 ✅ · new repo + migration-170 tests 128/128 ✅.

Findings

  • P3 — saveHyperNeoActionMessage counter bump is outside a transaction (see anchored comment on sdk-message-repository.ts:1786). The other two save paths wrap insert+bump in a transaction; this one doesn't. Trivial one-line fix for consistency. The only thing keeping this from an approve.

Optional observation (not a finding, no change requested)

BADGE_HIDDEN_SUBTYPES (Set, insert path) and EXCLUDED_FROM_PAGINATION_SQL_LIST (SQL, recompute path) both encode HIDDEN_SYSTEM_SUBTYPES + 'thinking_tokens' ~20 lines apart. They're consistent today and the anti-drift test guards against divergence; this also matches the file's existing multi-list style, so I'm not asking for a change — just noting the two representations must stay in sync.

Summary

Clean, well-scoped implementation of exactly what the issue asked for, with thorough tests and correct predicate parity. Only the one P3 transaction-consistency nit on the action-message path — fix that and this is good to go.

…ction

Match saveSDKMessage / saveUserMessage: wrap the INSERT and the
visible_message_count bump in a single transaction so a failure between the
two autocommit statements can't leave the counter under-counted. Addresses
review P3 on #2358.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lsm has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@lsm lsm left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review by glm-5.1[1m] (GLM / Zhipu)

Model: glm-5.1[1m] | Client: NeoKai | Provider: GLM (Zhipu)

Recommendation: APPROVE — zero findings remain.

The P3 from the previous round is addressed correctly in dfa38a0: saveHyperNeoActionMessage now wraps the INSERT + bumpVisibleMessageCount in this.db.transaction(...), with the prepared statement hoisted and upsertMessageSearchRow left outside — exactly matching saveSDKMessage / saveUserMessage. Surgical change, no scope creep.

Re-verified this round:

  • Fix is well-formed and consistent with the other two save paths.
  • Repo suite 121/121 (incl. the action-message increment test) ✅.
  • 4-space-storage shard 1893/1893 ✅.
  • PR is OPEN / MERGEABLE; the single review thread is resolved.

Everything from the prior review stands: the perf fix lands (spaceSessions.bySpace reads the maintained column, correlated per-session COUNT(*) removed), predicate parity is exact across insert/recompute/backfill (incl. NULL handling), every runtime sdk_messages mutation maintains the counter, and tests are comprehensive. Approving.

@lsm

lsm commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dfa38a031f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

-- 150ms debounce). SDKMessageRepository keeps this in sync with the same
-- visibility predicate (top-level rows, non-deferred user rows, non-hidden
-- subtypes) on every sdk_messages mutation.
s.visible_message_count as messageCount,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit sessions changes when updating the badge counter

When normal message writes go through reactiveDb.db.saveSDKMessage / saveUserMessage / updateMessageStatus, createReactiveDatabase still emits only a sdk_messages table change for those facade methods, and LiveQueryEngine reevaluates subscriptions only for tables extracted from the SQL. After this change spaceSessions.bySpace is indexed under sessions/spaces and no longer under sdk_messages, while the counter update is performed inside SDKMessageRepository against the raw SQLite connection, so no sessions change event is emitted. In a running space, the sidebar badge will therefore stay stale after messages arrive or statuses flip until some unrelated session/space write happens; please also notify/invalidate sessions for visible-message-count maintenance or keep an explicit sdk_messages dependency.

Useful? React with 👍 / 👎.

-- 150ms debounce). SDKMessageRepository keeps this in sync with the same
-- visibility predicate (top-level rows, non-deferred user rows, non-hidden
-- subtypes) on every sdk_messages mutation.
s.visible_message_count as messageCount,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — reactivity regression: the badge query lost its sdk_messages dependency, and the counter write emits no sessions event.

This SELECT is what LiveQueryEngine uses to index the query. Old SQL had a correlated FROM sdk_messages subquery, so extractTables() yielded ["sdk_messages","sessions","spaces"] — every saveSDKMessage (which emits a sdk_messages change per METHOD_TABLE_MAP) re-evaluated the badge live. Verified: the new SQL extracts only ["sessions","spaces"] — the sdk_messages dependency is gone.

Meanwhile the counter is maintained by raw UPDATE sessions SET visible_message_count=… inside SDKMessageRepository.bumpVisibleMessageCount/recomputeVisibleMessageCount. The reactive proxy emits table events from the method name (METHOD_TABLE_MAP), not from the SQL, and prepare().run() is bound to the raw target — so that raw sessions write emits no sessions change.

Net: a counter-changing event no longer causes spaceSessions.bySpace to re-evaluate. The badge now updates only incidentally when some unrelated reactive updateSession (e.g. a processing_state transition) happens to fire — which is unreliable and misses action-message inserts and post-hoc status flips entirely. This breaks the live unread badge, which is the whole point of the change.

Fix (matches the codebase pattern — every other repo calls notifyChange after raw writes; see state-projection-service.ts:285/308 for notifyChange(sessions) specifically): thread reactiveDb into SDKMessageRepository (like task/skill/provider repos) and call this.reactiveDb.notifyChange(sessions) after bumpVisibleMessageCount / recomputeVisibleMessageCount. This restores live updates without giving up the perf win — the query stays a cheap column read; only the re-eval frequency is restored (still 150ms-debounced).

@lsm lsm left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review by glm-5.1[1m] (GLM / Zhipu)

Model: glm-5.1[1m] | Client: NeoKai | Provider: GLM (Zhipu)

Recommendation: REQUEST_CHANGESsupersedes my earlier APPROVE. A reactivity regression surfaced (raised by @chatgpt-codex-connector and confirmed on re-investigation) that breaks the live unread badge — the PR's central purpose. The earlier P3 (transaction wrap) is fixed and fine; this is a separate, more serious issue I missed in both prior rounds.

P1 — badge query lost its sdk_messages dependency, and the counter write emits no sessions event

How the badge stayed live before. LiveQueryEngine derives each query's table dependencies from extractTables(sql) and re-evaluates only on a change to one of those tables (live-query.ts:306, 402). The old spaceSessions.bySpace SQL had a correlated FROM sdk_messages subquery, so its deps were ["sdk_messages","sessions","spaces"]. Every saveSDKMessage emits a sdk_messages change (reactive-database.ts METHOD_TABLE_MAP), which re-evaluated the badge — that was the live-update path.

What changed. I ran extractTables on both SQL strings:

  • OLD deps: ["sdk_messages","sessions","spaces"]
  • NEW deps: ["sessions","spaces"]sdk_messages is gone.

So a sdk_messages change no longer re-evaluates spaceSessions.bySpace. And the maintained counter is updated by raw UPDATE sessions SET visible_message_count=… inside SDKMessageRepository.bumpVisibleMessageCount / recomputeVisibleMessageCount. The reactive proxy emits table events from the method name (METHOD_TABLE_MAP), not from the SQL — prepare().run() binds to the raw target and emits nothing. So that sessions write emits no sessions change either.

Net effect. A counter-changing event no longer triggers badge re-evaluation. The badge now refreshes only incidentally when an unrelated reactive updateSession (e.g. a processing_state transition around a turn) happens to fire. That is unreliable and demonstrably misses cases the old code handled:

  • saveHyperNeoActionMessage — action-message inserts with no coincident session-row write.
  • updateMessageStatus flips (deferred→consumed) after a turn ends.
  • the tail of a turn once processing_state has gone idle.

This violates the codebase's own reactivity pattern. Every other repository that writes raw SQL calls reactiveDb.notifyChange('<table>') afterwards (task, skill, provider, mcp-enablement, app-mcp-server, space-task, goal, …). state-projection-service.ts:285/308 even calls notifyChange('sessions') for its raw UPDATE sessions SET last_error. The counter maintenance is the one raw sessions write that skips it.

Suggested fix (preserves the perf win)

Thread reactiveDb into SDKMessageRepository (as task/skill/provider repos already do) and call this.reactiveDb.notifyChange('sessions') after bumpVisibleMessageCount / recomputeVisibleMessageCount. This restores the live re-eval without re-introducing the correlated COUNT(*): the query stays a cheap column read, and re-evaluation frequency returns to what it was (still 150ms-debounced via DEBOUNCE_SPACE_SESSIONS_MS).

A test for this should assert that a saveSDKMessage on a space session causes spaceSessions.bySpace to re-evaluate (e.g. via the LiveQueryEngine change event / a subscriber delta), not just that the column is updated — the repo-level counter tests pass today but don't cover the reactivity gap.

Standing

Everything else from prior rounds holds (predicate parity across insert/recompute/backfill; all mutation paths covered; migration idempotent; the P3 transaction wrap is now in). Only this P1 blocks.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

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.

1 participant