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/2029-blocked-card-age.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Project board's Blocked group now says how long a card has been stuck, and lists the card stuck longest first ([#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029)): the row kept the card's own reason and read the same whether the card had been blocked for three days or three minutes, and the group was unsorted. The host now answers `since` / `waitedMs` from the question's own `requestedAt` stamp — a card whose ask wrote no stamp shows no duration rather than an invented one — and the board draws `reason · 2d`, ordering the group oldest first the way Waiting already is.
4 changes: 2 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -10654,7 +10654,7 @@ function projectBoardView({ project, rows, done, canManage, tasks, decisions, me
});
const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }));
const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" }));
const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null }));
const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }));
const queueGroups = [
{ group: "waiting", label: "Waiting", items: waiting },
{ group: "inflight", label: "In flight", items: inflight },
Expand Down Expand Up @@ -10849,7 +10849,7 @@ function ProjectQueueGroups({ view, onOpen }) {
(0, import_react19.createElement)("h4", null, t(entry.label)),
entry.items.map((row) => {
const open = row.board === null ? void 0 : () => onOpen?.(row.board);
const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason);
const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`);
return (0, import_react19.createElement)(
"div",
{
Expand Down
8 changes: 6 additions & 2 deletions core/src/client/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@ export function projectBoardView({ project, rows, done, canManage, tasks, decisi
})
const waiting = queueItems(queues?.waiting, item => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }))
const inflight = queueItems(queues?.inflight, item => ({ holder: text(item.holder) || null, state: item.stalled === true ? 'stalled' : 'running' }))
const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null }))
// #2029: a blocked row carries the age the host measured beside its reason, the same way the
// waiting row does — the view never measures it here.
const blocked = queueItems(queues?.blocked, item => ({ reason: text(item.reason) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }))
const queueGroups = [
{ group: 'waiting', label: 'Waiting', items: waiting },
{ group: 'inflight', label: 'In flight', items: inflight },
Expand Down Expand Up @@ -503,7 +505,9 @@ function ProjectQueueGroups({ view, onOpen }) {
? h('span', { className: 'bt-project-wait' }, [row.role === null ? null : h('span', { key: 'role', className: 'bt-project-role' }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`])
: entry.group === 'inflight'
? h('span', { className: 'bt-project-state' }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`)
: h('span', { className: 'bt-project-reason' }, row.reason)
// #2029: a blocked row says why and, when the host measured it, how long — `reason · 2d`.
// With no stamp it keeps the reason alone; an invented age would read as a wait nobody measured.
: h('span', { className: 'bt-project-reason' }, row.reason === null ? null : waitedLabel(row.waitedMs) === null ? row.reason : `${row.reason} · ${waitedLabel(row.waitedMs)}`)
return h('div', {
key: row.key,
className: 'bt-project-row',
Expand Down
23 changes: 17 additions & 6 deletions core/src/host/project-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,22 @@ export function inflightOf(task) {
return { id: String(task.id), holder }
}

// Blocked: the reason is the card's own line, never a second copy.
export function blockedOf(task) {
// Blocked: the reason is the card's own line, never a second copy. #2029 adds the age the ask
// already stamped (`inputRequest.requestedAt`, `tasks.js`) — a card blocked for three days and one
// blocked for three minutes must not read alike. A card with no stamp has no age: the card's own
// `createdAt`/`updatedAt` are not the question's clock, and an invented wait is worse than none.
export function blockedOf(task, now) {
if (task === null || typeof task !== 'object' || task.status !== 'blocked') return null
const reason = oneLine(task.inputRequest?.question) || oneLine(task.queueReason) || oneLine(task.title) || ''
return { id: String(task.id), reason }
const since = stampOf(task.inputRequest?.requestedAt)
return { id: String(task.id), reason, since, waitedMs: since === null ? null : Math.max(0, now - since) }
}

// Oldest wait first: the card that has waited longest is the one a person wants next. Shared by the
// Waiting and the Blocked groups so the two read in the same order; a card the host cannot date
// sorts last rather than first.
const oldestFirst = (a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id)

// A decision is read for its title line only: the head of the file is enough for a heading, and
// the body stays where it is. The bound also keeps a megabyte file from being read to answer one
// line; the hash the reader needs comes from `listProjectState`, not from this read.
Expand Down Expand Up @@ -504,11 +513,13 @@ export function install(rt) {
const live = lease !== null && Number.isFinite(lease.expiresAt) && lease.expiresAt > now
inflight.push({ ...flight, lease: lease === null ? 'none' : live ? 'live' : 'expired', stalled: lease !== null && !live })
}
const block = blockedOf(task)
const block = blockedOf(task, now)
if (block !== null) blocked.push(block)
}
// Oldest wait first: the card that has waited longest is the one a person wants next.
waiting.sort((a, b) => (a.since ?? Infinity) - (b.since ?? Infinity) || a.id.localeCompare(b.id))
// Oldest wait first, in both groups: the card that has waited longest is the one a person wants
// next, and a blocked card that has been stuck for days belongs above one stuck for minutes.
waiting.sort(oldestFirst)
blocked.sort(oldestFirst)
return { waiting, inflight, blocked, counts: { waiting: waiting.length, inflight: inflight.length, blocked: blocked.length, done } }
}
// #1828: the endpoint the host calls for a role switch. It answers the policy's own decision;
Expand Down
18 changes: 18 additions & 0 deletions core/test/project-board-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,24 @@ test('#1768: the pane draws each group and a blocked card shows its reason', ()
const ids = nodes.filter(node => node && node.props && node.props['data-task-id']).map(node => node.props['data-task-id'])
assert.ok(ids.includes('task-w1') && ids.includes('task-b1') && ids.includes('task-g1'), JSON.stringify(ids))
})

// #2029: the blocked row said why and nothing about how long, so a card stuck three days and one
// stuck three minutes drew the same line. The host now hands the age over as `waitedMs`; the row
// draws it only when the host measured one, and a card with no stamp keeps the reason alone.
test('#2029: a blocked row says how long it has been stuck, and nothing when the host has no stamp', () => {
const aged = { ...queues, blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?', since: 1, waitedMs: 172800000 }] }
const view = plain(projectBoardView({ project: { id: 'project-1', name: 'FlyBot' }, rows, tasks: [], queues: aged }))
assert.equal(view.queues[2].items[0].waitedMs, 172800000)
const nodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: aged }))
const reason = nodes.find(node => node && node.props && node.props.className === 'bt-project-reason')
assert.ok(reason, 'the blocked row draws its reason line')
assert.ok(textOf(reason).endsWith(' · 2d'), textOf(reason))
// No stamp: the line is the reason alone — `0s` would claim a wait nobody measured.
const bare = { ...queues, blocked: [{ id: 'task-g1', reason: 'Which branch should this ship on?', since: null, waitedMs: null }] }
const bareNodes = all(ProjectBoardPane({ project: { id: 'project-1', name: 'FlyBot' }, rows, canManage: false, tasks: [], queues: bare }))
const bareReason = bareNodes.find(node => node && node.props && node.props.className === 'bt-project-reason')
assert.equal(textOf(bareReason), 'Which branch should this ship on?')
})
// #1814: the wait a card shows and whether its lease is live are computed at read time on the host
// (`waitedMs`, `buildQueues`), so a board that reads once freezes: a lease that expires while the
// page is open never turns `stalled` and `3h` stays `3h`. The board polls, and the count below is
Expand Down
44 changes: 44 additions & 0 deletions core/test/project-board.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { test } from 'node:test'
import { createRuntime, provideFlyBot } from '../index.js'
import { blockedOf } from '../src/host/project-board.js'
import { fakeContext } from './fake-ctx.js'

const seed = () => ({
Expand Down Expand Up @@ -496,6 +497,49 @@ test('#1768: a blocked card carries its own reason and another Project is in non
} finally { await stop() }
})

// #2029: the Blocked group answered why and never how long. `inputRequest.requestedAt` is the stamp
// the ask itself wrote (`tasks.js`), so a card's age is read from it — and from nowhere else: a
// fallback to `createdAt` would read as a wait nobody measured.
test('#2029: a blocked card carries the age of its own question', () => {
const NOW = 1_800_000_000_000
const since = NOW - 48 * 3600e3
assert.deepEqual(
blockedOf({ id: 't1', status: 'blocked', inputRequest: { id: 'q', question: 'why', requestedAt: since } }, NOW),
{ id: 't1', reason: 'why', since, waitedMs: 172800000 },
)
})

test('#2029: a blocked card with no requestedAt has no age, invented or otherwise', async () => {
const { rt, stop } = await start(undefined, queueSeed())
try {
// The card's own timestamps are there and a fallback would find them; what is missing is the
// question's stamp, so the card says nothing about how long it has been blocked.
const now = Date.now()
rt.state.tasks['task-blocked'].createdAt = now - 48 * 3600e3
rt.state.tasks['task-blocked'].updatedAt = now - 48 * 3600e3
const queues = await rt.projectBoardQueues('project-p1')
const item = queues.blocked.find(entry => entry.id === 'task-blocked')
assert.equal(item.since, null, 'no stamp, no age')
assert.equal(item.waitedMs, null, 'and no invented duration')
} finally { await stop() }
})

test('#2029: the Blocked group lists the card stuck longest first', async () => {
const team = queueSeed()
const now = Date.now()
team.tasks['task-blocked'].inputRequest.requestedAt = now - 3600e3
team.tasks['task-blocked-old'] = { id: 'task-blocked-old', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'Stuck since Friday', inputRequest: { id: 'q2', question: 'Still stuck?', requestedAt: now - 48 * 3600e3 }, createdAt: 41, updatedAt: 41 }
team.tasks['task-blocked-unstamped'] = { id: 'task-blocked-unstamped', kind: 'group', status: 'blocked', waitingFor: 'input', origin: { roomId: 'r1' }, title: 'No stamp', inputRequest: { id: 'q3', question: 'Since when?' }, createdAt: 42, updatedAt: 42 }
const { rt, stop } = await start(undefined, team)
try {
const queues = await rt.projectBoardQueues('project-p1')
// 48 h before 1 h, and the card the host cannot date sorts last rather than first.
assert.deepEqual(queues.blocked.map(item => item.id), ['task-blocked-old', 'task-blocked', 'task-blocked-unstamped'])
assert.deepEqual(queues.blocked.map(item => item.since), [now - 48 * 3600e3, now - 3600e3, null])
assert.equal(queues.blocked[2].waitedMs, null)
} finally { await stop() }
})

// #2005: every one-line the board derives (`oneLine`) is bounded at 200 UTF-16 units, so a line
// whose 200th unit is the first half of an emoji used to reach the client as an unpaired surrogate
// — the queue reason is the case that reads a field another home supplied.
Expand Down
4 changes: 3 additions & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,9 @@ hidden, and a read that lands after the pane closed is dropped. Tests:

The pane draws the three host groups beside the columns (#1768) — `Waiting` with the role and how long
the card waited, `In flight` with the holder and `running`/`stalled`, `Blocked` with the card's own
reason — and the header counts waiting / in flight / blocked / done. The groups and the counts are the
reason and, when the host measured one, how long it has been stuck (`reason · 2d`, from the row's
`waitedMs`; a card the host could not date keeps the reason alone) — and the header counts waiting /
in flight / blocked / done. The groups and the counts are the
`project-board` answer, not a second read: `waiting`, `inflight`, `blocked` and `counts` ride
along with `tasks` in the same response. A count the host answered as `null` renders as `unknown` and
never as `0`; a group with no items is not drawn, and a board with neither rows nor groups is still the
Expand Down
Loading
Loading