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:
@@ -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