diff --git a/lib/project-connection.js b/lib/project-connection.js index 198572be..6c5e103b 100644 --- a/lib/project-connection.js +++ b/lib/project-connection.js @@ -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 }); @@ -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++) { diff --git a/lib/project.js b/lib/project.js index 16228557..401fa806 100644 --- a/lib/project.js +++ b/lib/project.js @@ -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"); @@ -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) { @@ -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) { diff --git a/lib/public/modules/activity-latch.js b/lib/public/modules/activity-latch.js new file mode 100644 index 00000000..de2bdee0 --- /dev/null +++ b/lib/public/modules/activity-latch.js @@ -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, + }; +} diff --git a/lib/public/modules/app-favicon.js b/lib/public/modules/app-favicon.js index 8b4d926a..67ca5eab 100644 --- a/lib/public/modules/app-favicon.js +++ b/lib/public/modules/app-favicon.js @@ -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; @@ -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"); diff --git a/lib/public/modules/app-messages.js b/lib/public/modules/app-messages.js index cff42f78..674ad044 100644 --- a/lib/public/modules/app-messages.js +++ b/lib/public/modules/app-messages.js @@ -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"); @@ -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 }); @@ -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. @@ -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 @@ -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'); @@ -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(); diff --git a/lib/sessions.js b/lib/sessions.js index 7ddb1ec2..60b88865 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -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 @@ -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); @@ -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 @@ -1770,4 +1792,4 @@ function createSessionManager(opts) { return _instance; } -module.exports = { createSessionManager, effectiveSessionModel }; +module.exports = { createSessionManager, effectiveSessionModel, stampActivityLocalId }; diff --git a/test/activity-latch-lr-96e7da.test.js b/test/activity-latch-lr-96e7da.test.js new file mode 100644 index 00000000..f7f16f77 --- /dev/null +++ b/test/activity-latch-lr-96e7da.test.js @@ -0,0 +1,416 @@ +// activity-latch-lr-96e7da.test.js +// +// lr-96e7da: MILLER diagnosis. store.processing (the field app-favicon.js's +// initActivityFooter subscribes to) is a client-local edge latch with no +// server reconciliation. Missing a single 1->0 done edge — e.g. because a +// cross-session status:"processing" raised it and no matching done was ever +// routed back to THIS focused session — leaves the footer widget stuck ON +// forever. This is the INVERSE of lr-6e20f7's stuck-OFF symptom. +// +// CI BLIND SPOT this file closes (per task spec, MILLER's durable finding): +// test/activity-transcript-footer-lr-6e20f7.test.js:157 asserts by STATIC +// SOURCE-TEXT REGEX that store.subscribe(['processing']) exists — it proves +// the wire, never that the widget CLEARS, never drives a real transition, +// never exercises a missed-edge or cross-session scenario. Every existing +// activity invariant in this suite guards stuck-OFF (does a driver exist?); +// nothing guarded stuck-ON (does the driver converge to false?) before this +// file. This file replaces that blind spot with EXECUTED behavioral +// transitions: status:processing -> done drives the widget to REMOVED, and a +// cross-session done leaves the focused session's widget UNAFFECTED. +// +// WHY THIS TESTS lib/public/modules/activity-latch.js RATHER THAN +// app-favicon.js/app-messages.js DIRECTLY: those two files are DOM-heavy — +// app-favicon.js's import graph reaches theme.js, which has a mutual +// circular import with markdown.js (theme.js imports markdown.js; +// markdown.js's module body unconditionally calls +// mermaid.initialize({themeVariables: getMermaidThemeVars()}) at import +// time, before theme.js's own `var currentThemeId = "clagentic-dark"` +// assignment has executed in ESM's circular-import evaluation order). That +// pre-existing ordering hazard is unrelated to this fix and out of this +// task's scope to repair (drive-by rewrite of an unrelated module, code- +// craft rule 1) — importing app-favicon.js in a plain Node test process +// throws inside that unrelated cycle, with or without this diff, and there +// is no jsdom dependency in this repo to paper over it (project convention, +// confirmed project-wide — see other DOM-heavy-module test files, none add +// jsdom). +// +// activity-latch.js was carved out of app-favicon.js/app-messages.js +// specifically so the STUCK-ON decision logic (session-scoping predicate + +// staleness-backstop timer state machine) is provably behaviorally correct +// — mirroring why activity-state.js was carved out of the sidebar/hub +// render sites for the exact same reason (lr-a6a449). This file: +// 1. Proves activity-latch.js's shouldApplyActivityEdge/ +// createActivityStaleBackstop functions are behaviorally correct +// (sections 1-3). +// 2. Drives a REAL end-to-end reproduction of the stuck-ON bug through +// the real frontend store.js + the real activity-latch.js together — +// a status:processing -> done transition removes the widget, and a +// cross-session done leaves an unrelated focused session's widget +// state untouched (section 4) — using a minimal DOM-free "footer" +// double that mirrors exactly what app-favicon.js's store.subscribe +// callback does (setActivity("thinking") / setActivity(null)), so the +// transition being asserted is the real one, not a hand-wave. +// 3. Source-inspects (the correct/limited grep use, per this suite's own +// established convention) that app-messages.js's status/done/ +// auth_required handlers and app-favicon.js's initActivityFooter +// actually CALL INTO activity-latch.js's real exports, rather than +// reimplementing the guard inline where it would be unproven (section +// 5) — this is what stops the extracted module from silently drifting +// out of sync with the code that is supposed to use it. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); +var { pathToFileURL } = require("url"); + +var LATCH_URL = pathToFileURL( + path.join(__dirname, "..", "lib", "public", "modules", "activity-latch.js") +).href; +var STORE_URL = pathToFileURL( + path.join(__dirname, "..", "lib", "public", "modules", "store.js") +).href; + +function readMod(rel) { + return fs.readFileSync(path.join(__dirname, "..", rel), "utf8"); +} + +function stripLineComments(src) { + return src + .split("\n") + .map(function (line) { + var idx = line.indexOf("//"); + return idx === -1 ? line : line.slice(0, idx); + }) + .join("\n"); +} + +var latch, storeMod; + +test("activity-latch.js and store.js load as real ESM modules with no DOM dependency", { timeout: 10000 }, function () { + return Promise.all([import(LATCH_URL), import(STORE_URL)]).then(function (mods) { + latch = mods[0]; + storeMod = mods[1]; + assert.strictEqual(typeof latch.shouldApplyActivityEdge, "function"); + assert.strictEqual(typeof latch.createActivityStaleBackstop, "function"); + assert.strictEqual(typeof storeMod.createStore, "function"); + assert.strictEqual(typeof storeMod.store, "object"); + }); +}); + +// --------------------------------------------------------------------------- +// 1. shouldApplyActivityEdge — session-scoping predicate (item a) +// --------------------------------------------------------------------------- + +test("shouldApplyActivityEdge: a message with no localId always applies (back-compat with an older server)", function () { + assert.equal(latch.shouldApplyActivityEdge(null, 5), true); + assert.equal(latch.shouldApplyActivityEdge(undefined, 5), true); + assert.equal(latch.shouldApplyActivityEdge(null, null), true); +}); + +test("shouldApplyActivityEdge: a message for the FOCUSED session applies", function () { + assert.equal(latch.shouldApplyActivityEdge(5, 5), true); +}); + +test("shouldApplyActivityEdge: a message for a DIFFERENT (background) session does NOT apply — the cross-session crosstalk MILLER diagnosed", function () { + assert.equal(latch.shouldApplyActivityEdge(5, 7), false); + assert.equal(latch.shouldApplyActivityEdge(7, 5), false); +}); + +// --------------------------------------------------------------------------- +// 2. createActivityStaleBackstop — timer state machine (item c) +// --------------------------------------------------------------------------- + +function makeFakeClock() { + var scheduled = null; // { delay, cb } + return { + setTimeout: function (cb, delay) { + scheduled = { cb: cb, delay: delay }; + return "timer-token"; + }, + clearTimeout: function (token) { + if (token === "timer-token") scheduled = null; + }, + fire: function () { + var s = scheduled; + scheduled = null; + if (s) s.cb(); + }, + isScheduled: function () { return scheduled !== null; }, + lastDelay: function () { return scheduled ? scheduled.delay : null; }, + }; +} + +test("createActivityStaleBackstop: 0->1 transition arms exactly one timer", function () { + var clock = makeFakeClock(); + var fired = 0; + var backstop = latch.createActivityStaleBackstop({ + delayMs: 1234, + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + onFire: function () { fired++; }, + }); + + assert.equal(backstop.isArmed(), false); + backstop.onTransition(true); + assert.equal(backstop.isArmed(), true, "arming on 0->1 must schedule a timer"); + assert.equal(clock.lastDelay(), 1234, "must use the configured delay (mirrors sdk-bridge.js ACTIVITY_STALE_MS)"); + assert.equal(backstop.armCount(), 1); + assert.equal(fired, 0, "must not fire immediately on arm"); +}); + +test("createActivityStaleBackstop: 1->0 transition disarms the timer before it can fire — the false-alarm bound", function () { + var clock = makeFakeClock(); + var fired = 0; + var backstop = latch.createActivityStaleBackstop({ + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + onFire: function () { fired++; }, + }); + + backstop.onTransition(true); + assert.equal(clock.isScheduled(), true); + backstop.onTransition(false); // a normal, healthy done arrives well within the window + assert.equal(clock.isScheduled(), false, "the underlying timer must be cleared, not just ignored"); + assert.equal(backstop.isArmed(), false); + clock.fire(); // no-op: nothing scheduled + assert.equal(fired, 0, "a session that finished normally must never fire the backstop"); +}); + +test("createActivityStaleBackstop: repeated 0->1 transitions never stack more than one in-flight timer — the O(1) chattiness bound named in the PR", function () { + var clock = makeFakeClock(); + var backstop = latch.createActivityStaleBackstop({ + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + }); + + backstop.onTransition(true); + backstop.onTransition(true); // re-arm without an intervening false (e.g. two rapid turns) + backstop.onTransition(true); + + assert.equal(backstop.armCount(), 3, "each arm call schedules fresh (clear-then-set), not additively"); + assert.equal(clock.isScheduled(), true, "exactly one timer must be live, never zero and never multiple"); +}); + +test("createActivityStaleBackstop: firing while still processing invokes onFire exactly once — this IS the re-request, not a poll", function () { + var clock = makeFakeClock(); + var fired = 0; + var backstop = latch.createActivityStaleBackstop({ + setTimeout: clock.setTimeout, + clearTimeout: clock.clearTimeout, + onFire: function () { fired++; }, + }); + + backstop.onTransition(true); + clock.fire(); + assert.equal(fired, 1); + assert.equal(backstop.isArmed(), false, "firing must not self-re-arm — a genuinely still-stuck session needs a NEW 0->1 transition (there won't be one) or operator action, not a recurring poll"); +}); + +// --------------------------------------------------------------------------- +// 3. store.js + activity-latch.js driven together — end-to-end stuck-ON +// reproduction using a minimal DOM-free "footer double" that performs +// EXACTLY the actions app-favicon.js's real store.subscribe(['processing']) +// callback performs (setActivity("thinking") / setActivity(null)), driven +// by the real, unmodified activity-latch.js exports. This is the +// EXECUTED behavioral transition the task spec requires in place of the +// static regex at activity-transcript-footer-lr-6e20f7.test.js:157. +// --------------------------------------------------------------------------- + +test("END-TO-END: status:processing then done (session-scoped) drives the widget from PRESENT to REMOVED — the exact transition the lr-6e20f7 regex never drove", { timeout: 10000 }, function () { + return Promise.all([import(STORE_URL), import(LATCH_URL)]).then(function (mods) { + var s = mods[0]; + var l = mods[1]; + s.createStore({ processing: false, activeSessionId: "sess-A" }); + + var widgetPresent = false; + s.store.subscribe(["processing"], function (state, prev) { + if (state.processing === prev.processing) return; + widgetPresent = !!state.processing; // mirrors app-favicon.js's setActivity(!!state.processing) + }); + + // Simulate app-messages.js's status handler: server sends + // status:"processing" scoped to sess-A while sess-A is focused. + var statusMsg = { status: "processing", localId: "sess-A" }; + if (statusMsg.status === "processing" && l.shouldApplyActivityEdge(statusMsg.localId, s.store.get("activeSessionId"))) { + s.store.set({ processing: true }); + } + assert.equal(widgetPresent, true, "widget must be raised after status:processing for the focused session"); + + // Simulate app-messages.js's done handler: server sends done scoped to + // the SAME session that is still focused. + var doneMsg = { localId: "sess-A" }; + if (l.shouldApplyActivityEdge(doneMsg.localId, s.store.get("activeSessionId"))) { + s.store.set({ processing: false }); + } + assert.equal(widgetPresent, false, "widget MUST be removed after the matching done — this is the assertion the source-text regex could never make"); + }); +}); + +test("END-TO-END REGRESSION: a cross-session done does NOT clear the focused session's widget (would strand it OFF while still genuinely running)", { timeout: 10000 }, function () { + return Promise.all([import(STORE_URL), import(LATCH_URL)]).then(function (mods) { + var s = mods[0]; + var l = mods[1]; + s.createStore({ processing: false, activeSessionId: "sess-FOCUSED" }); + + var widgetPresent = false; + s.store.subscribe(["processing"], function (state, prev) { + if (state.processing === prev.processing) return; + widgetPresent = !!state.processing; + }); + + // The focused session starts processing. + var statusMsg = { status: "processing", localId: "sess-FOCUSED" }; + if (statusMsg.status === "processing" && l.shouldApplyActivityEdge(statusMsg.localId, s.store.get("activeSessionId"))) { + s.store.set({ processing: true }); + } + assert.equal(widgetPresent, true); + + // A DIFFERENT, background session finishes and sends its own done. + var backgroundDoneMsg = { localId: "sess-BACKGROUND" }; + if (l.shouldApplyActivityEdge(backgroundDoneMsg.localId, s.store.get("activeSessionId"))) { + s.store.set({ processing: false }); + } + assert.equal(widgetPresent, true, "the focused session's widget must remain ON — a background session's done must never clear it (this is the exact crosstalk MILLER's diagnosis names as the highest-probability stuck-ON trigger)"); + }); +}); + +test("END-TO-END REGRESSION (MILLER's stuck-ON reproduction): a cross-session status:processing does NOT raise the focused session's widget", { timeout: 10000 }, function () { + return Promise.all([import(STORE_URL), import(LATCH_URL)]).then(function (mods) { + var s = mods[0]; + var l = mods[1]; + s.createStore({ processing: false, activeSessionId: "sess-FOCUSED" }); + + var widgetPresent = false; + s.store.subscribe(["processing"], function (state, prev) { + if (state.processing === prev.processing) return; + widgetPresent = !!state.processing; + }); + + // A BACKGROUND session (e.g. one of several concurrent sessions per + // MILLER's diagnosis: "operator runs many concurrent sessions") starts + // processing while a DIFFERENT session is focused. + var statusMsg = { status: "processing", localId: "sess-BACKGROUND" }; + if (statusMsg.status === "processing" && l.shouldApplyActivityEdge(statusMsg.localId, s.store.get("activeSessionId"))) { + s.store.set({ processing: true }); + } + assert.equal(widgetPresent, false, "a background session's status:processing must never raise the FOCUSED session's widget — pre-fix this write was unconditional and exactly this edge could leave a permanently-stuck-ON dot with no done ever routed back to clear it"); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Source-inspection (correct/limited grep use, per this suite's own +// convention): the real DOM-driving files actually CALL INTO the pure, +// behaviorally-proven exports above, rather than reimplementing the +// guard inline (which would silently drift out of sync with the proof). +// --------------------------------------------------------------------------- + +test("CI invariant: app-messages.js status/done/auth_required handlers call the real shouldApplyActivityEdge, not a reimplemented inline guard", function () { + var src = stripLineComments(readMod("lib/public/modules/app-messages.js")); + assert.match( + src, + /import\s*\{[^}]*\bshouldApplyActivityEdge\b[^}]*\}\s*from\s*['"]\.\/activity-latch\.js['"]/, + "app-messages.js must import shouldApplyActivityEdge from activity-latch.js" + ); + var occurrences = src.match(/shouldApplyActivityEdge\(/g) || []; + assert.ok( + occurrences.length >= 3, + "expected shouldApplyActivityEdge(...) to be called at least 3 times (status, done, auth_required handlers) — found " + occurrences.length + ); +}); + +test("CI invariant: app-favicon.js's initActivityFooter drives the staleness backstop via the real createActivityStaleBackstop, not a reimplemented inline timer", function () { + var src = stripLineComments(readMod("lib/public/modules/app-favicon.js")); + assert.match( + src, + /import\s*\{[^}]*\bcreateActivityStaleBackstop\b[^}]*\}\s*from\s*['"]\.\/activity-latch\.js['"]/, + "app-favicon.js must import createActivityStaleBackstop from activity-latch.js" + ); + assert.match( + src, + /createActivityStaleBackstop\(/, + "app-favicon.js must actually construct a backstop instance" + ); + var fnStart = src.indexOf("export function initActivityFooter"); + assert.ok(fnStart !== -1); + var fnBody = src.slice(fnStart, src.indexOf("\n}", fnStart) + 2); + assert.match( + fnBody, + /\.onTransition\(/, + "initActivityFooter must call the backstop's onTransition on every processing change, mirroring the exact state machine proven in section 2 above" + ); +}); + +test("CI invariant: server-side sessions.js/project.js stamp localId on status/done/auth_required at a shared choke point, not per-call-site (reuse-first, PEACHES rule 2)", function () { + var sessionsSrc = stripLineComments(readMod("lib/sessions.js")); + assert.match( + sessionsSrc, + /function stampActivityLocalId\s*\(/, + "lib/sessions.js must define the shared stamping helper" + ); + assert.match( + sessionsSrc, + /module\.exports\s*=\s*\{[^}]*\bstampActivityLocalId\b/, + "stampActivityLocalId must be exported for project.js to reuse (not duplicated)" + ); + var projectSrc = stripLineComments(readMod("lib/project.js")); + assert.match( + projectSrc, + /require\(["']\.\/sessions["']\)/, + "project.js must require sessions.js" + ); + assert.match( + projectSrc, + /stampActivityLocalId/, + "project.js's own sendToSession/sendToSessionOthers must reuse the shared stamping helper, not reimplement it" + ); +}); + +test("CI invariant: session_switched now carries isProcessing from BOTH server send sites (item b — the hydration path previously omitted it, per MILLER's smoking-gun citation)", function () { + var connSrc = stripLineComments(readMod("lib/project-connection.js")); + var switchedIdx = connSrc.indexOf('type: "session_switched"'); + assert.ok(switchedIdx !== -1, "expected a session_switched send in project-connection.js"); + var sendCallEnd = connSrc.indexOf(");", switchedIdx); + var sendCall = connSrc.slice(switchedIdx, sendCallEnd); + assert.match( + sendCall, + /isProcessing:\s*!!active\.isProcessing/, + "project-connection.js's session_switched hydration send must carry isProcessing so a fresh connect/reconnect reconciles the footer instead of leaving 'processing' at whatever the latch happened to be" + ); + + var sessionsSrc = stripLineComments(readMod("lib/sessions.js")); + var switchedIdx2 = sessionsSrc.indexOf('type: "session_switched"'); + assert.ok(switchedIdx2 !== -1, "expected a session_switched send in sessions.js"); + var sendCallEnd2 = sessionsSrc.indexOf(");", switchedIdx2); + var sendCall2 = sessionsSrc.slice(switchedIdx2, sendCallEnd2); + assert.match( + sendCall2, + /isProcessing:\s*!!session\.isProcessing/, + "sessions.js's switchSession send must still carry isProcessing (pre-existing, pinned so it can't regress)" + ); +}); + +test("CI invariant: app-messages.js's session_switched handler reconciles the 'processing' latch from the authoritative snapshot, not only 'sessionIsProcessing' (item b)", function () { + var src = stripLineComments(readMod("lib/public/modules/app-messages.js")); + var idx = src.indexOf("session_switched: function"); + assert.ok(idx !== -1); + var handlerBody = src.slice(idx, src.indexOf("\n },", idx) + 5); + // sessionIsProcessing must still be set directly from msg.isProcessing — + // this is activity-state-lr-66c118.test.js's one documented .isProcessing + // exception, and it must stay the ONLY raw read in this file (see that + // suite's CI invariant #2). 'processing' must be re-derived from THAT + // store field (not a second raw msg.isProcessing read) so both fields stay + // reconciled to one source without adding a second documented exception. + assert.match( + handlerBody, + /sessionIsProcessing:\s*!!msg\.isProcessing/, + "session_switched must still set sessionIsProcessing directly from msg.isProcessing (the one documented exception)" + ); + assert.match( + handlerBody, + /processing:\s*store\.get\(['"]sessionIsProcessing['"]\)/, + "session_switched must reconcile store 'processing' (the field initActivityFooter subscribes to) from the just-set sessionIsProcessing value on every switch/reconnect/hydration" + ); +});