diff --git a/changelog.d/1910-team-read-retry.fixed.md b/changelog.d/1910-team-read-retry.fixed.md new file mode 100644 index 000000000..2aa231270 --- /dev/null +++ b/changelog.d/1910-team-read-retry.fixed.md @@ -0,0 +1 @@ +Project board: a refused `project-team` read at open is no answer yet, not an empty team — the next five-second poll asks again, the block fills when the answer lands, and it stays frozen for the rest of the open; before this the refusal was normalised to `{ rows: [] }` and served by every later poll ([#1910](https://github.com/ThinkFlowLab/FlyBot/issues/1910)). diff --git a/core/client.js b/core/client.js index a47a44925..dae8cd33e 100644 --- a/core/client.js +++ b/core/client.js @@ -11264,13 +11264,19 @@ async function readProjectBoard(read, wanted, knownTeam) { const listing = await read("projects"); const projects = Array.isArray(listing?.projects) ? listing.projects : []; const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); - if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } }; + if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true }; const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; - const [record, board, taskList, team] = await Promise.all([ + const [record, board, taskList, dossier] = await Promise.all([ read("project-read", { id: project.id }), read("project-board", { id: project.id, timeZone: zone }), read("tasks", {}), - knownTeam !== void 0 ? knownTeam : read("project-team", { id: project.id }).catch(() => null) + // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for + // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached + // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. + knownTeam !== void 0 ? { answer: knownTeam, fresh: true } : read("project-team", { id: project.id }).then( + (answer) => ({ answer, fresh: answer !== null && typeof answer === "object" }), + () => ({ answer: null, fresh: false }) + ) ]); const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; @@ -11287,7 +11293,8 @@ async function readProjectBoard(read, wanted, knownTeam) { meetings: board?.meetings, queues, metrics: board?.metrics ?? null, - team: team !== null && typeof team === "object" ? team : { rows: [] } + team: dossier.answer !== null && typeof dossier.answer === "object" ? dossier.answer : { rows: [] }, + teamFresh: dossier.fresh }; } var PROJECT_BOARD_POLL_MS = 5e3; @@ -11334,7 +11341,7 @@ function ProjectBoardOverlay({ actions, call: request } = {}) { try { const next = await loadBoard(); if (!gone) { - team = next.team; + if (next.teamFresh !== false) team = next.team; setState(next); } } catch { diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 98dc3ade6..c1e589249 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -869,19 +869,29 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) { // answer the poll hands back — `knownTeam` is what the open read — because the host builds it by // reading persona and memory files, and a five-second refresh must not pay that again. A refusal // leaves the rest of the board standing, so a dossier problem is never the empty-Project answer. +// #1910: a refusal is "no answer yet", not an empty team. `teamFresh` is false exactly when the +// dossier could not be read, so the caller keeps no answer to hand back and the next tick asks again. export async function readProjectBoard(read, wanted, knownTeam) { const listing = await read('projects') const projects = Array.isArray(listing?.projects) ? listing.projects : [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) - if (project === undefined) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } } + if (project === undefined) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true } const zone = typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function' ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone - const [record, board, taskList, team] = await Promise.all([ + const [record, board, taskList, dossier] = await Promise.all([ read('project-read', { id: project.id }), read('project-board', { id: project.id, timeZone: zone }), read('tasks', {}), - knownTeam !== undefined ? knownTeam : read('project-team', { id: project.id }).catch(() => null), + // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for + // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached + // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. + knownTeam !== undefined + ? { answer: knownTeam, fresh: true } + : read('project-team', { id: project.id }).then( + answer => ({ answer, fresh: answer !== null && typeof answer === 'object' }), + () => ({ answer: null, fresh: false }), + ), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) // The groups are the same read as the rows, so the header cannot disagree with the cards. @@ -894,7 +904,8 @@ export async function readProjectBoard(read, wanted, knownTeam) { return { project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null, - team: team !== null && typeof team === 'object' ? team : { rows: [] }, + team: dossier.answer !== null && typeof dossier.answer === 'object' ? dossier.answer : { rows: [] }, + teamFresh: dossier.fresh, } } @@ -956,12 +967,17 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { // and the dossier comes from the single `project-team` call that read makes. #1900: that call is // this open's, not the poll's — `team` is handed back on every later read, so a tick re-reads // only the three answers that change and the dossier is read again on the next board open. + // #1910: only an answer is kept. A refused read is drawn as the empty team it always was, but + // `team` stays undefined, so the next tick asks `project-team` again instead of serving it. let team const loadBoard = () => readProjectBoard(read, wanted, team) const refresh = async (first) => { try { const next = await loadBoard() - if (!gone) { team = next.team; setState(next) } + if (!gone) { + if (next.teamFresh !== false) team = next.team + setState(next) + } } catch { // The first read is the page's answer, so it takes the empty state it always took. A poll // that fails keeps the board the last good read drew (#1814): a board that vanishes under diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js index 4542a3c62..2b090f827 100644 --- a/core/test/project-board-view.test.js +++ b/core/test/project-board-view.test.js @@ -424,6 +424,54 @@ test('#1900: an open board reads the dossier once, never on a tick', async () => assert.equal(count('project-board'), 4, 'while the board itself is re-read on every tick') }) +// #1910: a refused dossier read at open is "no answer yet", not "the team is empty". #1902 made the +// dossier the open's one read and let that read swallow its failure into an ordinary empty team, so +// one transient refusal became the answer every later poll handed back: the block stayed empty until +// the board was closed and opened again, where before (#1814) the next tick healed it. The refusal +// must leave no cached answer, so the next poll asks again; an answer that lands is this open's and +// later polls do not read the dossier again. +test('#1910: a refused dossier read at open is asked again on the next tick, then frozen', async () => { + resetClock() + win.location.hash = '#project' + const calls = [] + let dossierReads = 0 + const teamAnswer = { rows: [{ botId: 'bot-chief', name: 'Project Chief', persona: 'You are the Project Chief of FlyBot.', memory: { head: 'The Chief remembers the Project.', topics: ['work'] }, skills: ['ship-it'] }] } + const call = async (name) => { + calls.push(name) + if (name === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (name === 'project-read') { + return { project: { id: 'project-1', name: 'FlyBot', chief: { botId: 'bot-chief' }, assistant: { botId: 'bot-assistant' }, roles: { maintainers: ['bot-dev'], members: [] } }, canManage: false } + } + if (name === 'project-board') return { tasks: rows, done: [], waiting: [], inflight: [], blocked: [], counts: null } + if (name === 'tasks') return [{ id: 'task-b1', title: 'Ship the poll' }] + if (name === 'project-team') { + dossierReads += 1 + if (dossierReads === 1) throw new Error('the persona read timed out') + return teamAnswer + } + throw new Error(`unexpected endpoint ${name}`) + } + const count = (endpoint) => calls.filter(name => name === endpoint).length + const { tree, redraw } = await mount({ actions: {}, call }) + assert.equal(count('project-team'), 1, 'the open asks the dossier once') + // The refusal is drawn as the empty team it was normalised to, not as a failed board. + const control = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief') + assert.ok(control, 'the board still draws its team rows from the record') + control.props.onClick() + assert.doesNotMatch(all(redraw()).map(textOf).join(' | '), /You are the Project Chief of FlyBot\./, 'a refusal is no answer yet, so the panel does not invent a dossier') + // The refusal is not the answer: the next poll asks again, and the row it gets fills the block. + // Both are read before the assertion so one red run shows both numbers. + await tick() + const drawn = all(redraw()).map(textOf).join(' | ') + const afterTick = { calls: count('project-team'), filled: /You are the Project Chief of FlyBot\./.test(drawn) } + assert.deepEqual(afterTick, { calls: 2, filled: true }, 'the next tick asks again, and the row it gets fills the dossier') + assert.match(drawn, /The Chief remembers the Project\./) + // That answer is this open's answer: the poll hands it back and never reads the dossier again. + await tick(3) + assert.equal(count('project-team'), 2, 'once an answer landed later polls do not read the dossier again') + assert.equal(count('project-board'), 5, 'while the board itself is re-read on every tick — the open and the four ticks') +}) + test('#1814: a failing poll keeps the board the last good read drew', async () => { resetClock() win.location.hash = '#project' diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 4b96fc1c2..4d70cf3c7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -326,7 +326,10 @@ keeps the dossier on the same read as the rows, so the board and the panel canno "my agent" row uses the same panel. Since #1900 that one answer is the open's, not the poll's: the five-second refresh hands it back instead of asking `project-team` again, because the host builds it by reading persona and memory files, and a refused dossier leaves the rows, the done group and the tasks -on screen with an empty team block instead of the empty-Project answer. A role-pool entry draws none: since #1892 it is plain `@id` +on screen with an empty team block instead of the empty-Project answer. Since #1910 that block is drawn +but not remembered: a refused read returns `teamFresh` false, the overlay keeps no answer for the poll +to hand back, and the next tick asks `project-team` again until an answer lands — a real empty team is +fresh and frozen like any other answer, so "no answer yet" never becomes "the team is empty". A role-pool entry draws none: since #1892 it is plain `@id` beside its rank, because `project-team` answers rows keyed by the Project's own Bots and the old control asked for a row the host never sent. The entry in the owner's own Bot chat is `ProjectAgentEntry` in the header: the roster already carries `bot.projectId`, the entry reads @@ -364,8 +367,9 @@ against. Since #1814 those four repeat every five seconds while the pane is open waited and whether its lease is live are computed by the host at read time: a lease that expires during the visit turns the card `stalled` on its own instead of freezing the board. The first read failing answers the empty state as it did; a poll that fails keeps the board the last good read drew. The -dossier is not among the four (#1900): it is read once when the pane opens and again on the next open, -not on a tick. Polling +dossier is not among the four (#1900): it is read when the pane opens and again on the next open, +not on a healthy tick — since #1910 the ticks after a refused open read do ask again, and the answer +that lands is the one frozen for the rest of the open. Polling pauses while a row's own board is open (the hash no longer names this Project) and while the tab is hidden, and a read that lands after the pane closed is dropped. Tests: `core/test/project-board-view.test.js`, `core/test/project-board.test.js`, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ded5eca38..0ecfa47d9 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -9032,6 +9032,10 @@ diff --git a/client.js b/client.js + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); ++} ++function busyMembers(room2, work, roster) { ++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); ++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); } -function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { - if (whole && bots.length === 1) { @@ -9057,10 +9061,6 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(BotMark, { bot, size: 16 }), - bot.name - )); -+function busyMembers(room2, work, roster) { -+ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); -+ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); -+} +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); @@ -9859,13 +9859,13 @@ diff --git a/client.js b/client.js + const questionId = card.task?.inputRequest?.id; + if (text2 === "" || typeof questionId !== "string") return void 0; + return Promise.resolve(actions.answerTaskInput(card.task.id, questionId, text2)); - } ++ } + if (card?.kind === "task" && card.status === "result") { + if (choice === "accept") return Promise.resolve(actions.acceptTaskResult(card.task.id)); + const note = choice !== null && typeof choice === "object" ? String(choice.note ?? "").trim() : ""; + if (note === "") return void 0; + return Promise.resolve(actions.sendBackTask(card.task.id, note)); -+ } + } + if (card?.kind === "approval" && card.approval?.answerable === true) { + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } @@ -12247,7 +12247,7 @@ diff --git a/client.js b/client.js setOpen(false); }; const onKey = (event) => { -@@ -5097,412 +5097,1334 @@ function UpdateButton({ useUpdate, actions }) { +@@ -5097,412 +5097,1341 @@ function UpdateButton({ useUpdate, actions }) { window.removeEventListener("keydown", onKey, true); }; }, [open]); @@ -13495,8 +13495,7 @@ diff --git a/client.js b/client.js + { className: "bt-project-actions" }, + (0, import_react19.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")), + (0, import_react19.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members")) - ), -- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( ++ ), + (0, import_react19.createElement)(ProjectMetrics, { view: view.metrics }), + (0, import_react19.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }), + (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }), @@ -13532,14 +13531,7 @@ diff --git a/client.js b/client.js + { className: "bt-project-panel", "data-panel": "project-settings" }, + (0, import_react19.createElement)("h3", null, t("Project settings")), + (0, import_react19.createElement)( - "div", -- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, -- groups.map((group, index) => (0, import_react8.createElement)( -- import_react8.Fragment, -- { key: index }, -- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { -- key: label, ++ "div", + { className: "bt-project-field" }, + (0, import_react19.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")), + (0, import_react19.createElement)( @@ -13555,9 +13547,17 @@ diff --git a/client.js b/client.js + (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")), + (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind")) + ) -+ ), + ), +- open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( + (0, import_react19.createElement)( -+ "div", + "div", +- { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, +- groups.map((group, index) => (0, import_react8.createElement)( +- import_react8.Fragment, +- { key: index }, +- index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, +- group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { +- key: label, + { className: "bt-project-field" }, + (0, import_react19.createElement)("label", null, t("Project Assistant")), + (0, import_react19.createElement)( @@ -13675,13 +13675,19 @@ diff --git a/client.js b/client.js + const listing = await read("projects"); + const projects = Array.isArray(listing?.projects) ? listing.projects : []; + const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted); -+ if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } }; ++ if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true }; + const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone; -+ const [record, board, taskList, team] = await Promise.all([ ++ const [record, board, taskList, dossier] = await Promise.all([ + read("project-read", { id: project.id }), + read("project-board", { id: project.id, timeZone: zone }), + read("tasks", {}), -+ knownTeam !== void 0 ? knownTeam : read("project-team", { id: project.id }).catch(() => null) ++ // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for ++ // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached ++ // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. ++ knownTeam !== void 0 ? { answer: knownTeam, fresh: true } : read("project-team", { id: project.id }).then( ++ (answer) => ({ answer, fresh: answer !== null && typeof answer === "object" }), ++ () => ({ answer: null, fresh: false }) ++ ) + ]); + const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? []; + const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }; @@ -13698,7 +13704,8 @@ diff --git a/client.js b/client.js + meetings: board?.meetings, + queues, + metrics: board?.metrics ?? null, -+ team: team !== null && typeof team === "object" ? team : { rows: [] } ++ team: dossier.answer !== null && typeof dossier.answer === "object" ? dossier.answer : { rows: [] }, ++ teamFresh: dossier.fresh + }; +} +var PROJECT_BOARD_POLL_MS = 5e3; @@ -13763,7 +13770,7 @@ diff --git a/client.js b/client.js + try { + const next = await loadBoard(); + if (!gone) { -+ team = next.team; ++ if (next.teamFresh !== false) team = next.team; + setState(next); + } + } catch { diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 98dc3ade6..c1e589249 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -869,19 +869,29 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) { // answer the poll hands back — `knownTeam` is what the open read — because the host builds it by // reading persona and memory files, and a five-second refresh must not pay that again. A refusal // leaves the rest of the board standing, so a dossier problem is never the empty-Project answer. +// #1910: a refusal is "no answer yet", not an empty team. `teamFresh` is false exactly when the +// dossier could not be read, so the caller keeps no answer to hand back and the next tick asks again. export async function readProjectBoard(read, wanted, knownTeam) { const listing = await read('projects') const projects = Array.isArray(listing?.projects) ? listing.projects : [] const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted) - if (project === undefined) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } } + if (project === undefined) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] }, teamFresh: true } const zone = typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function' ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone - const [record, board, taskList, team] = await Promise.all([ + const [record, board, taskList, dossier] = await Promise.all([ read('project-read', { id: project.id }), read('project-board', { id: project.id, timeZone: zone }), read('tasks', {}), - knownTeam !== undefined ? knownTeam : read('project-team', { id: project.id }).catch(() => null), + // #1910: #1902 turned this failure into an ordinary empty team, which the poll then served for + // the rest of the open. Keep the two answers apart: a refusal is not fresh, so nothing is cached + // and the next tick asks again, while a real `{ rows: [] }` answer is fresh and frozen as usual. + knownTeam !== undefined + ? { answer: knownTeam, fresh: true } + : read('project-team', { id: project.id }).then( + answer => ({ answer, fresh: answer !== null && typeof answer === 'object' }), + () => ({ answer: null, fresh: false }), + ), ]) const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? []) // The groups are the same read as the rows, so the header cannot disagree with the cards. @@ -894,7 +904,8 @@ export async function readProjectBoard(read, wanted, knownTeam) { return { project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null, - team: team !== null && typeof team === 'object' ? team : { rows: [] }, + team: dossier.answer !== null && typeof dossier.answer === 'object' ? dossier.answer : { rows: [] }, + teamFresh: dossier.fresh, } } @@ -956,12 +967,17 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) { // and the dossier comes from the single `project-team` call that read makes. #1900: that call is // this open's, not the poll's — `team` is handed back on every later read, so a tick re-reads // only the three answers that change and the dossier is read again on the next board open. + // #1910: only an answer is kept. A refused read is drawn as the empty team it always was, but + // `team` stays undefined, so the next tick asks `project-team` again instead of serving it. let team const loadBoard = () => readProjectBoard(read, wanted, team) const refresh = async (first) => { try { const next = await loadBoard() - if (!gone) { team = next.team; setState(next) } + if (!gone) { + if (next.teamFresh !== false) team = next.team + setState(next) + } } catch { // The first read is the page's answer, so it takes the empty state it always took. A poll // that fails keeps the board the last good read drew (#1814): a board that vanishes under diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js index 4542a3c62..2b090f827 100644 --- a/patches/ds-bot/files/test/project-board-view.test.js +++ b/patches/ds-bot/files/test/project-board-view.test.js @@ -424,6 +424,54 @@ test('#1900: an open board reads the dossier once, never on a tick', async () => assert.equal(count('project-board'), 4, 'while the board itself is re-read on every tick') }) +// #1910: a refused dossier read at open is "no answer yet", not "the team is empty". #1902 made the +// dossier the open's one read and let that read swallow its failure into an ordinary empty team, so +// one transient refusal became the answer every later poll handed back: the block stayed empty until +// the board was closed and opened again, where before (#1814) the next tick healed it. The refusal +// must leave no cached answer, so the next poll asks again; an answer that lands is this open's and +// later polls do not read the dossier again. +test('#1910: a refused dossier read at open is asked again on the next tick, then frozen', async () => { + resetClock() + win.location.hash = '#project' + const calls = [] + let dossierReads = 0 + const teamAnswer = { rows: [{ botId: 'bot-chief', name: 'Project Chief', persona: 'You are the Project Chief of FlyBot.', memory: { head: 'The Chief remembers the Project.', topics: ['work'] }, skills: ['ship-it'] }] } + const call = async (name) => { + calls.push(name) + if (name === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] } + if (name === 'project-read') { + return { project: { id: 'project-1', name: 'FlyBot', chief: { botId: 'bot-chief' }, assistant: { botId: 'bot-assistant' }, roles: { maintainers: ['bot-dev'], members: [] } }, canManage: false } + } + if (name === 'project-board') return { tasks: rows, done: [], waiting: [], inflight: [], blocked: [], counts: null } + if (name === 'tasks') return [{ id: 'task-b1', title: 'Ship the poll' }] + if (name === 'project-team') { + dossierReads += 1 + if (dossierReads === 1) throw new Error('the persona read timed out') + return teamAnswer + } + throw new Error(`unexpected endpoint ${name}`) + } + const count = (endpoint) => calls.filter(name => name === endpoint).length + const { tree, redraw } = await mount({ actions: {}, call }) + assert.equal(count('project-team'), 1, 'the open asks the dossier once') + // The refusal is drawn as the empty team it was normalised to, not as a failed board. + const control = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief') + assert.ok(control, 'the board still draws its team rows from the record') + control.props.onClick() + assert.doesNotMatch(all(redraw()).map(textOf).join(' | '), /You are the Project Chief of FlyBot\./, 'a refusal is no answer yet, so the panel does not invent a dossier') + // The refusal is not the answer: the next poll asks again, and the row it gets fills the block. + // Both are read before the assertion so one red run shows both numbers. + await tick() + const drawn = all(redraw()).map(textOf).join(' | ') + const afterTick = { calls: count('project-team'), filled: /You are the Project Chief of FlyBot\./.test(drawn) } + assert.deepEqual(afterTick, { calls: 2, filled: true }, 'the next tick asks again, and the row it gets fills the dossier') + assert.match(drawn, /The Chief remembers the Project\./) + // That answer is this open's answer: the poll hands it back and never reads the dossier again. + await tick(3) + assert.equal(count('project-team'), 2, 'once an answer landed later polls do not read the dossier again') + assert.equal(count('project-board'), 5, 'while the board itself is re-read on every tick — the open and the four ticks') +}) + test('#1814: a failing poll keeps the board the last good read drew', async () => { resetClock() win.location.hash = '#project'