diff --git a/changelog.d/2543-timeline-busy-window.fixed.md b/changelog.d/2543-timeline-busy-window.fixed.md new file mode 100644 index 000000000..5eed6b36f --- /dev/null +++ b/changelog.d/2543-timeline-busy-window.fixed.md @@ -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. diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index c38f39014..4f56c37ba 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -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. | diff --git a/plugins/timeline/src/digest.js b/plugins/timeline/src/digest.js index aa9cf80ed..67114174c 100644 --- a/plugins/timeline/src/digest.js +++ b/plugins/timeline/src/digest.js @@ -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 { @@ -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') } diff --git a/plugins/timeline/test/digest.test.js b/plugins/timeline/test/digest.test.js index bc90f2146..dc0b94699 100644 --- a/plugins/timeline/test/digest.test.js +++ b/plugins/timeline/test/digest.test.js @@ -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 = {}) => ({ @@ -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/) +})