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/1900-team-dossier-off-the-poll.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Project board: the five-second refresh no longer re-reads the team dossier — `project-team` is read once when the board opens and handed back to each poll, so an open board stops re-reading persona and memory files (about 192 KB a minute) for an answer only an edit changes; a dossier that fails to read now leaves the rows, the done group and the tasks on screen with an empty team block instead of showing "No Project yet" ([#1900](https://github.com/ThinkFlowLab/FlyBot/issues/1900)).
12 changes: 8 additions & 4 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -11260,7 +11260,7 @@ function ProjectMembersPanel({ project, rows, onClose } = {}) {
)
);
}
async function readProjectBoard(read, wanted) {
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);
Expand All @@ -11270,7 +11270,7 @@ async function readProjectBoard(read, wanted) {
read("project-read", { id: project.id }),
read("project-board", { id: project.id, timeZone: zone }),
read("tasks", {}),
read("project-team", { id: project.id })
knownTeam !== void 0 ? knownTeam : read("project-team", { id: project.id }).catch(() => null)
]);
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,11 +11328,15 @@ function ProjectBoardOverlay({ actions, call: request } = {}) {
}
const wanted = hash.slice("#project".length).replace(/^=/, "");
let gone = false;
const loadBoard = () => readProjectBoard(read, wanted);
let team;
const loadBoard = () => readProjectBoard(read, wanted, team);
const refresh = async (first) => {
try {
const next = await loadBoard();
if (!gone) setState(next);
if (!gone) {
team = next.team;
setState(next);
}
} catch {
if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] });
}
Expand Down
18 changes: 12 additions & 6 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -865,8 +865,11 @@ 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) {
// `project-team` call. `read` is the channel seam (`call` in the app). #1900: the dossier is the one
// 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.
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)
Expand All @@ -878,7 +881,7 @@ export async function readProjectBoard(read, wanted) {
read('project-read', { id: project.id }),
read('project-board', { id: project.id, timeZone: zone }),
read('tasks', {}),
read('project-team', { id: project.id }),
knownTeam !== undefined ? knownTeam : read('project-team', { id: project.id }).catch(() => null),
])
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 @@ -950,12 +953,15 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
const wanted = hash.slice('#project'.length).replace(/^=/, '')
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)
// 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.
let team
const loadBoard = () => readProjectBoard(read, wanted, team)
const refresh = async (first) => {
try {
const next = await loadBoard()
if (!gone) setState(next)
if (!gone) { 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
62 changes: 62 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,68 @@ test('the board reads the dossier from exactly one project-team call', async ()
assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief'])
})

// #1900: the board re-reads itself every five seconds (#1814), and the team dossier rode that same
// read, so an open board re-read persona and memory files — up to ~192 KB a minute — for an answer
// only an edit changes. The dossier is read when the board opens; the poll hands back the answer it
// already has, and `project-team` is not asked again.
test('#1900: a poll re-reads the board and the task texts, not the team dossier', 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(), tasks: rows, done: [{ id: 'task-done', column: 'done', title: 'Finished' }] }
if (endpoint === 'tasks') return [{ id: 'task-1', title: 'Ship the poll' }]
if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] }
throw new Error(`unexpected endpoint ${endpoint}`)
}
const open = await readProjectBoard(read, 'project-1')
const poll = await readProjectBoard(read, 'project-1', open.team)
assert.equal(calls.filter(endpoint => endpoint === 'project-team').length, 1, 'the open reads the dossier once, and the poll does not ask again')
assert.equal(calls.filter(endpoint => endpoint === 'project-board').length, 2, 'the poll still re-reads the board, which is what it is for')
assert.deepEqual(poll.rows.map(row => row.id), ['task-1'], 'the poll keeps the rows it read')
assert.deepEqual(poll.done.map(row => row.id), ['task-done'], 'and the done group')
assert.deepEqual(poll.tasks.map(task => task.id), ['task-1'], 'and the task texts')
assert.deepEqual(poll.team.rows.map(row => row?.botId ?? null), ['bot-chief', null, 'bot-dev'], 'the one dossier answer survives whole, null row included')

// The block still draws its three rows, and the panel its three sections, from that one answer.
const tree = render(ProjectBoardPane, { project: poll.project, rows: poll.rows, done: poll.done, tasks: poll.tasks, team: poll.team, canManage: true })
assert.deepEqual(all(tree).filter(node => node.props?.['data-team-role'] !== undefined).map(node => node.props['data-team-role']), ['chief', 'agent', 'pool'])
const drawn = text(render(ProjectDossierPanel, { row: poll.team.rows[0], label: 'Project Chief' }))
assert.match(drawn, /Persona/, 'Persona still draws from the one answer')
assert.match(drawn, /Project Chief of FlyBot/)
assert.match(drawn, /Memory/, 'Memory still draws from the one answer')
assert.match(drawn, /The Chief remembers the Project\./)
assert.match(drawn, /Skills/, 'Skills still draw from the one answer')
assert.match(drawn, /ship-it/)
})

// #1900: the same read path must not make a failing dossier fatal. The board's first paint used to
// turn a thrown `project-team` into "No Project yet — create one.", which is the answer reserved for
// a Project that really is absent; the rows, the done group and the tasks are a different answer.
test('#1900: a refused dossier still draws rows, done and tasks, not the empty Project', async () => {
resetSlots()
const read = async (endpoint) => {
if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot' }] }
if (endpoint === 'project-read') return { project: projectRecord(), canManage: true }
if (endpoint === 'project-board') return { ...boardAnswer(), tasks: rows, done: [{ id: 'task-done', column: 'done', title: 'Finished' }] }
if (endpoint === 'tasks') return [{ id: 'task-1', title: 'Ship the poll' }]
if (endpoint === 'project-team') throw new Error('project-team: cannot read the memory of bot-chief')
throw new Error(`unexpected endpoint ${endpoint}`)
}
const state = await readProjectBoard(read, 'project-1')
assert.notEqual(state.project, null, 'a refused dossier is not the empty Project')
assert.equal(state.project.id, 'project-1')
assert.deepEqual(state.rows.map(row => row.id), ['task-1'], 'the rows still come back')
assert.deepEqual(state.done.map(row => row.id), ['task-done'], 'and the done group')
assert.deepEqual(state.tasks.map(task => task.id), ['task-1'], 'and the task texts')
assert.deepEqual(Array.from(state.team.rows), [], 'the team block reads empty instead of failing the page')
const drawn = all(render(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, tasks: state.tasks, team: state.team, canManage: true })).map(text).join(' | ')
assert.match(drawn, /Ship the poll/, 'the board the reader gets still carries the tasks')
assert.doesNotMatch(drawn, /No Project yet/, 'and is not the create-a-Project page')
})

// #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
50 changes: 47 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 four calls now — the listing, the record, the board and the task texts. The
// fifth, the team dossier, belongs to the open and is not re-read (see the #1900 test below).
assert.equal(count.calls, afterFirst + 4, 'an open board reads again on the next tick, without 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 All @@ -380,6 +382,48 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
assert.equal(count.calls, afterVisible, 'nothing reads after the board closed')
})

// #1900: the dossier is `project-team`, which the host answers by reading persona and memory files
// and resending their heads — up to ~16 KB every 5 s, an answer that only an edit changes. The poll
// is wanted (#1814); the dossier riding on it is not. One open reads it, and every tick after that
// reads only what the board is polling for.
test('#1900: an open board reads the dossier once, never on a tick', async () => {
resetClock()
win.location.hash = '#project'
const calls = []
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') {
return { 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'] }] }
}
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, 'opening the board reads the dossier once')
assert.equal(count('project-board'), 1, 'and the board once')
// The one answer still feeds the row and the panel the reader opens from it.
const control = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief')
assert.ok(control, 'the Chief row carries its Dossier control from that answer')
control.props.onClick()
const drawn = all(redraw()).map(textOf).join(' | ')
assert.match(drawn, /Persona/)
assert.match(drawn, /You are the Project Chief of FlyBot\./)
assert.match(drawn, /Memory/)
assert.match(drawn, /The Chief remembers the Project\./)
assert.match(drawn, /Skills/)
assert.match(drawn, /ship-it/)
// Three ticks: the board is re-read each time, the dossier is not read again at all.
await tick(3)
assert.equal(count('project-team'), 1, 'three polls later the dossier has not been read again')
assert.equal(count('project-board'), 4, 'while the board itself is re-read on every tick')
})

test('#1814: a failing poll keeps the board the last good read drew', async () => {
resetClock()
win.location.hash = '#project'
Expand Down
11 changes: 8 additions & 3 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,10 @@ 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. 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`
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,10 +360,12 @@ 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 #1814 those four 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
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
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