diff --git a/changelog.d/2419-timeline-wait-total.fixed.md b/changelog.d/2419-timeline-wait-total.fixed.md new file mode 100644 index 000000000..fd5674968 --- /dev/null +++ b/changelog.d/2419-timeline-wait-total.fixed.md @@ -0,0 +1 @@ +The timeline panel's "Who waits on whom" table no longer prints `0 ms` under **Total wait** for a handoff nobody has answered yet: an edge with no answer has no median, slowest or total wait, and all three now read `–` instead of a zero beside `Not yet 1`. diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index 7dd0c5c20..c38f39014 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -52,7 +52,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model 5. Honest numbers. "Delivered" is when a message reached the recipient's log; there are no read stamps. "Idle" means no running turn; a Bot waiting on a model call or a slow tool is busy. The health flags follow that: `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`, and `idle` needs no overlapping turn and nothing running; the `errors` rate divides by the larger of the turns that began in the window and the failed turns, so it never reads a failure count above its turn count. "Model" time is turn time outside tool calls (queueing and host overhead included), not a measured model latency. A turn an earlier run left open is closed at the last record before the restart and marked `lost`. "You asked" latency is the length of the turn it started. 6. A friend's Bot is a lane of its own, never a Bot of the local team (#248): it shows as a `friend` lane (its chat's name, else its handle) with messages only. The local side of a friend chat runs no model, so its transport turns are not recorded and it has no utilization row; your line to a friend waits until the friend's next line back (`role: 'remote'`), and a friend's request into one of your Bots is answered by that Bot's turn as a typed line is. Lanes never show a raw session id: a gone Bot or group with no remembered name is `Removed Bot` / `Removed group`. Group rooms are the room Session's own turn (the round), with the members' hidden group turns inside it. 7. The browser poller runs only while a panel holds it open, one request in flight, backing off on errors (4 s to 30 s) and keeping the last good window; a reply of another version is an error state, never drawn from. A request a window change superseded (a new one replaced it) does not clear the in-flight guard of the one that replaced it, so coming back to the tab never doubles the poll. A hidden page loads once (a panel opened in a background tab is never left on Loading…, #259), then stops polling; becoming visible refreshes at once. -8. The panel draws text only: every string is a React text child or an SVG text node, never markup, and is cleaned again in the browser (`safe`). It polls only while open (and refreshes at once when the tab or window comes back, #259, #271), shows the last good data under an error, and says in a note when the trace is shorter than the window, truncated, memory-only or looking back. Its one-line footnote repeats the honesty rules of invariant 5 (delivered is not read, busy is not idle, model time is not a measured latency). The dialog shrinks to its content and hangs from the top (switching tabs moves nothing above the body), the **Live** control is always shown (pressed while following the latest activity, a call to action when looking back), lanes name what they are under the name (`main`, `group`, `friend`, `removed`) with no icon prefix, and the map puts each name beside or under its node, clipped with the full name in a tooltip (#249). A turn bar's tooltip ends with the model the turn ran on (its model id, `· deepseek-v4-pro`); a running turn, or one recorded before #366, names none (#366). A friend's Bot is named as the sidebar names it (`UiA1415's Chief`, `Helper · UiB1415's Bot`, from the feed's `remote.name` / party `name`), so two Bots called Helper stay apart; a wait label that would run past the plot is right-aligned, and one with no room is left to the arrow's tooltip (#255). The window's range is always in view under the header (`Oct 18 · 13:20:05 – 13:35:05 (15 min)`, dates at both ends across midnight, and whether it is live), and the footnote is two short items, not one line (#279). +8. The panel draws text only: every string is a React text child or an SVG text node, never markup, and is cleaned again in the browser (`safe`). It polls only while open (and refreshes at once when the tab or window comes back, #259, #271), shows the last good data under an error, and says in a note when the trace is shorter than the window, truncated, memory-only or looking back. Its one-line footnote repeats the honesty rules of invariant 5 (delivered is not read, busy is not idle, model time is not a measured latency). The dialog shrinks to its content and hangs from the top (switching tabs moves nothing above the body), the **Live** control is always shown (pressed while following the latest activity, a call to action when looking back), lanes name what they are under the name (`main`, `group`, `friend`, `removed`) with no icon prefix, and the map puts each name beside or under its node, clipped with the full name in a tooltip (#249). A turn bar's tooltip ends with the model the turn ran on (its model id, `· deepseek-v4-pro`); a running turn, or one recorded before #366, names none (#366). A friend's Bot is named as the sidebar names it (`UiA1415's Chief`, `Helper · UiB1415's Bot`, from the feed's `remote.name` / party `name`), so two Bots called Helper stay apart; a wait label that would run past the plot is right-aligned, and one with no room is left to the arrow's tooltip (#255). The window's range is always in view under the header (`Oct 18 · 13:20:05 – 13:35:05 (15 min)`, dates at both ends across midnight, and whether it is live), and the footnote is two short items, not one line (#279). The wait table below the map prints `–` wherever a number does not exist yet: an edge nobody answered has no median, slowest or total wait, so all three read `–` rather than a measured `0 ms` beside a `Not yet 1`. 9. Opt-in (#281): `apply` without `config.enabled === true` subscribes to nothing, writes nothing and registers no endpoint; the browser entry draws no sidebar row unless `config.sidebarSection === true` and its poller only runs while a panel is open. A test asserts the host does nothing by default. Docs and the install doctor's default commands do not present it as part of a standard install. 10. The endpoint reads only. It does not start, stop, send or change anything. 11. OTLP export (#483) is opt-in and off by default: without `config.exporter` nothing leaves the host. With `exporter: "otlp"` the plugin POSTs OTLP/HTTP JSON to `otlpUrl`; with `"json"` it appends `dir/export.jsonl` (capped at 8 MiB, then it stops and warns). `otlpUrl` must be `https`, or `http` on localhost only (the request may carry an auth header). Only CLOSED spans are exported: a turn needs its `te` and a tool call its `ke`, so an open turn is carried between flushes and exported when it ends, never as a finished success. The exporter reads only the same compact records the panel does (invariant 2 still holds: no message text, arguments or output), maps turns to `invoke_agent` spans and tool calls to `execute_tool` spans under them, and carries every id through the same deterministic hash. Bot and room NAMES are sent only with `exportNames: true`; by default a span carries ids only, so a friend's or another home's name never leaves the host (a name comes from `trace.names()`, which includes friends' Bots and face-room names). The feed has no model name and no token counts, so those GenAI attributes are absent, not guessed. A failed send keeps the cursor, so the next flush retries the same closed spans (tested). The cursor re-reads records at its last timestamp (`>=`), because a `ke` can share the millisecond of its `ks` (or a `te` the time of the last record); a span already sent is deduplicated by id, so a same-millisecond record is neither skipped nor sent twice. diff --git a/plugins/timeline/client.js b/plugins/timeline/client.js index 5ed18f785..c37cd3dcc 100644 --- a/plugins/timeline/client.js +++ b/plugins/timeline/client.js @@ -420,12 +420,14 @@ window.__ModuleLoader__.load({ return { size: width, width, height, nodes: [...place.values()], edges } } // "Who waits on whom": the edges that carried requests, the longest total wait first. + // `totalMs` stays undefined until an answer exists (the host sends no total for an edge nobody + // answered), so the table can print "–" beside the Median and Slowest it already leaves empty. function waitRows(win) { const name = new Map(win.lanes.map(lane => [lane.id, nameOf(lane)])) return win.edges.filter(edge => edge.requests > 0 && name.has(edge.from) && name.has(edge.to)).map(edge => ({ waiter: name.get(edge.from), on: name.get(edge.to), requests: edge.requests, answered: edge.answered, waiting: edge.unanswered, - medianMs: edge.medianMs, maxMs: edge.maxMs, totalMs: edge.totalWaitMs ?? 0, - })).sort((p, q) => (q.totalMs - p.totalMs) || (q.waiting - p.waiting) || (q.requests - p.requests)) + medianMs: edge.medianMs, maxMs: edge.maxMs, totalMs: edge.totalWaitMs, + })).sort((p, q) => ((q.totalMs ?? 0) - (p.totalMs ?? 0)) || (q.waiting - p.waiting) || (q.requests - p.requests)) } // ------------------------------------------------------------------ utilization rows @@ -580,7 +582,7 @@ window.__ModuleLoader__.load({ h('thead', null, h('tr', null, ...['Waits', 'on', 'Requests', 'Answered', 'Not yet', 'Median', 'Slowest', 'Total wait'].map(head => h('th', { key: head }, head)))), h('tbody', null, ...rows.map((row, i) => h('tr', { key: `r-${i}` }, h('td', null, row.waiter), h('td', null, row.on), h('td', null, String(row.requests)), h('td', null, String(row.answered)), h('td', null, String(row.waiting)), - h('td', null, row.medianMs === undefined ? '–' : fmtDur(row.medianMs)), h('td', null, row.maxMs === undefined ? '–' : fmtDur(row.maxMs)), h('td', null, fmtDur(row.totalMs)))))), + h('td', null, row.medianMs === undefined ? '–' : fmtDur(row.medianMs)), h('td', null, row.maxMs === undefined ? '–' : fmtDur(row.maxMs)), h('td', null, row.totalMs === undefined ? '–' : fmtDur(row.totalMs)))))), ) } diff --git a/plugins/timeline/test/panel.test.js b/plugins/timeline/test/panel.test.js index b1cff6afe..34c3f12a2 100644 --- a/plugins/timeline/test/panel.test.js +++ b/plugins/timeline/test/panel.test.js @@ -210,6 +210,21 @@ test('communication map: nodes, curved edges with labels, and the wait table', ( assert.ok(rows.every((row, i) => i === 0 || rows[i - 1].totalMs >= row.totalMs)) }) +test('communication map: a handoff nobody answered yet has no total wait to print, not "0 ms"', () => { + // One request, never answered. The row's Median and Slowest already read "–"; the total wait of an + // answered exchange does not exist yet, so it must read "–" too rather than a measured zero. + const asked = buildWindow([ + { r: 'boot', t: at(0) }, + { r: 'm', t: at(1000), id: 'xq', f: { k: 'b', id: 'a' }, to: { k: 'b', id: 'b' }, ro: 'request', hop: 0, n: 10 }, + ], { now: at(5000), bots }) + const [row] = client.waitRows(asked) + assert.equal(row.waiting, 1) + assert.equal(row.answered, 0) + assert.equal(row.totalMs, undefined, 'nothing was answered, so there is no total wait to print') + const map = mount(client.CommMap, { win: asked }) + assert.deepEqual(findAll(map.tree, node => node.type === 'td').map(textOf), ['Chief', 'Writer', '1', '0', '1', '–', '–', '–']) +}) + test('utilization rows add up and carry the running flag', () => { const rows = client.utilizationRows(win()) assert.deepEqual(rows.map(row => row.name), ['Chief', 'Writer'])