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/2419-timeline-wait-total.fixed.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion docs/subsystems/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions plugins/timeline/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
let generation = 0
let stopWatching
const listeners = new Set()
const emit = () => { for (const fn of [...listeners]) fn(state) }

Check warning on line 58 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
const set = patch => { state = { ...state, ...patch }; emit() }

const schedule = (ms) => {
Expand Down Expand Up @@ -150,7 +150,7 @@

// ------------------------------------------------------------------ formatting
// The host cleaned every name already; this is the second line, for a host that did not.
const safe = (value, max = 60) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069<>]/g, '').trim().slice(0, max)

Check warning on line 153 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

eslint(no-control-regex)

Unexpected control characters
// A turn's model as the tooltip names it: the model id of `provider/model`, as Bot settings fall back to.
const modelName = ref => safe(String(ref ?? '').split('/').slice(1).join('/') || ref, 80)
const pad = n => String(n).padStart(2, '0')
Expand Down Expand Up @@ -261,7 +261,7 @@
// What a lane is, under its name: plain Bots say nothing.
const subOf = (lane, name) => {
if (lane.kind === 'friend') return name.includes(' · ') ? name.split(' · ').slice(1).join(' · ') : 'friend'
if (/^Removed /.test(name)) return ''

Check warning on line 264 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

unicorn(prefer-string-starts-ends-with)

Prefer String#startsWith over a regex with a caret.
if (lane.kind === 'room') return lane.removed ? 'removed group' : 'group'
return lane.main ? 'main' : lane.removed ? 'removed' : ''
}
Expand Down Expand Up @@ -420,12 +420,14 @@
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
Expand Down Expand Up @@ -580,7 +582,7 @@
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)))))),
)
}

Expand Down Expand Up @@ -683,7 +685,7 @@
function createPanelState() {
let open = false
const listeners = new Set()
const set = (value) => { if (open !== value) { open = value; for (const fn of [...listeners]) fn(open) } }

Check warning on line 688 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
return { get open() { return open }, show: () => set(true), hide: () => set(false), toggle: () => set(!open), subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) } }
}
function makeSection(api) {
Expand Down
15 changes: 15 additions & 0 deletions plugins/timeline/test/panel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
root.rendering = true
for (const inst of root.instances.values()) inst.used = false
root.tree = expand({ type: Component, props, children: [] }, 'r')
for (const [path, inst] of [...root.instances]) {

Check warning on line 62 in plugins/timeline/test/panel.test.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (!inst.used) { for (const slot of inst.hooks) slot?.cleanup?.(); root.instances.delete(path) }
}
const run = root.pending.splice(0)
Expand Down Expand Up @@ -210,6 +210,21 @@
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'])
Expand Down
Loading