diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index ce35ac9..f9ca671 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -4933,10 +4933,17 @@

${linkify(k.statement)}

loading = loadNow(); try { return await loading; } finally { loading = null; } } +// A refresh gives up after READ_GIVE_UP_MS, so one read the server never answers can't hold the +// one-at-a-time guard: the next poll tries again. The first read waits as long as the server does. +const READ_GIVE_UP_MS = 30000; async function loadNow() { let d; + const stop = new AbortController(); + const late = DATA ? setTimeout(() => stop.abort(), READ_GIVE_UP_MS) : null; try { - const r = await fetch('/api/data'); + const r = await fetch('/api/data', { signal: stop.signal }); + // the body first: a read given up on mid-body leaves none of what its headers say applied + const body = await r.text(); const pv = r.headers.get('x-devteam-page'); if (pv) { if (!PAGE_VERSION) PAGE_VERSION = pv; else if (pv !== PAGE_VERSION) $('updated').hidden = false; } // what the cloud box serves; absent on a laptop, and a header that doesn't parse counts as absent @@ -4944,13 +4951,12 @@

${linkify(k.statement)}

try { if (r.headers.get('x-devteam-public')) PUBLIC = JSON.parse(r.headers.get('x-devteam-public')); } catch {} PREVIEWS = r.headers.get('x-devteam-previews') === '1'; STALE = r.headers.get('x-devteam-stale') === 'true'; - const body = await r.text(); try { d = JSON.parse(body); } catch { throw new Error(r.ok ? 'the server sent something that is not JSON' : `${r.status} ${body.slice(0, 400)}`); } if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); } catch (e) { $('err').textContent = 'could not load (see the top of the page)'; - return showFailure(e.message || String(e)); - } + return showFailure(stop.signal.aborted ? `the server did not answer in ${READ_GIVE_UP_MS / 1000} s; trying again` : e.message || String(e)); + } finally { clearTimeout(late); } $('err').textContent = ''; $('banner').hidden = true; $('loading').hidden = true; diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index 8a29a7a..e1cca1c 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -10760,6 +10760,9 @@ const RESTART_MAX = 3; const RESTART_CHILD_MS = 100000; const RESTART_TICK_MS = 110000; const RESTART_MIN_MS = 30000; +// How long a dashboard read waits on a dashboard-data run before it has the last good data instead +// (0 is no wait at all; anything that isn't a number is the default). +const DATA_WAIT_MS = ((n) => (process.env.DEVTEAM_DATA_WAIT_MS?.trim() && Number.isFinite(n) && n >= 0 ? n : 20000))(Number(process.env.DEVTEAM_DATA_WAIT_MS)); // makeSession --if-gone found the window there after all (it never types into one): not a failure. const IF_GONE_LEFT = 3; // The last moment a tick may start a cswap switch (its 45 s timeout, inside RESTART_TICK_MS). @@ -12198,13 +12201,14 @@ async function cmdDashboard() { accountsSaid = said; }); }; - // One dashboard-data run at a time, shared by every page waiting on it, given 2 minutes: each + // One dashboard-data run at a time, shared by every page waiting on it, given 2 minutes (a page + // with data already waits DATA_WAIT_MS for it, then has that data, marked stale): each // refresh used to start its own, and on a slow machine they piled up (one ran for an hour). let dataRun = null; let lastData = null; const dashboardData = () => (dataRun ??= new Promise((resolve) => execFile(process.execPath, [self, 'dashboard-data'], { cwd: mainRoot, maxBuffer: 64 * 1024 * 1024, env: { ...ownerEnv, DEVTEAM_PREVIEW_UP: JSON.stringify(Object.fromEntries(previewUp)) }, timeout: 120000, killSignal: 'SIGKILL' }, (err, stdout, stderr) => { dataRun = null; - if (!err) lastData = stdout; + if (!err && stdout) lastData = stdout; resolve({ ok: !err, stdout, stderr: err?.killed ? 'dashboard-data took over 2 minutes and was stopped' : String(stderr ?? '') }); }))); // /api/threads: every thread, its turns and the questions asked in it, for the open card's 2 s poll @@ -12255,7 +12259,12 @@ async function cmdDashboard() { return res.end(fs.readFileSync(page)); } if (req.method === 'GET' && req.url.startsWith('/api/data')) { - const r = await dashboardData(); + // A run slower than DATA_WAIT_MS, with data to fall back on, answers with that, marked stale: + // the run goes on and the next read has it, but no page waits minutes on a stuck child. + let waited; + const slow = new Promise((resolve) => { waited = setTimeout(() => resolve({ ok: false, stderr: '' }), DATA_WAIT_MS); }); + const r = await (lastData ? Promise.race([dashboardData(), slow]) : dashboardData()); + clearTimeout(waited); if (!r.ok && !lastData) return send(res, 500, 'application/json', JSON.stringify({ error: r.stderr.trim() })); // The page in use says which version of itself it is: an open tab keeps the code it loaded, // so after a kit update it compares this with the first one it saw and offers a reload. diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 843f8d1..f58da63 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -16,8 +16,8 @@ import { canDrive, openChrome } from './lib/chrome.mjs'; // A wait on a condition in a page. Page.navigate answers before the new page is parsed, so an // evaluation that throws (the last page still there, or the new one half parsed) is "not yet"; timed // out, it says where the tab is and the last error, so a page that never became the one expected -// reads as that. -const soonIn = (p, ms = 30000) => async (expr, what) => { +// reads as that. server, if given, adds the end of what the dashboard's server has said. +const soonIn = (p, ms = 30000, server = null) => async (expr, what) => { const end = Date.now() + ms; for (;;) { let v, err = null; @@ -25,7 +25,9 @@ const soonIn = (p, ms = 30000) => async (expr, what) => { if (v) return v; if (Date.now() > end) { const at = await p.ev('[location.href, document.title, document.readyState]').catch((e) => e.message); - assert.fail(`timed out: ${what}; the tab is at ${JSON.stringify(at)}${err ? `; last: ${err.message}` : ''}`); + // with the fixture's fetch hook: the page's last /api/data reads, a stuck one with its age so far + const reads = await p.ev('(window.__reads ?? []).slice(-5).map((x) => x.ms === null ? `in flight ${Date.now() - x.at} ms` : `${x.status} in ${x.ms} ms`)').catch(() => []); + assert.fail(`timed out: ${what}; the tab is at ${JSON.stringify(at)}${err ? `; last: ${err.message}` : ''}${reads.length ? `; its last data reads: ${reads.join(', ')}` : ''}${server ? `; the server said, last: ${JSON.stringify(String(server()).slice(-600))}` : ''}`); } await new Promise((r) => setTimeout(r, 50)); } @@ -327,7 +329,17 @@ const POLL_FAST = `(() => { window.__gate = null; window.__answerHold = null; const inner = async (u, o) => { - if (String(u).startsWith('/api/data')) { window.__polls++; if (window.__gate) await window.__gate; return rf(u, o); } + // each read's start and end (a held one starts when the gate opens), so a wait that times out can + // say whether a read was stuck on the server, and for how long. The page gives up on a read after + // 30 s from its fetch, gate included: a gate held that long reads as a read given up at 0 ms. + if (String(u).startsWith('/api/data')) { + window.__polls++; + if (window.__gate) await window.__gate; + const x = { at: Date.now(), ms: null, status: null }; + (window.__reads ??= []).push(x); + if (window.__reads.length > 20) window.__reads.shift(); + try { const r = await rf(u, o); x.status = r.status; return r; } catch (e) { x.status = String(e.message || e); throw e; } finally { x.ms = Date.now() - x.at; } + } if (String(u).startsWith('/api/answer') && window.__answerHold) await window.__answerHold; // every owner action recorded as sent, and held on the wire, as a slow server would hold it if (o?.method === 'POST' && String(u).startsWith('/api/')) { (window.__posts ??= []).push({ url: String(u), body: o.body }); if (window.__postHold) await window.__postHold; } @@ -429,7 +441,7 @@ test('presses: the page answers before the server, and a refusal puts it back', await p.send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 800, deviceScaleFactor: 1, mobile: false }); await p.send('Page.addScriptToEvaluateOnNewDocument', { source: POLL_FAST }); await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/#questions` }); - const soon = soonIn(p, 15000); + const soon = soonIn(p, 15000, () => dash.out()); const polls = async (n = 2) => { const at = await p.ev('window.__polls'); await soon(`window.__polls >= ${at + n}`, `${n} polls`); }; const hold = () => p.ev('void (window.__postHold = new Promise((r) => { window.__let = r; }))'); const letGo = () => p.ev('window.__let(); window.__postHold = null; void 0'); @@ -543,7 +555,7 @@ test('typing in the dashboard: a refresh never takes the field, the caret or the await p.send('Page.addScriptToEvaluateOnNewDocument', { source: POLL_FAST }); await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/#questions` }); const box = `textarea[data-q="${Q}"]`; - const soon = soonIn(p, 15000); + const soon = soonIn(p, 15000, () => dash.out()); await soon(`!!document.querySelector('${box}')`, 'the question card'); // Answered by polls actually arriving, however long /api/data takes. const polls = async (n = 2) => { const at = await p.ev('window.__polls'); await soon(`window.__polls >= ${at + n}`, `${n} polls`); }; @@ -676,6 +688,27 @@ test('typing in the dashboard: a refresh never takes the field, the caret or the } }); +test('a slow data run: a read with data to fall back on has it at once, marked stale, and the run still lands', async (t) => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'devteam-slow-data-')); + t.after(() => fs.rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + // waiting no time at all on a run: every read after the first is the last good data + const env = { ...process.env, DEVTEAM_DATA_WAIT_MS: '0', DEVTEAM_HUB_PORT: String(PORTS + 40), DEVTEAM_PORT_BASE: String(PORTS + 41), CLAUDE_CONFIG_DIR: path.join(base, 'claude'), CODEX_HOME: path.join(base, 'codex') }; + for (const k of ['DEVTEAM_ROLE', 'DEVTEAM_TICKET', 'DEVTEAM_FEATURE', 'DEVTEAM_RUN', 'DEVTEAM_SUPERVISOR', 'TMUX', 'TMUX_PANE']) delete env[k]; + const root = bareProject(base, 'slow'); + const ask = (q) => { const r = spawnSync(process.execPath, [path.join(root, 'docs/devteam/scripts/devteam.mjs'), 'ask', '--question', q], { cwd: root, env: { ...env, DEVTEAM_ROLE: 'supervisor' }, encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); }; + ask('First?'); + const port = await freePort(); + const dash = startDashboard(root, env, ['--port', String(port)]); + t.after(() => dash.stop()); + await until(() => /dashboard on http/.test(dash.out()), 'the dashboard'); + const read = async () => { const r = await fetch(`http://127.0.0.1:${port}/api/data`); return { stale: r.headers.get('x-devteam-stale') === 'true', asked: (await r.json()).questions.map((q) => q.question) }; }; + assert.deepEqual(await read(), { stale: false, asked: ['First?'] }, 'the first read waits for its run: there is nothing to fall back on'); + ask('Second?'); + assert.deepEqual(await read(), { stale: true, asked: ['First?'] }, 'a read during a run has the last good data, marked stale'); + // the run that read started goes on, and a later read has what it found + await until(async () => (await read()).asked.includes('Second?'), 'the run landing'); +}); + test('the dashboard and hub pages parse: one stray quote and nothing shows at all', () => { for (const page of ['index.html', 'hub.html']) { const html = fs.readFileSync(path.join(kit, 'dashboard', page), 'utf8');