diff --git a/changelog.d/timeline-stuck.added.md b/changelog.d/timeline-stuck.added.md new file mode 100644 index 00000000..0c801cfd --- /dev/null +++ b/changelog.d/timeline-stuck.added.md @@ -0,0 +1 @@ +The Timeline window now lists the requests that have been waiting longest — the open handoffs, worst first, with how long each has waited — so a stuck Bot-to-Bot request can be spotted instead of only counted. Read-only, part of the existing `timeline/window` answer. diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index d7987478..a63664f3 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -26,7 +26,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model | `plugins/timeline/src/adapter.js` | The only file that knows the `ctx.flybot` shape. | | `plugins/timeline/src/recorder.js` | Feed events to compact trace records; rebuilds every field from a strict alphabet. | | `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) and per-Bot outcomes and recent failures (#471). | +| `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) and the open requests waiting longest (#469). | | `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), 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. | | `plugins/timeline/src/otel.js` | OTLP export: maps trace records to OpenTelemetry GenAI spans (`spansFrom`, `otlpPayload`) and a cursor exporter (`createExporter`). Off unless `config.exporter` is set. | diff --git a/plugins/timeline/src/window.js b/plugins/timeline/src/window.js index 154dbe28..06745a9d 100644 --- a/plugins/timeline/src/window.js +++ b/plugins/timeline/src/window.js @@ -287,12 +287,20 @@ export function buildWindow(records, { now = Date.now(), to, windowMs, bots = [] .slice(0, FAILED_KEPT) .map(turn => ({ id: turn.id, bot: turn.bot, at: turn.t1, outcome: turn.outcome, ...(turn.errorCode ? { code: cleanCode(turn.errorCode) } : {}) })) + // ---- waiting too long (#469): the open requests, worst first, so a stuck handoff surfaces + const STUCK_KEPT = 20 + const stuck = exchanges + .filter(arrow => arrow.waiting === true && Number.isFinite(arrow.t)) + .map(arrow => ({ id: arrow.id, from: arrow.from, to: arrow.to, role: arrow.role, waitedMs: Math.max(0, now - arrow.t) })) + .sort((a, b) => b.waitedMs - a.waitedMs) + .slice(0, STUCK_KEPT) + return { version: 1, now, from: lo, to: hi, windowMs: windowMsActual, live, coverage: { oldest: oldest ?? null, newest: last || null, complete: oldest !== undefined && oldest <= lo }, trace: stats ?? null, truncated, - lanes, turns: shapedTurns, rounds, exchanges, utilization, edges, usage, outcomes, failures, + lanes, turns: shapedTurns, rounds, exchanges, utilization, edges, usage, outcomes, failures, stuck, } } diff --git a/plugins/timeline/test/window.test.js b/plugins/timeline/test/window.test.js index a245f02f..f6c05626 100644 --- a/plugins/timeline/test/window.test.js +++ b/plugins/timeline/test/window.test.js @@ -274,3 +274,11 @@ test('outcomes: per-Bot turn endings and the recent failures (#357/#471)', () => assert.deepEqual(win.outcomes.find(entry => entry.lane === 'b').aborted, 1) assert.deepEqual(win.failures.map(f => [f.id, f.bot, f.outcome, f.code]), [['b#1', 'b', 'aborted', undefined], ['a#2', 'a', 'error', 'RATE_LIMIT']]) }) + +test('stuck: open requests are listed worst-first (#469)', () => { + const win = build() + assert.ok(win.stuck.length >= 1) + assert.ok(win.stuck.some(entry => entry.id === 'x3'), JSON.stringify(win.stuck)) + assert.ok(win.stuck.every(entry => entry.waitedMs >= 0)) + for (let i = 1; i < win.stuck.length; i += 1) assert.ok(win.stuck[i - 1].waitedMs >= win.stuck[i].waitedMs) +})