Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/project-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,18 @@ function attachSessions(ctx) {
memAvailableMB: memAvailMB,
activeLiveCount: memStats.activeLiveCount !== undefined ? memStats.activeLiveCount : null,
maxConcurrentSessions: memStats.maxConcurrentSessions !== undefined ? memStats.maxConcurrentSessions : null,
// lr-58c813: baseline instrumentation for the lr-5edd64 redesign —
// count of session.isProcessing !== sessionActivity.isSessionActive(session)
// divergences observed since daemon start, plus a bounded ring
// buffer of the most recent occurrences. Read-only counter; does not
// reflect or affect what any renderer displays. Samples carry no
// session identifier (BOBBIE finding): this handler has no
// admin/role gate, unlike its update_now/kill_process siblings, so
// any authenticated client on a shared project could otherwise read
// another user's session id through it. Fixing that pre-existing
// gate gap is out of scope for this instrumentation-only PR.
activityDivergenceCount: memStats.activityDivergenceCount !== undefined ? memStats.activityDivergenceCount : null,
activityDivergenceRecentSamples: memStats.activityDivergenceRecentSamples || [],
});
return true;
}
Expand Down
7 changes: 7 additions & 0 deletions lib/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { initDm, openDm as _dmOpenDm, enterDmMode as _dmEnterDmMode, exitDmMode
import { initMention, handleMentionStart, handleMentionStream, handleMentionDone, handleMentionError, handleMentionActivity, renderMentionUser, renderMentionResponse } from './modules/mention.js';
import { initTeamPanel } from './modules/team-panel.js';
import { initDiagnostics } from './modules/diagnostics.js';
import { getActivityEdgeLedger } from './modules/activity-latch.js';

// --- Base path for multi-project routing ---
var slugMatch = location.pathname.match(/^\/p\/([a-z0-9_-]+)/);
Expand Down Expand Up @@ -1034,6 +1035,12 @@ import { initDiagnostics } from './modules/diagnostics.js';
// --- Diagnostics panel (lr-8294, epic lr-1a52) ---
initDiagnostics();

// lr-58c813: expose the client-side activity-edge accept/reject ledger for
// production retrieval without a debugger — open devtools console and run
// `window.__clagenticActivityLedger()`. Read-only accessor over a plain
// counter object (activity-latch.js); does not affect rendering.
window.__clagenticActivityLedger = getActivityEdgeLedger;

// --- Messages module (uses direct imports, no ctx injection needed) ---

// --- Connection module ---
Expand Down
64 changes: 64 additions & 0 deletions lib/public/modules/activity-latch.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,70 @@ export function shouldApplyActivityEdge(msgLocalId, activeSessionId) {
return msgLocalId == null || msgLocalId === activeSessionId;
}

// ---------------------------------------------------------------------------
// lr-58c813: accept/reject ledger — instrumentation only, no decision logic.
//
// MILLER (lr-96e7da) hypothesized cross-session crosstalk on this latch at
// 0.5 confidence and flagged it never instrumented to proof; the symptom
// recurred (lr-5edd64) in code that already has this guard. This ledger
// records what shouldApplyActivityEdge actually decided at runtime, at the
// three call sites in app-messages.js (status/done/auth_required), WITHOUT
// changing which branch any of them take — recordActivityEdgeDecision is
// called for its side effect (counting) only, after the real decision has
// already been made by shouldApplyActivityEdge, never in place of it.
//
// The fail-open branch (msgLocalId == null) is counted SEPARATELY per the
// task spec: it is a deliberate back-compat path, but a live send site that
// still fails to stamp localId would silently re-enable the pre-fix
// unguarded write through this exact branch, and no existing test would
// notice. A nonzero count here in production is the actionable signal.
//
// Bounded: this is a plain in-memory counter object, reset on page
// load/reload (no persistence, no growth — four integers total, no per-
// event array). It does not log to the console on every message (that would
// flood devtools on a chatty session); callers read the totals on demand via
// getActivityEdgeLedger(), e.g. from devtools or a future diagnostics-panel
// hook — never a per-event stream.

var _activityEdgeLedger = {
accepted: 0,
rejected: 0,
acceptedFailOpen: 0, // subset of `accepted`: msgLocalId == null specifically
};

/**
* Record the outcome of a shouldApplyActivityEdge call for the ledger.
* Pure bookkeeping — does not itself decide anything and must be called
* with the SAME inputs the real decision already used, after the fact.
*
* @param {string} msgType - "status" | "done" | "auth_required"
* @param {number|string|null|undefined} msgLocalId
* @param {number|string|null|undefined} activeSessionId
* @param {boolean} accepted - the shouldApplyActivityEdge result already computed by the caller
*/
export function recordActivityEdgeDecision(msgType, msgLocalId, activeSessionId, accepted) {
if (accepted) {
_activityEdgeLedger.accepted++;
if (msgLocalId == null) _activityEdgeLedger.acceptedFailOpen++;
} else {
_activityEdgeLedger.rejected++;
}
}

/** Read-only snapshot of the ledger totals (does not reset). */
export function getActivityEdgeLedger() {
return {
accepted: _activityEdgeLedger.accepted,
rejected: _activityEdgeLedger.rejected,
acceptedFailOpen: _activityEdgeLedger.acceptedFailOpen,
};
}

/** Test-only reset so ledger tests don't leak counts across cases. */
export function _resetActivityEdgeLedgerForTest() {
_activityEdgeLedger = { accepted: 0, rejected: 0, acceptedFailOpen: 0 };
}

/**
* Staleness-backstop timer state machine. A single timer is
* armed on a genuine 0->1 'processing' transition and disarmed on every
Expand Down
39 changes: 28 additions & 11 deletions lib/public/modules/app-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import { handleMentionStart, handleMentionActivity, handleMentionStream, handleM
import { handleTeamState, handleTeamMemberUpdate, handleTeamTaskUpdate, handleTeamMessage as handleTeamMsg, handleTeamGone } from './team-panel.js';
import { addDiagnostic } from './diagnostics.js';
import { handleHistoryMeta, handleHistoryDone } from './app-history-replay.js';
import { shouldApplyActivityEdge } from './activity-latch.js';
import { shouldApplyActivityEdge, recordActivityEdgeDecision } from './activity-latch.js';

// --- DOM refs (cached once, stable for page lifetime) ---
var messagesEl = document.getElementById("messages");
Expand Down Expand Up @@ -635,14 +635,21 @@ registerHandlers({
// stampActivityLocalId / project.js sendToSession); a missing localId
// (e.g. an older server) falls back to applying the edge
// unconditionally, same as before this fix.
if (msg.status === "processing" && shouldApplyActivityEdge(msg.localId, store.get('activeSessionId'))) {
setStatus("processing");
// Session became live — undo any dead-session todo compaction
// applied at history_done time.
store.set({ sessionIsProcessing: true });
applyDeadSessionTodoCompaction();
if (!store.get('dmMode')) {
removePreThinking(); // drop pre-thinking dots before showing activity indicator
if (msg.status === "processing") {
var _statusActiveSid = store.get('activeSessionId');
var _statusEdgeAccepted = shouldApplyActivityEdge(msg.localId, _statusActiveSid);
// lr-58c813: ledger this edge's outcome for observability only — the
// decision below (same _statusEdgeAccepted value) is unchanged.
recordActivityEdgeDecision("status", msg.localId, _statusActiveSid, _statusEdgeAccepted);
if (_statusEdgeAccepted) {
setStatus("processing");
// Session became live — undo any dead-session todo compaction
// applied at history_done time.
store.set({ sessionIsProcessing: true });
applyDeadSessionTodoCompaction();
if (!store.get('dmMode')) {
removePreThinking(); // drop pre-thinking dots before showing activity indicator
}
}
}
},
Expand Down Expand Up @@ -872,7 +879,12 @@ registerHandlers({
markAllToolsDone();
closeToolGroup();
finalizeAssistantBlock();
if (shouldApplyActivityEdge(msg.localId, store.get('activeSessionId'))) {
var _doneActiveSid = store.get('activeSessionId');
var _doneEdgeAccepted = shouldApplyActivityEdge(msg.localId, _doneActiveSid);
// lr-58c813: ledger this edge's outcome for observability only — the
// decision below (same _doneEdgeAccepted value) is unchanged.
recordActivityEdgeDecision("done", msg.localId, _doneActiveSid, _doneEdgeAccepted);
if (_doneEdgeAccepted) {
setStatus("connected");
}
// Re-enable input unless this is one of the loop's own sessions (coder/judge).
Expand Down Expand Up @@ -928,7 +940,12 @@ registerHandlers({
// Same session-scoping as the done handler above — a background
// session's auth_required must not clear the focused session's
// 'processing' latch.
if (shouldApplyActivityEdge(msg.localId, store.get('activeSessionId'))) {
var _authActiveSid = store.get('activeSessionId');
var _authEdgeAccepted = shouldApplyActivityEdge(msg.localId, _authActiveSid);
// lr-58c813: ledger this edge's outcome for observability only — the
// decision below (same _authEdgeAccepted value) is unchanged.
recordActivityEdgeDecision("auth_required", msg.localId, _authActiveSid, _authEdgeAccepted);
if (_authEdgeAccepted) {
setStatus("connected");
}
var _authLoopSid = store.get('loopCurrentSessionId');
Expand Down
123 changes: 122 additions & 1 deletion lib/sdk-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,114 @@ var MAX_CONCURRENT_SESSIONS = (function () {
return (env > 0) ? env : 50;
})();

// --- lr-58c813: activity-source divergence probe (instrumentation only) ---
// Baseline measurement for the lr-5edd64 redesign: session.isProcessing (the
// plain mutable boolean, ~14 raw writers) is DERIVED at exactly one site
// (sdk-bridge.js's "lr-9bcd7b" comment above) from
// sessionActivity.isSessionActive(session) (the token registry). This counts
// how often the two disagree at runtime -- READ ONLY, never corrects or
// mutates either value, never gates a renderer, never changes what a client
// sees. Sizes the redesign; does not implement it.
//
// Genuinely read-only means never triggering session.activity's own lazy
// initialization either (session-activity.js's ensureRegistry assigns
// session.activity on first call, and lib/sessions.js's constructors never
// set it) -- see _peekIsSessionActive below, which this probe uses instead
// of sessionActivity.isSessionActive for exactly that reason.
//
// Bounded by construction: sampled once per session per idle-reaper tick
// (IDLE_CHECK_INTERVAL_MS = 60s below), not per tool call/message -- the
// reaper's own setInterval is the sampling clock, so this adds zero new
// per-event hot-path cost. A ring buffer caps the retained detail so a
// daemon that runs for weeks cannot grow this unboundedly; the total count
// is exact (increments forever), only the per-event detail is capped.
var _activityDivergenceCount = 0;
var _activityDivergenceSamples = []; // ring buffer, most-recent-first
var ACTIVITY_DIVERGENCE_SAMPLE_CAP = 20;

/**
* Non-mutating read of "does this session have at least one live activity
* token", WITHOUT going through sessionActivity.isSessionActive/
* getActiveCount -- both of those call ensureRegistry(session), which
* lazily creates and ASSIGNS session.activity on first call
* (lib/session-activity.js). Session objects from lib/sessions.js's two
* constructors never initialize session.activity, so calling the mutating
* path here would create it on every session's first idle-reaper tick --
* a real write this probe must never cause (PEACHES lr-58c813 finding).
*
* A session with no session.activity has, by construction, acquired zero
* tokens, so "absent" and "present but empty/stale-generation" both mean
* not-active -- this mirrors isSessionActive's own semantics exactly, it
* just never creates the registry to get there. Does not read/write
* anything in lib/session-activity.js, which is out of scope and proven
* correct twice already; this adapts the caller to the existing API
* instead of changing it.
*/
function _peekIsSessionActive(session) {
var registry = session.activity;
if (!registry) return false;
for (var token in registry.tokens) {
if (registry.tokens[token].generation === registry.generation) return true;
}
return false;
}

/**
* Read-only comparison of the two activity sources for one session. Does not
* write session.isProcessing, does not call onProcessingChanged, does not
* touch the registry -- including never triggering the registry's own lazy
* initialization (see _peekIsSessionActive above). Call site (idle reaper,
* see startIdleReaper) observes BEFORE any reaper-driven correction runs in
* the same tick, so a divergence caused by a leaked token that the reaper is
* about to sweep is still counted -- the point is measuring how often
* production state actually disagrees, not how often it disagrees net of
* self-healing.
*/
function _recordActivityDivergenceIfAny(session) {
var rawIsProcessing = !!session.isProcessing;
var derivedIsActive = _peekIsSessionActive(session);
if (rawIsProcessing === derivedIsActive) return;
_activityDivergenceCount++;
// lr-58c813 (BOBBIE finding): no sessionId here. process_stats (the
// handler that folds these samples into its response, see
// project-sessions.js) has no admin/role gate -- any authenticated
// client on a shared project could otherwise read another user's
// session ids through this counter. The measurement goal (how often the
// two sources disagree, and roughly under what conditions) survives
// without a per-session identifier, so the identifier is dropped rather
// than gating process_stats (which would widen this PR's scope into a
// pre-existing, unrelated auth gap -- see PR body).
var sample = {
ts: Date.now(),
rawIsProcessing: rawIsProcessing,
derivedIsActive: derivedIsActive,
hasQueryInstance: !!session.queryInstance,
};
_activityDivergenceSamples.unshift(sample);
if (_activityDivergenceSamples.length > ACTIVITY_DIVERGENCE_SAMPLE_CAP) {
_activityDivergenceSamples.length = ACTIVITY_DIVERGENCE_SAMPLE_CAP;
}
// lr-58c813 (PEACHES nit): no console.warn here. MILLER's diagnosis
// predicts divergence may be common, and this fires once per diverging
// session per idle-reaper tick (60s) forever -- an unbounded, unrated
// log volume risk on the operator's own machine for a value that isn't
// actionable as a log line anyway. The counter + bounded sample ring
// (both above) are the actual deliverable; dropping the warn and
// relying on getActivityDivergenceStats()/process_stats for visibility
// avoids the flood risk entirely rather than trying to rate-limit it.
}

// Module-scope accessor so tests and other bridge instances (this counter is
// intentionally shared across ALL project bridge instances, same as
// _activeLiveCount above) can read the current totals without reaching into
// closure state.
function getActivityDivergenceStats() {
return {
count: _activityDivergenceCount,
recentSamples: _activityDivergenceSamples.slice(),
};
}

// --- lr-2d91: MemAvailable gate ---
// Default minimum available memory threshold in MB. Referenced by sdk-bridge
// and daemon.js — changing this constant is the single place to adjust the default.
Expand Down Expand Up @@ -409,6 +517,12 @@ function createSDKBridge(opts) {
sm.sessions.forEach(function (session) {
if (session.destroying) return;

// lr-58c813: divergence probe, READ ONLY — observe the raw/derived
// activity values exactly as production has them right now, before
// any reaper-driven correction below runs this tick. See the
// _recordActivityDivergenceIfAny doc comment for why "before" matters.
_recordActivityDivergenceIfAny(session);

// lr-5450ef: sweepStaleTokens BACKSTOP — recover a session pinned by
// a leaked activity token (isProcessing derives true from the
// registry, but there is no live queryInstance left to ever call
Expand Down Expand Up @@ -2328,10 +2442,17 @@ function createSDKBridge(opts) {

// lr-2d91: Expose live concurrency stats for process_stats WS response.
// Called by project-sessions.js when building the process_stats payload.
// lr-58c813: also folds in the activity-source divergence probe totals —
// same "instance function reads module-shared counter" shape as
// activeLiveCount above, so process_stats gets both without a second round
// trip. See getActivityDivergenceStats() module-scope doc comment.
function getMemoryStats() {
var divergence = getActivityDivergenceStats();
return {
activeLiveCount: _activeLiveCount,
maxConcurrentSessions: MAX_CONCURRENT_SESSIONS,
activityDivergenceCount: divergence.count,
activityDivergenceRecentSamples: divergence.recentSamples,
};
}

Expand Down Expand Up @@ -2369,5 +2490,5 @@ function getActiveLiveCount() {
return _activeLiveCount;
}

module.exports = { createSDKBridge, createMessageQueue, readMemAvailableMB, readCgroupHeadroomMB, DEFAULT_MEM_AVAILABLE_MIN_MB, DEFAULT_TOKENS_PER_MB_HEADROOM, getActiveLiveCount };
module.exports = { createSDKBridge, createMessageQueue, readMemAvailableMB, readCgroupHeadroomMB, DEFAULT_MEM_AVAILABLE_MIN_MB, DEFAULT_TOKENS_PER_MB_HEADROOM, getActiveLiveCount, getActivityDivergenceStats };

Loading
Loading