Skip to content
Closed
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/1900-team-dossier-off-poll.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Project board reads the team dossier (personas, memory heads, skills) once when the board opens instead of on every five-second refresh, so an open board no longer pulls about 16 KB of unchanged data per tick ([#1900](https://github.com/ThinkFlowLab/FlyBot/issues/1900)). A dossier read that fails now leaves the board drawn with an empty team area instead of falling back to the empty-project state.
13 changes: 10 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -11260,17 +11260,19 @@ function ProjectMembersPanel({ project, rows, onClose } = {}) {
)
);
}
async function readProjectBoard(read, wanted) {
async function readProjectBoard(read, wanted, cached) {
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: [] } };
const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone;
const seeded = cached !== null && typeof cached === "object" && cached.team !== void 0;
const teamAnswer = seeded ? Promise.resolve(cached.team) : Promise.resolve().then(() => read("project-team", { id: project.id })).catch(() => null);
const [record, board, taskList, team] = await Promise.all([
read("project-read", { id: project.id }),
read("project-board", { id: project.id, timeZone: zone }),
read("tasks", {}),
read("project-team", { id: project.id })
teamAnswer
]);
const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? [];
const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null };
Expand Down Expand Up @@ -11328,7 +11330,12 @@ function ProjectBoardOverlay({ actions, call: request } = {}) {
}
const wanted = hash.slice("#project".length).replace(/^=/, "");
let gone = false;
const loadBoard = () => readProjectBoard(read, wanted);
let teamAnswer;
const loadBoard = async () => {
const next = await readProjectBoard(read, wanted, teamAnswer === void 0 ? void 0 : { team: teamAnswer });
if (next.project !== null) teamAnswer = next.team;
return next;
};
const refresh = async (first) => {
try {
const next = await loadBoard();
Expand Down
29 changes: 26 additions & 3 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -866,19 +866,32 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) {

// #1864: one read for the whole board, exported so a test can prove the dossier comes from a single
// `project-team` call. `read` is the channel seam (`call` in the app).
export async function readProjectBoard(read, wanted) {
// #1900: the dossier is the one answer in that read which does not change on a five-second clock, so
// the caller hands back the answer it already has (`cached.team`) and the read skips the call: the
// board reads it once when it opens and every poll after that draws that same answer. The other
// three channels are read every time — they are the reason a poll exists.
export async function readProjectBoard(read, wanted, cached) {
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: [] } }
const zone = typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function'
? undefined
: Intl.DateTimeFormat().resolvedOptions().timeZone
const seeded = cached !== null && typeof cached === 'object' && cached.team !== undefined
// #1900: a dossier that throws is the team area's loss, not the board's. The rejection used to
// reject the whole `Promise.all`, and the first paint turned that into the empty state — a broken
// persona file made a Project that exists read as "No Project yet", which is the one answer
// `:873` reserves for a Project that really is gone. Rows, `done`, tasks and queues still draw;
// only `team` gives up, as `{ rows: [] }` below.
const teamAnswer = seeded
? Promise.resolve(cached.team)
: Promise.resolve().then(() => read('project-team', { id: project.id })).catch(() => null)
const [record, board, taskList, team] = await Promise.all([
read('project-read', { id: project.id }),
read('project-board', { id: project.id, timeZone: zone }),
read('tasks', {}),
read('project-team', { id: project.id }),
teamAnswer,
])
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 Down Expand Up @@ -951,7 +964,17 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
let gone = false
// #1864: the board, its record, its rows, the task texts and the team dossier are one read path,
// and the dossier comes from the single `project-team` call that read makes.
const loadBoard = () => readProjectBoard(read, wanted)
// #1900: that one dossier answer is held for as long as this board is open. The effect re-runs
// per `#project` entry, so re-opening the board reads the dossier again and a persona or memory
// edit lands then; the five-second poll only draws the answer the open read already took.
let teamAnswer
const loadBoard = async () => {
const next = await readProjectBoard(read, wanted, teamAnswer === undefined ? undefined : { team: teamAnswer })
// A board with no Project took no dossier read at all, so it seeds nothing: the first poll
// that does find the Project reads the dossier itself instead of caching the empty answer.
if (next.project !== null) teamAnswer = next.team
return next
}
const refresh = async (first) => {
try {
const next = await loadBoard()
Expand Down
47 changes: 47 additions & 0 deletions core/test/project-board-panels.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,53 @@ test('the board reads the dossier from exactly one project-team call', async ()
assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief'])
})

// #1900: the dossier only changes when someone edits a persona or a memory file, so it is read
// once, when the board opens, and the five-second poll (#1814/#1824) draws that answer instead of
// asking again. The poll was re-reading the whole team dossier — host side a `memoryDigest` per row,
// ~16 KB every 5 s — to receive an answer that had not changed.
test('#1900: the five-second poll reuses the dossier answer the open read took', async () => {
resetSlots()
const calls = []
const read = async (endpoint) => {
calls.push(endpoint)
if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] }
if (endpoint === 'project-read') return { project: projectRecord(), canManage: true }
if (endpoint === 'project-board') return boardAnswer()
if (endpoint === 'tasks') return []
if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] }
throw new Error(`unexpected endpoint ${endpoint}`)
}
const count = () => calls.filter(endpoint => endpoint === 'project-team').length
const opened = await readProjectBoard(read, 'project-1')
assert.equal(count(), 1, 'opening the board reads the dossier once')
const polled = await readProjectBoard(read, 'project-1', { team: opened.team })
assert.equal(count(), 1, 'the poll does not ask for the dossier again')
assert.equal(polled.team.rows.length, 3, 'the poll keeps drawing the one answer')
assert.deepEqual(polled.team.rows.map(row => row?.botId), opened.team.rows.map(row => row?.botId))
})

// #1900: a dossier that fails is not the board failing. It keeps its rows, its `done` group and its
// tasks, and the team area draws empty — the empty-project answer (`project-board.js:873`) stays
// reserved for a Project that really is absent, so a broken dossier cannot paint "No Project yet".
test('#1900: a dossier read that throws leaves the board drawn and the team empty', async () => {
resetSlots()
const read = async (endpoint) => {
if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] }
if (endpoint === 'project-read') return { project: projectRecord(), canManage: true }
if (endpoint === 'project-board') return { ...boardAnswer(), tasks: [{ id: 'row-1' }], done: [{ id: 'done-1' }] }
if (endpoint === 'tasks') return [{ id: 'task-1' }]
if (endpoint === 'project-team') throw new Error('the dossier is unreadable this tick')
throw new Error(`unexpected endpoint ${endpoint}`)
}
const state = await readProjectBoard(read, 'project-1')
assert.equal(state.project?.id, 'project-1', 'the Project still draws: no empty state')
assert.equal(state.rows.length, 1)
assert.equal(state.done.length, 1)
assert.equal(state.tasks.length, 1)
assert.deepEqual(Object.keys(state.team).sort(), ['rows'], 'the team area is the one thing that gives up')
assert.equal(state.team.rows.length, 0)
})

// #1892: the role pool is a rank list, not a Bot the board holds a dossier answer for.
// `project-team` answers rows keyed by the Project's own Bots, so a pool click asked for a row the
// host never sent and drew an empty panel. A pool entry now reads `@id` beside its rank; the Chief
Expand Down
8 changes: 5 additions & 3 deletions core/test/project-board-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,9 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
assert.match(textIn(tree), /Dev4 · stalled/, 'the board draws what the first read answered')
const afterFirst = count.calls
await tick()
assert.equal(count.calls, afterFirst + 5, 'an open board reads again on the next tick')
// #1900: a tick is the four reads that change — the dossier was read once above and is drawn from
// that answer, so it is not part of a tick's count.
assert.equal(count.calls, afterFirst + 4, 'an open board reads the four that change on the next tick, not the dossier')
// A row opens its own board: the hash names the task and this board stops drawing, so a tick that
// lands before the cleanup must not read. This segment pins the unmount, not the predicate.
const afterTick = count.calls
Expand All @@ -363,15 +365,15 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
// Closing that board puts this one back in front: the next tick reads again.
win.location.hash = '#project'
await tick()
assert.equal(count.calls, afterTick + 5, 'closing the row’s board resumes the poll')
assert.equal(count.calls, afterTick + 4, 'closing the row’s board resumes the poll')
// A hidden tab reads nothing either — this is the segment the named predicate is pinned by.
const afterResume = count.calls
doc.visibilityState = 'hidden'
await tick(2)
assert.equal(count.calls, afterResume, 'no read in a hidden tab')
doc.visibilityState = 'visible'
await tick()
assert.equal(count.calls, afterResume + 5, 'a visible tab reads again on the next tick')
assert.equal(count.calls, afterResume + 4, 'a visible tab reads again on the next tick')
// Close the board: the timer goes with it, so a tick afterwards reads nothing at all.
const afterVisible = count.calls
for (const off of cleanups) off()
Expand Down
11 changes: 9 additions & 2 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,13 @@ instructions, role and model), **Memory** (the `MEMORY.md` head and the topic na
(the attached `skill` capability names). The panel never re-fetches per section, and a `null` row —
the host's answer for a deleted Bot — shows *Not set* with nothing invented. `readProjectBoard`
keeps the dossier on the same read as the rows, so the board and the panel cannot disagree, and the
"my agent" row uses the same panel. A role-pool entry draws none: since #1892 it is plain `@id`
"my agent" row uses the same panel. That read is taken once, when the board opens (#1900): the export
takes the previous answer back as `cached.team` and skips the `project-team` call when it has one, so
the five-second refresh draws the dossier the open read took instead of re-reading every persona and
memory head on a timer — the answer only changes when someone edits one of those files, and a re-open
reads it again. A dossier that throws is non-fatal: rows, `done`, tasks and queues still draw, `team`
becomes `{ rows: [] }`, and the empty-project answer stays reserved for a Project that is really
absent. 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 @@ -357,7 +363,8 @@ carried in and has no board of its own). A row with a board is a button: it writ
group and clears the hash, so a reload does not reopen the same board. A row without a board (or without a
task record) is not a button — `role` alone opens nothing. Opening the pane is four host reads, never a
per-Bot read: `projects`, `project-read`, `project-board`, and the `tasks` list the rows are joined
against. Since #1814 they repeat every five seconds while the pane is open, because what a card has
against; since #1900 the fourth, `project-team`, is read once at that open and the poll below draws
that answer instead. The other three repeat every five seconds while the pane is open, because what a card has
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. Polling
Expand Down
Loading
Loading