mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 11:47:54 +02:00
fix: prevent plan canvas connection starvation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<res>
|
||||
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 = {
|
||||
|
||||
@@ -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();
|
||||
})();`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user