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/2543-timeline-busy-window.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot that was busy across the whole window is no longer left out of the team report as if it had done nothing: a turn that began before the window now reports the share of the window it worked, instead of reading as a Bot with no turns.
2 changes: 1 addition & 1 deletion docs/subsystems/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model
| `plugins/timeline/src/store.js` | The trace: single writer, write-tmp + fsync + rename, capped segments. |
| `plugins/timeline/src/window.js` | Pure read model: records to lanes, turns, tools, arrows, edges, utilization, the token spend per Bot (#357), per-Bot outcomes and recent failures (#471), the open requests waiting longest (#469) and the tools each Bot used (#359). |
| `plugins/timeline/src/health.js` | Team health (#357): pure `buildHealth(window)` turning the window into per-Bot signals and flags. |
| `plugins/timeline/src/digest.js` | Team run report (#582): pure `buildDigest(window)` turning the window into short Markdown, with the count and duration formatting (`formatCount`, `formatMs`). A duration rounds once and carries into the next unit (#761), so the text says `1m` and `2h00m`, never `60s`, `60m` or `1h60m`. A count carries the same way (#1779): 999,950 reads `1.0M`, never `1000.0k`. |
| `plugins/timeline/src/digest.js` | Team run report (#582): pure `buildDigest(window)` turning the window into short Markdown, with the count and duration formatting (`formatCount`, `formatMs`). A duration rounds once and carries into the next unit (#761), so the text says `1m` and `2h00m`, never `60s`, `60m` or `1h60m`. A count carries the same way (#1779): 999,950 reads `1.0M`, never `1000.0k`. `busy` is the share of the window busy, not a count of turns (invariant 5): a Bot whose turn began before the window reports its busy share instead of reading as a turn-less Bot, the same answer `timeline/health` gives over that window. |
| `plugins/timeline/src/alerts.js` | Durable alerts (#584): pure `createAlertTracker()` folding a window across reads, so a condition opens one alert and resolves when it clears. |
| `plugins/timeline/client.js` | Browser entry (`@flybot/timeline`): the 2 s poller exposed as `ctx.flybot.timeline`, the geometry functions (`layoutSwimlanes`, `layoutMap`, `waitRows`, `utilizationRows`) and the panel (swimlanes, who waits on whom, utilization, and **Copy report**), opened from its `flybot.sidebar.section` row, the only entry (not in the + menu, #269). |
| `plugins/timeline/src/sanitize.js` | The cleaners for ids, labels, codes, times, counts. |
Expand Down
9 changes: 8 additions & 1 deletion plugins/timeline/src/digest.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ export function buildDigest(window = {}, { title = 'Team report', now = Date.now
const busyShare = use.windowMs > 0 ? use.busyMs / use.windowMs : 0
const flags = []
if (counted >= ERROR_TURNS_MIN && errorRate >= ERROR_RATE_MIN) flags.push(`${errors} of ${counted} turns failed`)
if (turns > 0 && busyShare >= BUSY_MIN) flags.push(`busy ${percent(busyShare)} of the window`)
// Invariant 5 (docs/subsystems/timeline.md): busy is a share of the window busy, not a count of
// turns, so a turn that began before the window still makes its Bot busy here — the health view
// over the same window already says so, and the two must not disagree.
if (busyShare >= BUSY_MIN) flags.push(`busy ${percent(busyShare)} of the window`)
if (counted === 0) flags.push('no turns')
if (waiting > 0) flags.push(`${waiting} request(s) unanswered`)
return {
Expand Down Expand Up @@ -139,6 +142,10 @@ export function buildDigest(window = {}, { title = 'Team report', now = Date.now
lines.push(`- ${bot.counted} ${turnWord(bot.counted)}${failed}`)
lines.push(`- busy ${percent(bot.busyShare)} of the window`)
lines.push(`- tokens: ${formatCount(bot.tokens.input)} in / ${formatCount(bot.tokens.output)} out / ${formatCount(bot.tokens.cacheRead)} cached`)
} else if (bot.busyShare > 0) {
// `turns` counts only the turns that began in the window; one that began before it keeps the
// Bot busy through it, which invariant 5 calls busy rather than a turn-less Bot.
lines.push(`- busy ${percent(bot.busyShare)} of the window`)
} else {
lines.push('- no turns in the window')
}
Expand Down
22 changes: 22 additions & 0 deletions plugins/timeline/test/digest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildDigest, formatCount, formatMs } from '../src/digest.js'
import { createTimeline, DIGEST_ENDPOINT } from '../src/timeline.js'
import { buildHealth } from '../src/health.js'
import { mockAdapter } from './mock-adapter.js'

const make = (extra = {}) => ({
Expand Down Expand Up @@ -256,3 +257,24 @@ test('#1787: a range inside one local day reads exactly as before', () => {
const to = new Date(2026, 9, 30, 9, 15).getTime()
assert.equal(buildDigest(make({ from, to }), { now: to }).range, '09:00–09:15')
})

// Invariant 5 (docs/subsystems/timeline.md): "busy" is a share of the window busy, not a count of
// turns, so a Bot in one long turn that began before the window is busy, not idle. The report gated
// its busy line on `turns > 0` — the count of turns that BEGAN in the window — and printed
// "- no turns in the window" for exactly that Bot, the one case the invariant names, while
// `timeline/health` over the same window said `busy 100% of the window`. Two views, one window.
test('#2543: a Bot busy the whole window reads as busy, not as a turn-less Bot', () => {
const window = make({
utilization: [{ lane: 'a', windowMs: 100_000, busyMs: 100_000, turns: 0, errors: 0, running: true }],
usage: [], toolUse: [], edges: [], stuck: [], exchanges: [],
})
const digest = buildDigest(window, { now: 100_000 })
const chief = digest.bots.find(bot => bot.id === 'a')
assert.deepEqual(chief.flags.filter(flag => flag !== 'no turns'), ['busy 100% of the window'])
assert.match(digest.markdown, /## Chief \(main\) · running\n- busy 100% of the window/)
assert.doesNotMatch(digest.markdown, /- no turns in the window/)
// The same window through the health view already said busy: one window, one answer.
assert.ok(buildHealth(window).bots.find(bot => bot.id === 'a').flags.some(flag => flag.code === 'busy'))
// Control: a Bot with no turn and no busy time still reads as idle, exactly as before.
assert.match(buildDigest(make(), { now: 100_000 }).markdown, /## Writer\n- no turns in the window/)
})
Loading