Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1910-team-read-retry.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
17 changes: 12 additions & 5 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 21 additions & 5 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions core/test/project-board-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 7 additions & 3 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand Down
Loading
Loading