Skip to content
17 changes: 15 additions & 2 deletions lib/project-connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,17 @@ function attachConnection(ctx) {
// lr-041af8: carry the restored session's own effective model — see
// effectiveSessionModel() in sessions.js and the model_info/config_state
// fix above in this same connect handler.
sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, model: sm.effectiveSessionModel(active), hasHistory: _hasHistory, capabilities: _vendorCaps, agentName: active.agentName || null });
// This hydration send previously omitted isProcessing — sessions.js's
// switchSession() send DOES carry it, but a client that hydrates via
// THIS path (fresh connect / reconnect) never got the authoritative
// reconciled value here, only via the separate status:"processing"
// send below (guarded by active.isProcessing) — and that separate
// message carried no session-scoping (see localId note below), so a
// client's derived 'sessionIsProcessing' could lag or desync from
// server truth across a reconnect. Carrying it directly on
// session_switched matches switchSession() and gives the client a
// single authoritative snapshot value to reconcile the footer against.
sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, model: sm.effectiveSessionModel(active), hasHistory: _hasHistory, capabilities: _vendorCaps, isProcessing: !!active.isProcessing, agentName: active.agentName || null });
// Send per-session context sources
var sessionSources = loadContextSources(slug, active.localId);
sendTo(ws, { type: "context_sources_state", active: sessionSources });
Expand All @@ -239,7 +249,10 @@ function attachConnection(ctx) {
sm.replayHistory(active, undefined, ws, hydrateImageRefs);

if (active.isProcessing) {
sendTo(ws, { type: "status", status: "processing" });
// Carry localId so the client can session-scope this edge (matching
// the scheduled_message_* pattern) instead of writing the global
// 'processing' latch blind to which session it belongs to.
sendTo(ws, { type: "status", status: "processing", localId: active.localId });
}
var pendingIds = Object.keys(active.pendingPermissions);
for (var pi = 0; pi < pendingIds.length; pi++) {
Expand Down
8 changes: 7 additions & 1 deletion lib/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ var fs = require("fs");
var path = require("path");
var os = require("os");
var crypto = require("crypto");
var { createSessionManager } = require("./sessions");
var { createSessionManager, stampActivityLocalId } = require("./sessions");
var { createSDKBridge, createMessageQueue } = require("./sdk-bridge");
var { createTerminalManager } = require("./terminal-manager");
var { createNotesManager } = require("./notes");
Expand Down Expand Up @@ -381,6 +381,11 @@ function createProjectContext(opts) {
}

function sendToSession(sessionId, obj) {
// Stamp localId on status/done/auth_required so the client can
// session-scope the edge write instead of applying it blind to
// whichever session is currently focused — see sessions.js's
// stampActivityLocalId doc comment for the full rationale/type list.
stampActivityLocalId(sessionId, obj);
var data = JSON.stringify(obj);
for (var ws of clients) {
if (ws.readyState === 1 && ws._clayActiveSession === sessionId) {
Expand All @@ -390,6 +395,7 @@ function createProjectContext(opts) {
}

function sendToSessionOthers(sender, sessionId, obj) {
stampActivityLocalId(sessionId, obj);
var data = JSON.stringify(obj);
for (var ws of clients) {
if (ws !== sender && ws.readyState === 1 && ws._clayActiveSession === sessionId) {
Expand Down
95 changes: 95 additions & 0 deletions lib/public/modules/activity-latch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// activity-latch.js - Pure decision logic for the client-local 'processing'
// activity-indicator latch.
//
// 'processing' (the field app-favicon.js's initActivityFooter subscribes
// to) is a client-local edge latch with no server reconciliation —
// app-messages.js's status/done/auth_required handlers wrote it
// unconditionally, with no knowledge of which session a message actually
// belongs to. A background session's status:"processing" could raise the
// FOCUSED session's latch with no done ever routed back to clear it
// (stuck-ON) — the inverse of the historical stuck-OFF symptom this
// codebase has previously fixed for the transcript footer.
//
// This module extracts the two DECISION functions those handlers need —
// "should this session-scoped edge apply to the currently-focused session?"
// and "when should the staleness backstop timer arm/fire/clear?" — as pure,
// DOM-free logic. app-messages.js and app-favicon.js are both DOM-heavy
// (app-favicon.js's import graph reaches theme.js <-> markdown.js, which
// has a circular-import ordering hazard around mermaid theme init that
// makes them unimportable in a plain Node test process without a much
// heavier harness than this repo carries — see the test file for this
// module's header comment). Carving the pure decision logic out here —
// mirroring how activity-state.js was carved out of the sidebar/hub render
// sites for the same reason — is what makes this fix's core logic
// behaviorally testable at all, rather than only provable by source-text
// inspection.

/**
* Should a session-scoped activity-latch edge (status:"processing",
* done, auth_required) be applied to the client's current local state?
*
* Mirrors the lr-0827ba pattern already used by scheduled_message_*
* handlers: a message with no localId (older server, or a message type
* that was never session-scoped) always applies, matching pre-fix
* behavior; a message WITH a localId only applies when it matches the
* session the client currently has focused.
*
* @param {number|string|null|undefined} msgLocalId - localId on the
* incoming message, or null/undefined if the server didn't stamp one.
* @param {number|string|null|undefined} activeSessionId - the client's
* currently-focused session id (store.get('activeSessionId')).
* @returns {boolean}
*/
export function shouldApplyActivityEdge(msgLocalId, activeSessionId) {
return msgLocalId == null || msgLocalId === activeSessionId;
}

/**
* Staleness-backstop timer state machine. A single timer is
* armed on a genuine 0->1 'processing' transition and disarmed on every
* 1->0 transition — never a recurring poll, never more than one in-flight
* timer regardless of how many turns/sessions run. Kept here as pure state
* transitions (arm/clear/shouldFire) so the bound (one timer, one-shot,
* re-armed only by a fresh transition) is asserted directly rather than
* only described in a comment.
*/
export function createActivityStaleBackstop(opts) {
var delayMs = (opts && opts.delayMs) || 5 * 60 * 1000; // mirrors sdk-bridge.js ACTIVITY_STALE_MS
var setTimeoutFn = (opts && opts.setTimeout) || setTimeout;
var clearTimeoutFn = (opts && opts.clearTimeout) || clearTimeout;
var onFire = (opts && opts.onFire) || function () {};
var timer = null;
var armCount = 0; // exposed for the "never polls / never stacks" test assertion

function clear() {
if (timer) {
clearTimeoutFn(timer);
timer = null;
}
}

function arm() {
clear(); // at most one in-flight timer, ever — re-arming replaces, never stacks
armCount++;
timer = setTimeoutFn(function () {
timer = null;
onFire();
}, delayMs);
}

/** Called on every store 'processing' transition (state !== prev only). */
function onTransition(nowProcessing) {
if (nowProcessing) {
arm();
} else {
clear();
}
}

return {
onTransition: onTransition,
isArmed: function () { return timer !== null; },
armCount: function () { return armCount; },
_clearForTest: clear,
};
}
43 changes: 43 additions & 0 deletions lib/public/modules/app-favicon.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { store } from './store.js';
import { getSendBtn, getStatusDot } from './dom-refs.js';
import { onThemeChange, getChatLayout } from './theme.js';
import { getActivityEl, setActivityEl, addToMessages, scrollToBottom } from './app-rendering.js';
import { getWs } from './ws-ref.js';
import { createActivityStaleBackstop } from './activity-latch.js';

// --- Module-owned state ---
var faviconLink, faviconOrigHref, faviconCanvas, faviconCtx, faviconImg, faviconImgReady;
Expand Down Expand Up @@ -261,9 +263,50 @@ export function setActivity(text) {
// theme.js getChatLayout/setChatLayout), so this must not fight
// .channel-pre-thinking for the same bottom-of-transcript slot in channel
// layout.
// Client-side staleness backstop, mirroring sdk-bridge.js's server-side
// ACTIVITY_STALE_MS (5 min) sweep for the registry. That server sweep can
// never repair THIS client-local latch — it sweeps the (already-correct)
// server registry, not any client state. Session-scoping the status/done/
// auth_required writers and reconciling on every session_switched snapshot
// (both elsewhere in this fix) close the known trigger paths; this is the
// backstop for an edge this fix's authors did not anticipate — a single
// timer, armed only on the 0->1 transition and disarmed on every 1->0
// transition, so it never polls per-tick and never accumulates more than
// one in-flight timer regardless of how many turns a session runs. On
// fire, it re-sends the EXISTING switch_session request for the
// currently-focused session (same message every sidebar click already
// sends) rather than inventing a new wire message — the server always
// answers with a fresh session_switched carrying the authoritative
// isProcessing (lib/sessions.js switchSession), which the reconciliation in
// app-messages.js's session_switched handler then applies. Bound: at most
// one re-request per ACTIVITY_STALE_BACKSTOP_MS per session-focus-duration,
// never a recurring poll — the timer is one-shot and only re-arms on a
// fresh 0->1 transition (a genuinely new turn), not on a fixed interval.
// Decision logic (arm/clear/one-shot-never-stacks) lives in the pure,
// directly-unit-tested activity-latch.js module — see its header comment
// for why (app-favicon.js's own import graph is not importable in a plain
// Node test process, so the behavioral proof lives against the pure
// module instead of this DOM-driving glue).
var _activityStaleBackstop = createActivityStaleBackstop({
onFire: function () {
// Only re-request if the latch is STILL true when the timer fires —
// a legitimate long-running turn re-arms nothing extra here (the
// request is one-shot, not a recurring poll); if the turn already
// ended, the 1->0 transition already cleared this timer, so reaching
// this callback at all means 'processing' has been true, unbroken,
// for the full backstop window.
if (!store.get('processing')) return;
var sid = store.get('activeSessionId');
var ws = getWs();
if (sid == null || !ws || ws.readyState !== 1) return;
ws.send(JSON.stringify({ type: "switch_session", id: sid }));
},
});

export function initActivityFooter() {
store.subscribe(['processing'], function (state, prev) {
if (state.processing === prev.processing) return;
_activityStaleBackstop.onTransition(state.processing);
if (getChatLayout() !== "channel") {
if (state.processing) {
setActivity("thinking");
Expand Down
50 changes: 47 additions & 3 deletions lib/public/modules/app-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +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';

// --- DOM refs (cached once, stable for page lifetime) ---
var messagesEl = document.getElementById("messages");
Expand Down Expand Up @@ -442,7 +443,25 @@ registerHandlers({
} else if (_prevSid) {
delete store.get('sessionDrafts')[_prevSid];
}
// Reconcile the footer's edge-latch field from the server-authoritative
// snapshot on every session switch/hydration, not only
// sessionIsProcessing. 'processing' (the field app-favicon.js's
// initActivityFooter subscribes to) was a pure client-local edge latch
// with no reconciliation path — missing a single 1->0 done edge (e.g. a
// cross-session race, see the session-scoping fix in the status/done/
// auth_required handlers below) left it stuck true forever.
// session_switched now always carries msg.isProcessing
// (project-connection.js's hydration path previously omitted it —
// sessions.js's own switchSession() send already had it).
store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing });
// Re-derive 'processing' from the store field just set above
// (sessionIsProcessing), NOT from a second raw msg.isProcessing read —
// this file's CI invariant test locks .isProcessing reads to exactly
// the one documented site immediately above, so every switch/reconnect/
// hydration re-derives 'processing' from that already-read,
// already-reconciled value instead of trusting whatever the
// client-local latch happened to be left at.
store.set({ processing: store.get('sessionIsProcessing') });
if (msg.vendor) {
if (!store.get('vendorSelectionLocked') || msg.hasHistory) {
store.set({ currentVendor: msg.vendor });
Expand Down Expand Up @@ -605,7 +624,18 @@ registerHandlers({
},

status: function (msg) {
if (msg.status === "processing") {
// Session-scope this edge write, mirroring the lr-0827ba pattern
// already used by scheduled_message_*/other handlers (msg.localId ==
// null || msg.localId === activeSessionId). This handler previously
// raised the global 'processing' latch for ANY session's
// status:"processing", including one the client is not currently
// focused on — a background session's edge could raise the FOCUSED
// session's dot with no done ever routed back to clear it. msg.localId
// is now always populated server-side (see sessions.js
// 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.
Expand Down Expand Up @@ -826,6 +856,13 @@ registerHandlers({
}
},

// Only setStatus("connected") below is session-scoped — a done for a
// BACKGROUND session must not clear the FOCUSED session's 'processing'
// latch (see activity-latch.js's header comment for the full rationale).
// Every other cleanup call in this handler stays unconditional: it
// targets DOM/tool state for whatever session this client's own WS
// connection is bound to server-side (ws._clayActiveSession), so it is
// never cross-session — only the store-wide latch write needs the guard.
done: function (msg) {
removePreThinking();
// lr-66c118: the setActivity clear call is removed here too — see the
Expand All @@ -835,7 +872,9 @@ registerHandlers({
markAllToolsDone();
closeToolGroup();
finalizeAssistantBlock();
setStatus("connected");
if (shouldApplyActivityEdge(msg.localId, store.get('activeSessionId'))) {
setStatus("connected");
}
// Re-enable input unless this is one of the loop's own sessions (coder/judge).
// A loop running in a separate session must not suppress input here.
var _doneLoopSid = store.get('loopCurrentSessionId');
Expand Down Expand Up @@ -886,7 +925,12 @@ registerHandlers({
markAllToolsDone();
closeToolGroup();
appendDelta((msg.text || "Authentication required.") + "\n");
setStatus("connected");
// 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'))) {
setStatus("connected");
}
var _authLoopSid = store.get('loopCurrentSessionId');
if (!store.get('loopActive') || !_authLoopSid || store.get('activeSessionId') !== _authLoopSid) {
enableMainInput();
Expand Down
24 changes: 23 additions & 1 deletion lib/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ function effectiveSessionModel(session, sm) {
return (sm && sm.currentModel) || "";
}

// Message types whose client-side handler writes the global client-local
// 'processing'/'sessionIsProcessing' edge latch (app-messages.js's status/
// done/auth_required handlers). Stamping localId at the shared send choke
// points (doSendToSession/doSendAndRecord below, and project.js's own
// sendToSession/sendToSessionOthers which reuse this same list) means every
// one of the ~15 status/done/auth_required send sites across sessions.js/
// project.js/project-loop.js/sdk-bridge.js gets session-scoping for free,
// mirroring the existing scheduled_message_queued/sent localId pattern
// (project.js), without widening this to every message type — most types
// are already inherently session-scoped by their own handler logic and
// don't need it.
var ACTIVITY_LATCH_TYPES = { status: true, done: true, auth_required: true };

function stampActivityLocalId(session, obj) {
if (ACTIVITY_LATCH_TYPES[obj.type] && obj.localId === undefined) {
obj.localId = (session && session.localId !== undefined) ? session.localId : session;
}
return obj;
}

function createSessionManager(opts) {
var cwd = opts.cwd;
var send = opts.send; // function(obj) - broadcast to all clients
Expand Down Expand Up @@ -1240,6 +1260,7 @@ function createSessionManager(opts) {
}

function doSendToSession(session, obj) {
stampActivityLocalId(session, obj);
// Send to active clients without recording to history/disk (ephemeral data)
if (sendEach) {
var data = JSON.stringify(obj);
Expand All @@ -1254,6 +1275,7 @@ function createSessionManager(opts) {
}

function doSendAndRecord(session, obj) {
stampActivityLocalId(session, obj);
// Stamp every recorded message so history replay preserves original times
if (!obj._ts) obj._ts = Date.now();
// If history has not been loaded from disk yet, do not trigger a load just
Expand Down Expand Up @@ -1770,4 +1792,4 @@ function createSessionManager(opts) {
return _instance;
}

module.exports = { createSessionManager, effectiveSessionModel };
module.exports = { createSessionManager, effectiveSessionModel, stampActivityLocalId };
Loading
Loading