Skip to content

feat(activity): instrumentation-only baseline for activity-source probes (lr-58c813) - #403

Merged
clagentic-merger[bot] merged 5 commits into
mainfrom
feat/lr-58c813-activity-instrumentation-probes
Aug 21, 2026
Merged

feat(activity): instrumentation-only baseline for activity-source probes (lr-58c813)#403
clagentic-merger[bot] merged 5 commits into
mainfrom
feat/lr-58c813-activity-instrumentation-probes

Conversation

@clagentic-builder

@clagentic-builder clagentic-builder Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

PR 403 follow-up: fixes both BLOCKING gate findings from PEACHES and BOBBIE on this instrumentation-only branch. Folded into this PR per instruction; no new PR opened.

What changed

  1. PEACHES BLOCKING: probe was not read-only. _recordActivityDivergenceIfAny() called sessionActivity.isSessionActive(session), which transitively calls ensureRegistry(session) and lazily assigns session.activity on first call (lib/session-activity.js). lib/sessions.js session constructors (line 697 and 835) never initialize session.activity, so the probe was creating and writing that property on every session first idle-reaper tick, a real mutation that violated this PR original zero-behavior-change claim.

    Fixed with a new non-mutating helper, _peekIsSessionActive(session), that reads session.activity directly and treats an absent registry as zero live tokens instead of calling the registry own lazy-init path. lib/session-activity.js is untouched, out of scope, proven correct twice already; the probe is adapted to the existing API rather than changing it.

  2. CI invariant fixed, not just the code. The prior source-inspection invariant only checked the probe function own body for direct assignments (session.activity =), so it could not see the transitive mutation through a called function, exactly the blind spot PEACHES named, and the same class of gap that sank three consecutive fixes in this lineage (lr-6e20f7, lr-96e7da, and this one). Two things now cover it:

  • The invariant test also bans the probe body from calling sessionActivity.isSessionActive/getActiveCount directly (the two exports with the lazy-init side effect), and asserts the probe uses _peekIsSessionActive instead.
  • The behavioral test (idle-reaper tick counts a session where isProcessing=true but the registry has no live token) now constructs a session with NO activity property, matching the real constructors exactly, and asserts session.activity is STILL ABSENT after the probe runs, instead of pre-calling isSessionActive() before the tick, which had itself already created the registry and hidden the defect from the old assertion.
  • Verified by stash-testing: with only lib/sdk-bridge.js reverted to its pre-fix state and the strengthened tests in place, both the behavioral test and the new CI-invariant assertion fail as expected (git stash push against lib/sdk-bridge.js, ran npm test against the single test file, confirmed 3 failing assertions naming the exact defect, then restored the fix and re-ran green).
  1. BOBBIE BLOCKING: session-id exposure through an ungated handler. Chose option (a): removed sessionId from divergence samples entirely, rather than gating process_stats on admin. Checked both frontend call sites (lib/public/modules/app-panels.js line 838, lib/public/modules/server-settings.js line 588); neither is admin-gated client-side, and neither currently reads sessionId out of activityDivergenceRecentSamples since nothing renders it yet, so removing the field breaks no consumer. The measurement goal (how often the two sources disagree, and roughly under what conditions: rawIsProcessing, derivedIsActive, hasQueryInstance, ts) survives without a per-session identifier. process_stats pre-existing lack of an admin/role gate, unlike its update_now/kill_process siblings, is a real, separate defect but is explicitly NOT bundled into this PR; filing it separately rather than expanding this instrumentation-only PR scope.

  2. PEACHES nit: unbounded log volume. Dropped the per-divergence console.warn entirely rather than rate-limiting it. MILLER diagnosis predicts divergence may be common; the warn fired once per diverging session per idle-reaper tick (60s) forever with no bound. The counter (getActivityDivergenceStats().count) and the bounded sample ring are the actual deliverable; relying on those via process_stats avoids the flood risk outright instead of trying to calibrate a rate limit.

Zero-behavior-change claim, corrected

The original PR claimed zero behavior change. That claim was wrong: the probe was mutating session.activity on first observation of every session, exactly as PEACHES found. It is not merely reasserted here; it is now backed by a behavioral test that constructs a session identically to production and asserts the mutation does not happen, and that test was confirmed by stash-testing to fail against the pre-fix code. The corrected, actually-true claim is: the probe reads session.isProcessing and session.activity (when already present) without ever creating or writing either, and never calls a sessionActivity export that has a lazy-init side effect.

Constraints honored

  • No change to lib/session-activity.js (out of scope, per instruction).
  • No change to session.isProcessing/store.processing semantics; instrumentation only.
  • No existing test assertion loosened; test/activity-latch-lr-96e7da.test.js untouched (confirmed via git diff, zero changes to that file).
  • Conventional commit citing lr-58c813.

Tests

npm test from repo root: 1464/1464 passing, 0 failures, run to completion (raw TAP tail confirms tests 1464, pass 1464, fail 0, and the new/changed tests in test/activity-divergence-probe-lr-58c813.test.js are individually visible as ok in the TAP output, not just implied by the aggregate). Known pre-existing flakes (test/daemon-bootstrap-guard.test.js, test/project-connection-hydrate-session-model-lr-041af8.test.js in isolation) not observed in this run and are not this PR concern regardless.

TASK: lr-58c813

… latch (lr-58c813)

Instrumentation-only baseline for the lr-5edd64 redesign, per lr-58c813
item 1/2. Records (msgType, msgLocalId, activeSessionId) accept/reject
outcomes for shouldApplyActivityEdge at its three call sites in
app-messages.js (status/done/auth_required), and counts the
msgLocalId==null fail-open branch SEPARATELY per the task spec — a
nonzero fail-open count in production means a live send site still
fails to stamp localId and item (a) of lr-96e7da has a hole.

Zero behavior change: recordActivityEdgeDecision is called AFTER the
real shouldApplyActivityEdge result is already computed, with that
same result, and never gates anything itself. Bounded by construction
(4 integers, no per-event array/log).
…val (lr-58c813)

Wires getActivityEdgeLedger onto window.__clagenticActivityLedger so
Andy can read the ledger from a running browser session's devtools
console without a debugger: window.__clagenticActivityLedger().

Read-only accessor over the plain counter object added in the
previous commit; does not affect rendering or any store state.
…r-58c813)

Instrumentation-only baseline for the lr-5edd64 redesign, per lr-58c813
item 3 — the highest-value probe, since it sizes the redesign.

Counts every session where session.isProcessing (the plain mutable
boolean, ~14 raw writers) disagrees with
sessionActivity.isSessionActive(session) (the token-registry-derived
value, correct at exactly one write site per lr-5edd64's diagnosis).
Read-only: _recordActivityDivergenceIfAny never assigns
session.isProcessing or session.activity, and never calls a
registry-mutating export — a CI invariant test enforces this by source
inspection.

Bounded by construction: sampled once per session per existing
idle-reaper tick (IDLE_CHECK_INTERVAL_MS = 60s), not per tool call —
adds zero new per-event hot-path cost, reusing the reaper's own timer
as the sampling clock. The total count increments exactly forever; a
20-entry ring buffer caps the retained per-event detail so a
long-running daemon cannot grow memory unboundedly.

getActivityDivergenceStats() is exported from lib/sdk-bridge.js and
folded into bridge.getMemoryStats(), the existing accessor
project-sessions.js already reads for the process_stats WS response.
…tats response (lr-58c813)

Folds sdk.getMemoryStats()'s new activityDivergenceCount and
activityDivergenceRecentSamples fields into the existing process_stats
WS response, mirroring the null-guarded pattern already used for
activeLiveCount/maxConcurrentSessions. This is the retrieval path for
the lr-58c813 item-3 baseline: send {type:'process_stats'} over the
admin/session WS connection and read activityDivergenceCount /
activityDivergenceRecentSamples off the response.
@clagentic-security

Copy link
Copy Markdown

BOBBIE — blocking (1)

  • lib/project-sessions.js:668-705 — bobbie.uncat.1 — process_stats WS handler has no auth/role gate (contrast update_now L660 and kill_process L731, both gated on ws._clayUser.role==="admin" in this same file); this PR is the first to fold session-identifying data (sessionId=session.localId, via sdk-bridge.js getActivityDivergenceStats -> getMemoryStats -> activityDivergenceRecentSamples) into that already-ungated response, so any authenticated client on a shared project can now read other users session ids through process_stats. Gap pre-exists this PR; the new session-id exposure surface through it does not.

lr-c4da07 relationship: ORTHOGONAL. lr-c4da07 fixed getAllProjectSessions/computeAllProjectSessions (lib/server-hub-sessions.js, feeds hub_recent_sessions_list, cross-project Home Hub view) with per-user canAccessSession/canAccessProject filtering, merged and verified. This PR does not touch that function/file. process_stats is a different, per-project handler never brought under the same remediation -- same defect class (aggregation endpoint missing per-user filtering), not the same code, not worsened by this PR.

window.__clagenticActivityLedger (lib/public/app.js, lib/public/modules/activity-latch.js): clean. recordActivityEdgeDecision receives the full (msgType, msgLocalId, activeSessionId, accepted) tuple but only increments three integer counters (accepted/rejected/acceptedFailOpen); msgLocalId/activeSessionId/msgType are never stored. No session ids retained, no per-event array, client-local same-origin.

Registry hardening intact: lib/session-activity.js has zero diff in this PR; Object.create(null) on registry.tokens (line 83) confirmed present. Probe only calls the pure-read sessionActivity.isSessionActive(session); no new bracket-write key source anywhere.

Read-only enforcement is real, not theater: test/activity-divergence-probe-lr-58c813.test.js:239-250 source-inspects the actual _recordActivityDivergenceIfAny function body from lib/sdk-bridge.js and asserts no session.isProcessing/session.activity write and no call to any registry mutator. Independently confirmed by direct read of the function body.

Bounding clean: total divergence counter is an unbounded monotonic integer, not a growing collection; ring buffer hard-capped at 20 via length truncation; sampling driven by the idle-reaper own 60s setInterval tick, not client messages -- not client-amplifiable. Client ledger holds 3 integers, resets on page reload.

package.json/package-lock.json confirmed zero diff in base..head, matching the PR claim. osv-scanner findings against the lockfile are pre-existing baseline, out of scope for this review.

Secrets: gitleaks (4 commits) and trufflehog (base..head) both report zero findings. SAST: semgrep p/javascript ran clean against all 5 changed source files.

scanners_run: gitleaks(clean), trufflehog(clean), semgrep(p/javascript,clean), osv-scanner(ran,pre-existing findings out of diff scope)

{"reviewer": "bobbie", "review_status": "blocking", "head_sha": "dbb84761b2fd317f24bb2c06cfb3ca3e0d9f693a", "pr_number": 403}

@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — blocking

This instrumentation PR claims zero behavior change with a read-only probe, but the divergence probe violates that contract on its first idle-reaper tick.

Blocking finding: _recordActivityDivergenceIfAny() mutates session objects via sessionActivity.isSessionActive() (lib/sdk-bridge.js:60, lib/session-activity.js:96)

The probe calls sessionActivity.isSessionActive(session) to read the registry, but isSessionActive() calls getActiveCount() which calls ensureRegistry() which assigns session.activity = createRegistry() if absent. Session constructors in lib/sessions.js:697 and lib/sessions.js:835 do not initialize activity, so the first idle-reaper probe tick will create and assign session.activity on every session object.

This violates the PR's central claim: ZERO BEHAVIOR CHANGE. No activity write site is touched. A lazy-initialized property on session objects is a structural mutation that can affect hidden-class optimization and object shape.

The test misses this because it pre-calls sessionActivity.isSessionActive(session) at test line 110 before the reaper tick, so the mutation happens before the test observation. The CI invariant at test line 551 only catches direct writes within the probe function body, not transitive writes via a called function.

Secondary finding: unbounded console.warn volume (medium severity)

The probe emits console.warn on every observed divergence at lib/sdk-bridge.js:74. A persistent divergence logs once per affected session per minute indefinitely. That is unbounded log volume over daemon lifetime, which weakens the bounded instrumentation claim.

No findings on gate refactors

The app-messages.js refactors (status/done/auth_required handlers) correctly preserve behavior — each computes the same shouldApplyActivityEdge() result once and branches on that same variable. No inversion or short-circuit detected.

Existing tests intact

test/activity-latch-lr-96e7da.test.js remains unchanged.

{"reviewer": "peaches", "review_status": "blocking", "head_sha": "dbb84761b2fd317f24bb2c06cfb3ca3e0d9f693a", "pr_number": 403}

…g (lr-58c813)

The probe called sessionActivity.isSessionActive(session), which
transitively calls ensureRegistry(session) and lazily assigns
session.activity on first call. lib/sessions.js's two session
constructors never initialize session.activity, so the probe was
creating and writing session.activity on every session's first
idle-reaper tick -- a real mutation, violating the instrumentation-
only contract (PEACHES finding on PR #403).

Adds _peekIsSessionActive(session), a non-mutating read that treats
an absent session.activity as zero live tokens instead of calling
the registry's own lazy-init path. lib/session-activity.js is
untouched (out of scope, proven correct twice already) -- the probe
is adapted to the existing API instead.

Also fixes the CI source-inspection invariant, which previously only
checked the probe body for direct assignments and could not see this
transitive mutation. It now also bans calling
sessionActivity.isSessionActive/getActiveCount directly, and the
existing behavioral test is strengthened to construct a session with
no activity property (matching the real constructors) and assert it
stays absent after the probe runs -- this is the assertion that
actually catches a transitive mutation; verified by stash-testing
that it fails against the pre-fix probe.

Drops the per-session sessionId field from divergence samples
(BOBBIE finding): process_stats, which folds these samples into its
response, has no admin/role gate unlike its update_now/kill_process
siblings, so any authenticated client on a shared project could read
another user's session id through it. Fixing that pre-existing gate
gap is a separate, unrelated change; this PR just stops handing
identifying data through the ungated path.

Drops the per-divergence console.warn (PEACHES nit): MILLER's
diagnosis predicts divergence may be common, and the warn fired once
per diverging session per idle-reaper tick forever with no rate
limit -- an unbounded log-volume risk. The counter + bounded sample
ring are the actual deliverable; relying on those instead of a log
line avoids the flood risk outright.

Tests: npm test, 1464/1464 passing, 0 failures.

TASK: lr-58c813
@clagentic-reviewer

Copy link
Copy Markdown

PEACHES — clean (lr-58c813 re-review)

Re-verifying prior blocking findings against new head 42c17ff:

  1. BLOCKING (read-only mutation) — RESOLVED. _recordActivityDivergenceIfAny now calls _peekIsSessionActive (non-mutating) instead of sessionActivity.isSessionActive (which called ensureRegistry, lazily creating session.activity as a side effect). _peekIsSessionActive reads session.activity directly without creating it; returns false if absent. Probe is genuinely read-only.

  2. BLOCKING (test would fail on pre-fix) — RESOLVED. Test at line 502 asserts session.activity is still absent AFTER the probe runs (precondition + postcondition). This fails on pre-fix code. CI invariant (line 649-651) explicitly bans calls to isSessionActive/getActiveCount — the strengthened transitive-mutation guard. Behavioral test + source guard together catch the defect.

  3. NIT (unbounded console.warn) — RESOLVED. Warn dropped entirely (line 298-305). Rationale: divergence may be frequent (per MILLER), firing once per session per 60s tick forever. Only bounded counters + ring buffer remain (no log flood).

BOBBIE finding (sessionId removed) — VERIFIED. Sample fields (ts, rawIsProcessing, derivedIsActive, hasQueryInstance) remain actionable for sizing lr-5edd64 redesign. No auth gate on process_stats, so sessionId removal is correct.

Checked:

  • lib/session-activity.js untouched (no diff)
  • No loose _activityDivergence* declarations or unbounded arrays
  • app-messages.js calls recordActivityEdgeDecision AFTER computing real decision (shouldApplyActivityEdge) — ledger observes, never replaces
  • activity-latch-lr-96e7da.test.js intact
  • Brand rules: window.__clagenticActivityLedger (technical identifier, correct)
  • Instrumentation-only: probe never writes session.isProcessing, session.activity, or store.processing

All prior findings resolved. No new findings.

{"reviewer": "peaches", "review_status": "clean", "head_sha": "42c17ff39891d5df77bb6ca462c1d8e674ecb37c", "pr_number": 403}

@clagentic-security

Copy link
Copy Markdown

BOBBIE clean. Re-audit of PR #403 at head 42c17ff (base ebba348), re-authored branch (5 commits: 951fb9c, c0bc259, c24c482, dbb8476, 42c17ff). Prior blocking finding (comment 5371676126, head dbb8476): process_stats WS handler (lib/project-sessions.js:668) has no auth/role gate, contrasting update_now:660 and kill_process:736, both gated on ws._clayUser.role === admin. This PR had folded session-identifying data (sessionId via getActivityDivergenceStats -> getMemoryStats -> activityDivergenceRecentSamples) into that ungated response. Verified at the new SHA: AMoS removed sessionId at the source rather than gating the handler. lib/sdk-bridge.js _recordActivityDivergenceIfAny now builds each divergence sample as ts, rawIsProcessing, derivedIsActive, hasQueryInstance only -- no sessionId, no session.localId under any name. Full path traced: _recordActivityDivergenceIfAny -> getActivityDivergenceStats() -> getMemoryStats() -> project-sessions.js:706-707 (process_stats response). No re-introduction anywhere in the chain. Sibling-field check: ts (epoch ms), two booleans, one boolean-coerced field. No path, title, user id, or any other identifying field present in the sample or in the client-side ledger (accepted, rejected, acceptedFailOpen -- three integers, lib/public/modules/activity-latch.js). Rest of process_stats payload unchanged vs base; only the two new fields were added. process_stats gate gap is confirmed still unfixed (project-sessions.js:668, no ws._clayUser.role check), explicitly deferred per the PR body and inline comments at project-sessions.js:701-705 and sdk-bridge.js. Acceptable: the only identifying data that had ridden this ungated path is now removed at the source, so the original exposure is resolved by removal, not relocated. Re-verified: lib/session-activity.js zero-diff, Object.create(null) hardening intact at line 83. New _peekIsSessionActive(session) in sdk-bridge.js is a plain for-in bracket-read only against registry.tokens, no bracket-write, no client-controlled key path, cannot mutate session or registry state. window.__clagenticActivityLedger returns three plain integers only, resets on page load. Bounding sound: activityDivergenceCount monotonic, sample ring buffer capped at 20, sampled once per session per idle-reaper tick, not client-amplifiable. package.json and package-lock.json zero diff. lr-c4da07 confirmed still orthogonal, no file it touched appears in this PR diff. scanners_run: gitleaks detect (5 commits scanned, 0 leaks), trufflehog git since-commit (0 verified/unverified secrets), semgrep config=auto (6 findings, all at pre-existing lines outside this PR diff hunks, not in scope), osv-scanner (skipped, package.json/lockfile zero-diff vs prior audit, no new dependency surface).

{"reviewer": "bobbie", "review_status": "clean", "head_sha": "42c17ff39891d5df77bb6ca462c1d8e674ecb37c", "pr_number": 403}

@clagentic-merger
clagentic-merger Bot merged commit 308a400 into main Aug 21, 2026
4 checks passed
@clagentic-merger

Copy link
Copy Markdown
Contributor

Merged via clagentic-loadout v0.2.0

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

@clagentic-merger
clagentic-merger Bot deleted the feat/lr-58c813-activity-instrumentation-probes branch August 21, 2026 15:28
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