From 5421dd2d65ccd29e410f10338f6d6706784316f5 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:33:16 +0800 Subject: [PATCH] fix(timeline): report a Bot busy before the window as busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `turns` counts the turns that began in the window, so a Bot in one long turn that started before it had turns = 0 and the run report drew "- no turns in the window" and no busy flag — the exact case docs/subsystems/timeline.md invariant 5 names, and the opposite of what `timeline/health` answers over the same window. The report now flags busy from the share of the window busy (the same threshold) and, when no turn began in the window, prints that share instead of denying the Bot worked. --- .../2543-timeline-busy-window.fixed.md | 1 + docs/subsystems/timeline.md | 2 +- plugins/timeline/src/digest.js | 9 +++++++- plugins/timeline/test/digest.test.js | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 changelog.d/2543-timeline-busy-window.fixed.md 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/) +})