diff --git a/docs/testing/plan-canvas-loading-hang.tdd.md b/docs/testing/plan-canvas-loading-hang.tdd.md index 64916d84e..03f44fd37 100644 --- a/docs/testing/plan-canvas-loading-hang.tdd.md +++ b/docs/testing/plan-canvas-loading-hang.tdd.md @@ -13,6 +13,9 @@ opening a second Plan Canvas in one agent chat. disable or stall the page. 2. As a reviewer opening a plan with Mermaid diagrams, I want the Canvas page to finish loading even when the Mermaid CDN is slow or unavailable. +3. As a reviewer with several Canvas tabs still open, I want the next Canvas to + receive and render its document instead of waiting forever for a browser + connection slot. ## Task report @@ -43,6 +46,36 @@ opening a second Plan Canvas in one agent chat. failures. - GREEN checkpoint: `7fc31254 fix: keep mermaid from blocking canvas load`. +### Stop open canvases from exhausting the browser connection pool + +- RED: the normal Chrome profile showed an `Untitled` blank tab with its load + indicator running indefinitely. `lsof -nP -iTCP:4517` showed exactly six + established connections from Chrome's network service to Plan Canvas. Each + older Canvas tab owned one permanent `EventSource`, exhausting Chromium's + six-connection HTTP/1 pool before the next document request could receive a + byte. +- RED: the focused integration test failed because `/client.js` still created + `EventSource`, `/api/session/:key/state` did not exist, `/events/:key` held + its response open, and health still advertised protocol 2. +- GREEN: protocol 3 replaces per-tab EventSource streams with one-second, + finite state requests carrying chat, presence, session status, and artifact + revision. Polls are sequential and abort after five seconds, so they cannot + pile up. Legacy `/events/:key` now returns HTTP 204, the status that tells an + existing EventSource client not to reconnect after the server upgrade. +- GREEN: `node tests/scripts/plan-canvas.test.js` produced 31 passes and 0 + failures, including preservation of the active-browser idle lifecycle. + Focused ESLint and `git diff --check` passed. +- BROWSER: nine Canvas tabs were opened in one Chrome automation profile on an + isolated protocol-3 server. Every tab reached `document.readyState = + complete`, exposed its expected plan heading through the iframe accessibility + tree, and reported zero EventSource resources. Two shared keep-alive sockets + served the nine tabs at the observation point. +- DESKTOP: the normal Chrome profile kept the original blank protocol-2 tab + visible while three protocol-3 canvases on the isolated port rendered the + Sandbox Execution Fabric, Feature Fleet, and ECC 2 to ECC 3 plans. A captured + desktop-window image showed all three rendered tabs and live `agent listening` + presence. + ## Test specification | # | What is guaranteed | Test target | Type | Result | @@ -52,30 +85,44 @@ opening a second Plan Canvas in one agent chat. | 3 | Mermaid remote enhancement starts only after document load | `a plan containing mermaid serves the themed Mermaid loader` | Integration | PASS | | 4 | Open, browser load, await, feedback, reply, approval, reopen, end, and stop still work together | `tests/integration/plan-canvas-e2e.test.js` | End to end | PASS | | 5 | The large ECC 2 to ECC 3 master plan bootstraps under blocked local storage | Headless Chrome DOM and screenshot check against `/canvas/24af75d4c4fe` | Browser | PASS | +| 6 | The browser client never reserves one permanent HTTP connection per open Canvas | `browser client uses finite polling instead of one permanent connection per canvas` | Integration | PASS | +| 7 | Old EventSource clients stop reconnecting after upgrading the server | `legacy EventSource endpoint retires without reconnecting` | Integration | PASS | +| 8 | Browser polling carries chat, presence, end state, and artifact revision | Browser-state integration cases in `tests/scripts/plan-canvas.test.js` | Integration | PASS | +| 9 | More than Chromium's six HTTP/1 connection slots can coexist without blocking a new Canvas | Nine-tab Chrome DOM, accessibility-tree, resource-timing, and screenshot run | Browser | PASS | +| 10 | An actively viewed Canvas keeps the shared server alive through finite polls | `finite browser polls keep an actively viewed canvas server alive` | Integration | PASS | ## Coverage and full-suite evidence -`npm run coverage` passed all 3,993 discovered tests with these project totals: +`npm run coverage` passed all 3,996 discovered tests with these project totals: - Statements: 88.98% -- Branches: 80.66% -- Functions: 94.32% +- Branches: 80.65% +- Functions: 94.34% - Lines: 88.98% -The `scripts/lib/plan-canvas` group reached 98.38% statements, 88.2% branches, -98.64% functions, and 98.38% lines. Focused ESLint checks passed for every +The `scripts/lib/plan-canvas` group reached 98.53% statements, 87.82% branches, +100% functions, and 98.53% lines. Focused ESLint checks passed for every modified JavaScript test and production file. ## Browser evidence and known gaps The live shared server was initially process `15351`, started from the older `ecc-tiered-sandbox` worktree, and served an unguarded `localStorage` client even -when invoked from current main. The patched CLI replaced it with the isolated -worktree server and restored persisted sessions. Two real plan sessions then -opened successfully. Headless Chrome with local storage disabled completed the -large master-plan DOM load in about two seconds and produced a rendered Canvas -screenshot. +when invoked from current main. The first patch replaced it with the isolated +worktree server and restored persisted sessions. That did not establish the +reported bug was fixed: HTTP 200 responses and a fresh-profile screenshot did +not exercise the saturated normal browser profile. -Chrome was exercised directly on macOS. Safari and Firefox were not run. The -fix relies only on standard health JSON, dynamic `import()`, and the standard -window `load` event. +The corrected investigation captured the real blank tab, its six live browser +connections, and the exact release-preview session behind it. The final browser +run used an isolated protocol-3 server on port 4518 so other agents could not +replace the executable under test. It rendered the actual in-progress Sandbox +Execution Fabric from the tiered-sandbox worktree, plus Feature Fleet and the +ECC 2 to ECC 3 master plan, in the normal desktop Chrome profile. Active agent +listeners remained attached to all three. + +Chrome was exercised directly on macOS through both the user's normal profile +and a browser-automation profile. Safari and Firefox were not run. The +connection-pool fix relies on ordinary finite `fetch` requests, `AbortController`, +HTTP 204 EventSource retirement behavior, and file metadata for live-reload +revision checks. diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js index 6caf17534..c8fa091e4 100644 --- a/scripts/lib/plan-canvas/server.js +++ b/scripts/lib/plan-canvas/server.js @@ -4,7 +4,7 @@ * Plan Canvas loopback server. * * One detached process serves every open review session: the browser chrome, - * the rendered artifact, an SSE stream for live updates, and the long-poll + * the rendered artifact, finite browser state polling, and the long-poll * endpoint agents block on. Sessions are keyed by canonical artifact path * (see sessions.js). */ @@ -35,9 +35,7 @@ const MAX_BODY_BYTES = 1024 * 1024; const DEFAULT_THINKING_STALE_MS = 90 * 1000; // An explicit typing signal expires faster: it means "a reply is seconds away". const DEFAULT_TYPING_EXPIRY_MS = 30 * 1000; -// Presence is push-based, so expiring states need a tick to re-broadcast on. -const DEFAULT_PRESENCE_SWEEP_MS = 5 * 1000; -const PLAN_CANVAS_PROTOCOL_VERSION = 2; +const PLAN_CANVAS_PROTOCOL_VERSION = 3; const TYPING_STATES = new Set(['thinking', 'typing', 'idle']); // Package versions do not distinguish two worktrees on the same release. @@ -145,7 +143,6 @@ function createPlanCanvasServer({ heartbeatMs = 15000, thinkingStaleMs = DEFAULT_THINKING_STALE_MS, typingExpiryMs = DEFAULT_TYPING_EXPIRY_MS, - presenceSweepMs = DEFAULT_PRESENCE_SWEEP_MS, onIdleShutdown = null, log = () => {} } = {}) { @@ -154,17 +151,13 @@ function createPlanCanvasServer({ const allowedHostnames = buildAllowedHostnames(host); const wake = new EventEmitter(); wake.setMaxListeners(0); - const sseClients = new Map(); // key -> Set const awaitCounts = new Map(); // key -> active long-poll count const workingKeys = new Map(); // key -> ms timestamp the agent took feedback const typingKeys = new Map(); // key -> ms timestamp the agent signalled composing - const watchers = new Map(); // key -> fs.FSWatcher - const lastPresence = new Map(); // key -> last broadcast state, for sweep diffing let idleTimer = null; - let presenceSweep = null; let closed = false; - // --- presence + SSE --------------------------------------------------- + // --- presence --------------------------------------------------------- /** * Presence never claims more than the server actually knows: @@ -192,34 +185,6 @@ function createPlanCanvasServer({ return session.pendingFeedback && session.pendingFeedback.length > 0 ? 'queued' : 'waiting'; } - function broadcast(key, event, payload) { - const clients = sseClients.get(key); - if (!clients) return; - const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; - for (const client of clients) client.write(frameText); - } - - function broadcastPresence(key) { - const state = presenceFor(key); - lastPresence.set(key, state); - broadcast(key, 'presence', { state }); - } - - // Re-broadcast only where an expiry actually changed the answer, so an - // untouched canvas sees the thinking bubble clear itself. - function sweepPresence() { - for (const key of sseClients.keys()) { - const state = presenceFor(key); - if (lastPresence.get(key) !== state) broadcastPresence(key); - } - } - - function startPresenceSweep() { - if (presenceSweep || !presenceSweepMs) return; - presenceSweep = setInterval(sweepPresence, presenceSweepMs); - if (presenceSweep.unref) presenceSweep.unref(); - } - // The agent is off working on this feedback batch; start the thinking clock. function markThinking(key) { workingKeys.set(key, Date.now()); @@ -234,7 +199,6 @@ function createPlanCanvasServer({ function connectionCount() { let total = 0; - for (const clients of sseClients.values()) total += clients.size; for (const count of awaitCounts.values()) total += count; return total; } @@ -260,31 +224,12 @@ function createPlanCanvasServer({ armIdleTimer(); } - // --- artifact watching -------------------------------------------------- - - function watchSession(session) { - if (watchers.has(session.key)) return; - const dir = path.dirname(session.file); - const base = path.basename(session.file); - let debounce = null; + function artifactVersionFor(session) { try { - const watcher = fs.watch(dir, (eventType, filename) => { - if (filename && filename !== base) return; - clearTimeout(debounce); - debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150); - }); - watcher.on('error', () => watchers.delete(session.key)); - watchers.set(session.key, watcher); + const stat = fs.statSync(session.file, { bigint: true }); + return `${stat.mtimeNs}:${stat.size}`; } catch { - // Watching is best-effort; manual reload still works. - } - } - - function unwatchSession(key) { - const watcher = watchers.get(key); - if (watcher) { - watcher.close(); - watchers.delete(key); + return null; } } @@ -295,9 +240,6 @@ function createPlanCanvasServer({ if (!session) return null; clearAgentActivity(key); wake.emit(`wake:${key}`); - broadcast(key, 'ended', { endedBy: session.endedBy }); - broadcastPresence(key); - unwatchSession(key); return session; } @@ -322,8 +264,6 @@ function createPlanCanvasServer({ next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.' }); } - watchSession(session); - broadcastPresence(session.key); return sendJson(res, 200, { status: 'open', key: session.key, @@ -350,7 +290,6 @@ function createPlanCanvasServer({ const first = store.takeFeedback(key); if (first.status !== 'waiting') { if (first.status === 'feedback') markThinking(key); - broadcastPresence(key); return sendJson(res, 200, first); } @@ -358,7 +297,6 @@ function createPlanCanvasServer({ noteConnectionOpened(); awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1); clearAgentActivity(key); - broadcastPresence(key); let settled = false; let heartbeat = null; @@ -371,7 +309,6 @@ function createPlanCanvasServer({ if (payload.status === 'feedback') markThinking(key); res.end(JSON.stringify(payload)); } - broadcastPresence(key); noteConnectionClosed(); }; const onWake = () => { @@ -417,6 +354,23 @@ function createPlanCanvasServer({ return sendJson(res, 200, { status: 'ended', endedBy: 'agent' }); } + const stateMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/state$/); + if (stateMatch && req.method === 'GET') { + const session = store.get(stateMatch[1]); + if (!session) return sendJson(res, 404, { error: 'unknown session' }); + // A visible Canvas used to keep the shared server alive through its SSE + // connection. Preserve that lifecycle with finite polling by restarting + // the idle clock whenever an active browser reports in. + armIdleTimer(); + return sendJson(res, 200, { + status: session.status, + endedBy: session.endedBy || null, + chat: session.chat, + presence: presenceFor(session.key), + artifactVersion: artifactVersionFor(session) + }); + } + const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply|typing)$/); if (sessionMatch && req.method === 'POST') { const [, key, action] = sessionMatch; @@ -428,13 +382,10 @@ function createPlanCanvasServer({ const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) }); if (!result) return sendJson(res, 409, { error: 'session already ended' }); wake.emit(`wake:${key}`); - broadcast(key, 'chat-sync', { chat: store.get(key).chat }); - if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' }); // A parked `await` takes the batch synchronously on the wake above, so // presence is already `thinking` by now; with nobody listening it - // reports `queued`. Either way the browser must be told, which the - // original handler never did, leaving a stale pill on screen. - broadcastPresence(key); + // reports `queued`. The browser sees the current answer on its next + // finite state poll. return sendJson(res, 200, { status: 'queued', accepted: result.accepted.length, @@ -455,8 +406,6 @@ function createPlanCanvasServer({ } const entry = store.addAgentReply(key, body.text); clearAgentActivity(key); - broadcast(key, 'chat-sync', { chat: store.get(key).chat }); - broadcastPresence(key); return sendJson(res, 200, { status: 'sent', at: entry.at }); } @@ -471,7 +420,6 @@ function createPlanCanvasServer({ if (state === 'idle') clearAgentActivity(key); else if (state === 'typing') typingKeys.set(key, Date.now()); else markThinking(key); - broadcastPresence(key); return sendJson(res, 200, { status: 'ok', presence: presenceFor(key) }); } } @@ -482,32 +430,13 @@ function createPlanCanvasServer({ function handleEvents(req, res, key) { const session = store.get(key); if (!session) return sendJson(res, 404, { error: 'unknown session' }); - noteConnectionOpened(); - res.writeHead(200, { - 'content-type': 'text/event-stream', - 'cache-control': 'no-store', - connection: 'keep-alive' - }); - res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`); - res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`); - if (!sseClients.has(key)) sseClients.set(key, new Set()); - sseClients.get(key).add(res); - lastPresence.set(key, presenceFor(key)); - startPresenceSweep(); - const ping = setInterval(() => res.write(': ping\n\n'), 25000); - if (ping.unref) ping.unref(); - req.on('close', () => { - clearInterval(ping); - const clients = sseClients.get(key); - if (clients) { - clients.delete(res); - if (clients.size === 0) { - sseClients.delete(key); - lastPresence.delete(key); - } - } - noteConnectionClosed(); - }); + // Older Canvas clients opened one permanent EventSource per tab. Six open + // tabs exhausted Chromium's HTTP/1 connection pool for this origin, so + // the next top-level navigation waited forever without receiving a byte. + // HTTP 204 tells EventSource not to reconnect, releasing legacy tabs after + // a server upgrade. Current clients use finite state polling below. + res.writeHead(204, { 'cache-control': 'no-store', connection: 'close' }); + res.end(); } function serveArtifact(res, key, assetPath) { @@ -626,14 +555,6 @@ function createPlanCanvasServer({ function close() { closed = true; clearTimeout(idleTimer); - clearInterval(presenceSweep); - presenceSweep = null; - lastPresence.clear(); - for (const key of watchers.keys()) unwatchSession(key); - for (const clients of sseClients.values()) { - for (const client of clients) client.end(); - } - sseClients.clear(); wake.emit('server-close'); return new Promise((resolve, reject) => { server.close(error => (error ? reject(error) : resolve())); @@ -652,7 +573,7 @@ function createPlanCanvasServer({ }); } - return { server, listen, close, presenceFor, sweepPresence, watchSession }; + return { server, listen, close, presenceFor }; } module.exports = { diff --git a/scripts/lib/plan-canvas/ui.js b/scripts/lib/plan-canvas/ui.js index f221f5ae1..68492e08f 100644 --- a/scripts/lib/plan-canvas/ui.js +++ b/scripts/lib/plan-canvas/ui.js @@ -209,6 +209,8 @@ function canvasClientJs() { let lastScroll = { x: 0, y: 0 }; let ended = boot.status === 'ended'; let sending = false; + let chatFingerprint = JSON.stringify(boot.chat || []); + let artifactVersion = null; try { queue = JSON.parse(sessionStorage.getItem(QKEY) || '[]'); } catch { queue = []; } @@ -436,7 +438,7 @@ function canvasClientJs() { } if (ended) markEnded(boot.endedBy); - // --- server events ---------------------------------------------------- + // --- server state ----------------------------------------------------- const PRESENCE_LABELS = { waiting: 'agent not connected', listening: 'agent listening', @@ -450,20 +452,44 @@ function canvasClientJs() { presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state; renderActivity(state); } - function connectEvents() { - const es = new EventSource('/events/' + key); - es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || [])); - es.addEventListener('presence', e => applyPresence(JSON.parse(e.data).state)); - es.addEventListener('reload', reloadArtifact); - es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); }); - es.onerror = () => { - if (ended) return; - renderActivity('offline'); - presence.setAttribute('data-state', 'waiting'); - presence.querySelector('.label').textContent = 'canvas server offline'; - }; + async function pollState() { + if (ended) return; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch('/api/session/' + key + '/state', { + cache: 'no-store', + signal: controller.signal + }); + if (!res.ok) throw new Error('HTTP ' + res.status); + const state = await res.json(); + const nextChatFingerprint = JSON.stringify(state.chat || []); + if (nextChatFingerprint !== chatFingerprint) { + chatFingerprint = nextChatFingerprint; + renderChat(state.chat || []); + } + if (state.status === 'ended') { + markEnded(state.endedBy); + return; + } + applyPresence(state.presence); + if (artifactVersion === null) artifactVersion = state.artifactVersion; + else if (state.artifactVersion !== artifactVersion) { + artifactVersion = state.artifactVersion; + reloadArtifact(); + } + } catch { + if (!ended) { + renderActivity('offline'); + presence.setAttribute('data-state', 'waiting'); + presence.querySelector('.label').textContent = 'canvas server offline'; + } + } finally { + clearTimeout(timeout); + if (!ended) setTimeout(pollState, 1000); + } } - connectEvents(); + pollState(); })();`; } diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js index 63d68076b..389099906 100755 --- a/scripts/plan-canvas.js +++ b/scripts/plan-canvas.js @@ -390,10 +390,6 @@ async function cmdServer(args, { stateDir, port }) { startedAt: new Date().toISOString() }, null, 2) ); - // Sessions restored from disk resume their file watchers. - for (const session of store.list()) { - if (session.status !== 'ended') canvas.watchSession(store.get(session.key)); - } process.on('SIGINT', () => shutdown(0)); process.on('SIGTERM', () => shutdown(0)); process.stderr.write(`[plan-canvas] serving on http://${bound.host}:${bound.port}\n`); diff --git a/tests/integration/plan-canvas-e2e.test.js b/tests/integration/plan-canvas-e2e.test.js index a7c5805f9..8e7bb811b 100644 --- a/tests/integration/plan-canvas-e2e.test.js +++ b/tests/integration/plan-canvas-e2e.test.js @@ -176,7 +176,7 @@ async function main() { assert.strictEqual(result.parsed.status, 'open'); assert.strictEqual(shutdownRequested, true, 'current CLI should retire the stale server'); const health = JSON.parse((await request(legacyPort, 'GET', '/health')).body); - assert.strictEqual(health.protocolVersion, 2); + assert.strictEqual(health.protocolVersion, 3); } finally { if (legacyServer.listening) { await new Promise(resolve => legacyServer.close(resolve)); diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 9b044fb1f..0e0455055 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -2,7 +2,7 @@ * Integration tests for the Plan Canvas server (scripts/lib/plan-canvas/). * * Spins up the real HTTP server in-process and drives it exactly like the - * browser chrome (fetch + SSE) and the agent CLI (long-poll) do. + * browser chrome (finite fetch polling) and the agent CLI (long-poll) do. * * Run with: node tests/scripts/plan-canvas.test.js */ @@ -63,35 +63,8 @@ function jsonBody(res) { return JSON.parse(res.body.trim()); } -// Open an SSE stream and collect parsed events into `received`. -function openSse(port, key) { - const received = []; - let close = () => {}; - const ready = new Promise((resolve, reject) => { - const req = http.get( - { host: '127.0.0.1', port, path: `/events/${key}`, agent: false }, - res => { - let buffer = ''; - res.on('data', chunk => { - buffer += chunk; - let idx; - while ((idx = buffer.indexOf('\n\n')) >= 0) { - const frame = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); - const eventMatch = frame.match(/^event: (.+)$/m); - const dataMatch = frame.match(/^data: (.+)$/m); - if (eventMatch && dataMatch) { - received.push({ event: eventMatch[1], data: JSON.parse(dataMatch[1]) }); - } - } - }); - resolve(); - } - ); - req.on('error', reject); - close = () => req.destroy(); - }); - return { received, ready, close: () => close() }; +async function browserState(port, key) { + return jsonBody(await request(port, 'GET', `/api/session/${key}/state`)); } function waitFor(predicate, { timeoutMs = 3000, intervalMs = 20 } = {}) { @@ -146,7 +119,7 @@ async function main() { ok: true, app: 'ecc-plan-canvas', version: '9.9.9-test', - protocolVersion: 2, + protocolVersion: 3, runtimeId: PLAN_CANVAS_RUNTIME_ID }); })) passed++; else failed++; @@ -236,6 +209,41 @@ async function main() { } })) passed++; else failed++; + if (await test('browser client uses finite polling instead of one permanent connection per canvas', async () => { + const res = await request(port, 'GET', '/client.js'); + assert.ok(res.body.includes("'/api/session/' + key + '/state'")); + assert.ok(!res.body.includes('new EventSource(')); + })) passed++; else failed++; + + if (await test('legacy EventSource endpoint retires without reconnecting', async () => { + const res = await request(port, 'GET', `/events/${key}`); + assert.strictEqual(res.statusCode, 204); + assert.strictEqual(res.body, ''); + })) passed++; else failed++; + + if (await test('finite browser polls keep an actively viewed canvas server alive', async () => { + const idleArtifact = path.join(tmp, 'browser-active.plan.md'); + fs.writeFileSync(idleArtifact, '# Plan: Browser Active\n'); + const idleStore = createSessionStore({ stateDir: path.join(tmp, 'browser-active-state') }); + let shutdowns = 0; + const idleCanvas = createPlanCanvasServer({ + store: idleStore, + version: '9.9.9-test', + idleTimeoutMs: 200, + onIdleShutdown: () => { shutdowns += 1; } + }); + const bound = await idleCanvas.listen(0); + const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: idleArtifact } })); + + await new Promise(resolve => setTimeout(resolve, 100)); + await browserState(bound.port, opened.key); + await new Promise(resolve => setTimeout(resolve, 100)); + assert.strictEqual(shutdowns, 0); + await new Promise(resolve => setTimeout(resolve, 120)); + assert.strictEqual(shutdowns, 1); + await idleCanvas.close(); + })) passed++; else failed++; + if (await test('await with timeoutMs returns waiting when idle', async () => { const res = await request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}&timeoutMs=50`); assert.strictEqual(jsonBody(res).status, 'waiting'); @@ -247,10 +255,9 @@ async function main() { })) passed++; else failed++; if (await test('browser feedback wakes a blocking await; presence transitions', async () => { - const sse = openSse(port, key); - await sse.ready; const awaitPromise = request(port, 'GET', `/api/await?file=${encodeURIComponent(artifact)}`); - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'listening')); + await waitFor(() => canvas.presenceFor(key) === 'listening'); + assert.strictEqual((await browserState(port, key)).presence, 'listening'); const post = await request(port, 'POST', `/api/session/${key}/feedback`, { body: { @@ -268,9 +275,9 @@ async function main() { assert.strictEqual(result.items[0].anchor.selector, 'h2:nth-of-type(1)'); assert.strictEqual(result.items[1].verdict, 'request-changes'); - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking')); - await waitFor(() => sse.received.some(e => e.event === 'chat-sync' && e.data.chat.length === 2)); - sse.close(); + const state = await browserState(port, key); + assert.strictEqual(state.presence, 'thinking'); + assert.strictEqual(state.chat.length, 2); })) passed++; else failed++; // Regression: feedback sent with nobody parked on `await` used to leave the @@ -279,34 +286,30 @@ async function main() { const queuedArtifact = path.join(tmp, 'queued.plan.md'); fs.writeFileSync(queuedArtifact, '# Plan: Queued\n'); const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: queuedArtifact } })); - const sse = openSse(port, opened.key); - await sse.ready; - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting')); + assert.strictEqual((await browserState(port, opened.key)).presence, 'waiting'); const post = await request(port, 'POST', `/api/session/${opened.key}/feedback`, { body: { items: [{ kind: 'chat', text: 'anyone there?' }] } }); assert.strictEqual(jsonBody(post).presence, 'queued'); assert.strictEqual(canvas.presenceFor(opened.key), 'queued'); - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'queued')); + assert.strictEqual((await browserState(port, opened.key)).presence, 'queued'); // Draining it hands the batch over and flips the indicator to thinking. const drained = jsonBody(await request(port, 'GET', `/api/await?key=${opened.key}&timeoutMs=0`)); assert.strictEqual(drained.status, 'feedback'); assert.strictEqual(canvas.presenceFor(opened.key), 'thinking'); - sse.close(); + assert.strictEqual((await browserState(port, opened.key)).presence, 'thinking'); })) passed++; else failed++; if (await test('typing endpoint drives the indicator and reply clears it', async () => { const typingArtifact = path.join(tmp, 'typing.plan.md'); fs.writeFileSync(typingArtifact, '# Plan: Typing\n'); const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: typingArtifact } })); - const sse = openSse(port, opened.key); - await sse.ready; const typing = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'typing' } }); assert.strictEqual(jsonBody(typing).presence, 'typing'); - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'typing')); + assert.strictEqual((await browserState(port, opened.key)).presence, 'typing'); const thinking = await request(port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); assert.strictEqual(jsonBody(thinking).presence, 'thinking'); @@ -317,8 +320,7 @@ async function main() { // A landed reply must take the bubble down, not leave it spinning. await request(port, 'POST', `/api/session/${opened.key}/reply`, { body: { text: 'done' } }); assert.strictEqual(canvas.presenceFor(opened.key), 'waiting'); - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'waiting')); - sse.close(); + assert.strictEqual((await browserState(port, opened.key)).presence, 'waiting'); })) passed++; else failed++; if (await test('thinking and typing states expire instead of sticking', async () => { @@ -330,8 +332,7 @@ async function main() { version: '9.9.9-test', idleTimeoutMs: 0, thinkingStaleMs: 40, - typingExpiryMs: 20, - presenceSweepMs: 0 + typingExpiryMs: 20 }); const bound = await staleCanvas.listen(0); const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: staleArtifact } })); @@ -353,9 +354,7 @@ async function main() { await staleCanvas.close(); })) passed++; else failed++; - // The stuck pill only self-heals if the decay is pushed to an idle browser - // that is not making any requests of its own. - if (await test('presence sweep pushes the decayed state to an idle browser', async () => { + if (await test('finite browser polling observes a decayed presence state', async () => { const sweepArtifact = path.join(tmp, 'sweep.plan.md'); fs.writeFileSync(sweepArtifact, '# Plan: Sweep\n'); const sweepStore = createSessionStore({ stateDir: path.join(tmp, 'sweep-state') }); @@ -363,22 +362,15 @@ async function main() { store: sweepStore, version: '9.9.9-test', idleTimeoutMs: 0, - thinkingStaleMs: 50, - presenceSweepMs: 20 + thinkingStaleMs: 50 }); const bound = await sweepCanvas.listen(0); const opened = jsonBody(await request(bound.port, 'POST', '/api/sessions', { body: { file: sweepArtifact } })); - const sse = openSse(bound.port, opened.key); - await sse.ready; await request(bound.port, 'POST', `/api/session/${opened.key}/typing`, { body: { state: 'thinking' } }); - await waitFor(() => sse.received.some(e => e.event === 'presence' && e.data.state === 'thinking')); - - const before = sse.received.length; - await waitFor(() => - sse.received.slice(before).some(e => e.event === 'presence' && e.data.state === 'waiting') - ); - sse.close(); + assert.strictEqual((await browserState(bound.port, opened.key)).presence, 'thinking'); + await new Promise(resolve => setTimeout(resolve, 80)); + assert.strictEqual((await browserState(bound.port, opened.key)).presence, 'waiting'); await sweepCanvas.close(); })) passed++; else failed++; @@ -403,25 +395,18 @@ async function main() { assert.strictEqual(JSON.parse(full.trim()).status, 'feedback'); })) passed++; else failed++; - if (await test('agent reply lands in the chat via SSE chat-sync', async () => { - const sse = openSse(port, key); - await sse.ready; + if (await test('agent reply lands in the finite browser state response', async () => { const res = await request(port, 'POST', `/api/session/${key}/reply`, { body: { text: 'reworked, please re-check' } }); assert.strictEqual(jsonBody(res).status, 'sent'); - await waitFor(() => - sse.received.some( - e => e.event === 'chat-sync' && e.data.chat.some(m => m.role === 'agent' && m.text.includes('reworked')) - ) - ); - sse.close(); + const state = await browserState(port, key); + assert.ok(state.chat.some(m => m.role === 'agent' && m.text.includes('reworked'))); })) passed++; else failed++; - if (await test('live reload: editing the artifact emits an SSE reload event', async () => { - const sse = openSse(port, key); - await sse.ready; + if (await test('live reload: editing the artifact changes the finite state revision', async () => { + const before = (await browserState(port, key)).artifactVersion; fs.appendFileSync(artifact, '\n## Addendum\n'); - await waitFor(() => sse.received.some(e => e.event === 'reload'), { timeoutMs: 4000 }); - sse.close(); + const after = (await browserState(port, key)).artifactVersion; + assert.notStrictEqual(after, before); })) passed++; else failed++; if (await test('send-and-end delivers the final batch and ends the session', async () => {