From 7e0d115eb593cca519f0d47a25db00ba196b0eda Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:48:15 -0400 Subject: [PATCH] fix: restart stale plan canvas servers --- scripts/lib/plan-canvas/server.js | 36 +++++++++++++++++++++++++++- scripts/plan-canvas.js | 40 ++++++++++++++++++++++++------- tests/scripts/plan-canvas.test.js | 8 +++++-- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js index 11b44062a..6caf17534 100644 --- a/scripts/lib/plan-canvas/server.js +++ b/scripts/lib/plan-canvas/server.js @@ -10,6 +10,7 @@ */ const { EventEmitter } = require('events'); +const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const path = require('path'); @@ -36,8 +37,33 @@ const DEFAULT_THINKING_STALE_MS = 90 * 1000; 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 TYPING_STATES = new Set(['thinking', 'typing', 'idle']); +// Package versions do not distinguish two worktrees on the same release. +// Fingerprint every module loaded into the detached server so a current CLI +// never reuses stale browser or protocol code from an older checkout. +function computeRuntimeId() { + const sources = [ + ['loopback-guard.js', path.join(__dirname, '..', 'loopback-guard.js')], + ['markdown.js', path.join(__dirname, 'markdown.js')], + ['sdk.js', path.join(__dirname, 'sdk.js')], + ['server.js', __filename], + ['sessions.js', path.join(__dirname, 'sessions.js')], + ['ui.js', path.join(__dirname, 'ui.js')] + ]; + const digest = crypto.createHash('sha256'); + for (const [name, sourcePath] of sources) { + digest.update(name); + digest.update('\0'); + digest.update(fs.readFileSync(sourcePath)); + digest.update('\0'); + } + return digest.digest('hex').slice(0, 16); +} + +const PLAN_CANVAS_RUNTIME_ID = computeRuntimeId(); + const CONTENT_TYPES = { '.css': 'text/css; charset=utf-8', '.gif': 'image/gif', @@ -541,7 +567,13 @@ function createPlanCanvasServer({ Promise.resolve() .then(() => { if (req.method === 'GET' && pathname === '/health') { - return sendJson(res, 200, { ok: true, app: 'ecc-plan-canvas', version }); + return sendJson(res, 200, { + ok: true, + app: 'ecc-plan-canvas', + version, + protocolVersion: PLAN_CANVAS_PROTOCOL_VERSION, + runtimeId: PLAN_CANVAS_RUNTIME_ID + }); } if (req.method === 'POST' && pathname === '/shutdown') { sendJson(res, 200, { status: 'stopping' }); @@ -628,6 +660,8 @@ module.exports = { DEFAULT_PORT, DEFAULT_THINKING_STALE_MS, DEFAULT_TYPING_EXPIRY_MS, + PLAN_CANVAS_PROTOCOL_VERSION, + PLAN_CANVAS_RUNTIME_ID, createPlanCanvasServer, resolveIdleTimeoutMs, resolvePort diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js index 816a0be7b..63d68076b 100755 --- a/scripts/plan-canvas.js +++ b/scripts/plan-canvas.js @@ -30,6 +30,8 @@ const { } = require('./lib/plan-canvas/sessions'); const { DEFAULT_HOST, + PLAN_CANVAS_PROTOCOL_VERSION, + PLAN_CANVAS_RUNTIME_ID, createPlanCanvasServer, resolveIdleTimeoutMs, resolvePort @@ -168,12 +170,21 @@ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } -// Start (or reuse) the detached canvas server and return its port. A version -// mismatch after an ECC update restarts the server so browser and CLI never -// disagree about the protocol. +function serverIsCompatible(health) { + return Boolean( + health && + health.version === VERSION && + health.protocolVersion === PLAN_CANVAS_PROTOCOL_VERSION && + health.runtimeId === PLAN_CANVAS_RUNTIME_ID + ); +} + +// Start (or reuse) the detached canvas server and return its port. Worktrees +// can share a package version while carrying different Canvas code, so the +// health handshake binds reuse to the exact server runtime as well. async function ensureServer({ stateDir, port }) { const health = await healthCheck(port); - if (health && health.version === VERSION) return port; + if (serverIsCompatible(health)) return port; if (health) { await request(port, 'POST', '/shutdown').catch(() => {}); for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100); @@ -189,9 +200,9 @@ async function ensureServer({ stateDir, port }) { fs.closeSync(logFd); for (let i = 0; i < 50; i++) { await sleep(100); - if (await healthCheck(port)) return port; + if (serverIsCompatible(await healthCheck(port))) return port; } - throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`); + throw new Error(`plan-canvas server did not become compatible on port ${port}; check ${path.join(stateDir, 'server.log')}`); } function openBrowser(url) { @@ -218,7 +229,13 @@ async function cmdStatus({ stateDir, port }) { return { server: 'not running', hint: 'open an artifact to start one', stateDir }; } const sessions = await request(port, 'GET', '/api/sessions'); - return { server: `http://${DEFAULT_HOST}:${port}`, version: health.version, sessions: sessions.body.sessions }; + return { + server: `http://${DEFAULT_HOST}:${port}`, + version: health.version, + protocolVersion: health.protocolVersion, + runtimeId: health.runtimeId, + sessions: sessions.body.sessions + }; } async function cmdOpen(file, args, { stateDir, port }) { @@ -364,7 +381,14 @@ async function cmdServer(args, { stateDir, port }) { fs.mkdirSync(stateDir, { recursive: true }); fs.writeFileSync( serverInfoPath(stateDir), - JSON.stringify({ pid: process.pid, port: bound.port, version: VERSION, startedAt: new Date().toISOString() }, null, 2) + JSON.stringify({ + pid: process.pid, + port: bound.port, + version: VERSION, + protocolVersion: PLAN_CANVAS_PROTOCOL_VERSION, + runtimeId: PLAN_CANVAS_RUNTIME_ID, + startedAt: new Date().toISOString() + }, null, 2) ); // Sessions restored from disk resume their file watchers. for (const session of store.list()) { diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 9650a6fb4..6df582f64 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -14,7 +14,10 @@ const os = require('os'); const path = require('path'); const { createSessionStore } = require('../../scripts/lib/plan-canvas/sessions'); -const { createPlanCanvasServer } = require('../../scripts/lib/plan-canvas/server'); +const { + PLAN_CANVAS_RUNTIME_ID, + createPlanCanvasServer +} = require('../../scripts/lib/plan-canvas/server'); async function test(name, fn) { try { @@ -143,7 +146,8 @@ async function main() { ok: true, app: 'ecc-plan-canvas', version: '9.9.9-test', - protocolVersion: 2 + protocolVersion: 2, + runtimeId: PLAN_CANVAS_RUNTIME_ID }); })) passed++; else failed++;