Skip to content

Add durable timeline alerts (#584) - #731

Merged
Birfy merged 1 commit into
mainfrom
feat/584-timeline-alerts
Oct 22, 2026
Merged

Birfy merged 1 commit into
mainfrom
feat/584-timeline-alerts

Conversation

@Birfy

@Birfy Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Written-by: Dev4

Fixes #584

Reproduce

Call timeline/health twice while one Bot keeps failing: every answer re-derives the flags from the window, so nothing says since when — and once the condition clears, the next answer simply has no flag, leaving no record that it was ever open. There is also no endpoint that keeps state at all: GET-style timeline/alerts does not exist, and window() is pure, so a routine or a person can only poll and diff by hand.

What changed

  • plugins/timeline/src/alerts.js (new, pure) — createAlertTracker({ stuckMs, resolveMs, now }) folds a timeline/window answer into { version, now, open, resolved, changed, stale }. errors and waiting read the same numbers and thresholds as timeline/health (buildHealth), and stuck uses the open requests the window already carries (window.stuck) past a 15 min default (stuckMs, the threshold the issue left open). One alert per condition key (errors:<bot>, waiting:<bot>, stuck:<exchange>) with since; polling again updates seenAt, never opens a second. A condition that clears moves the alert to resolved with resolvedAt and it stays readable for resolveMs (1 min). changed is true only on an open or a resolve, not when a holding alert's detail moves. A window that ends before the last one folded is answered stale with the state untouched, so a look-back never resolves a live alert.
  • plugins/timeline/src/timeline.js — the read-only timeline/alerts endpoint over that tracker (body { windowMs?, to? } like timeline/window), one tracker per plugin instance, removed on dispose().
  • plugins/timeline/test/alerts.test.js (new) — 8 cases: one alert per condition and no second on re-poll; resolve once, readable through the grace window and then dropped; errors/waiting as separate alerts with idle/busy opening none; the stuck threshold, one alert per request and resolution on the reply; changed false when only the detail moves; the stale look-back; a re-open with a fresh since; the endpoint registered, its empty answer and its removal on dispose.
  • docs/subsystems/timeline.md — the new file in Key files, the endpoint in Entry points, a new invariant 12 (state in memory, one alert per condition, stale look-back, nothing written) and the test in Tests.
  • changelog.d/584-timeline-alerts.added.md.

What I ran

Tip SHA: ce44cc98e2c4db15eb1a38c5adb69a93bcc97d56 (base 9c835e5a, Node 22.23.3, TZ=UTC):

cd plugins/timeline && node --import ./test/resolve.mjs --test test/alerts.test.js            -> 8/8 pass
cd plugins/timeline && node --import ./test/resolve.mjs --test --test-concurrency=2 "test/*.test.js"
  -> # tests 113  # pass 113  # fail 0   (105 before this PR)
node scripts/lint-plugins.mjs timeline                        -> exit 0 (9 warnings, all pre-existing; none in alerts.js)
node .github/scripts/changelog.mjs check                     -> 19 fragment(s) well formed
ARCH_DOCS_BASE=<main-tree> node core/scripts/check-arch-docs.mjs -> arch-docs: OK (5 changed file(s))

Red without the source change

Two runs, both on this branch's test file:

# (a) main's source: src/alerts.js removed, src/timeline.js back to main
# Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../plugins/timeline/src/alerts.js'
not ok 1 - test/alerts.test.js
# tests 1 / # pass 0 / # fail 1

# (b) the module kept, but the tracker re-derives every fold (`open.clear()` at the top of fold),
#     which is the behaviour #584 removes:
not ok 1 - alerts: a failing Bot opens one alert, and polling again does not open a second
not ok 2 - alerts: a condition that clears resolves once and stays readable until the grace window ends
not ok 4 - alerts: a stuck handoff opens only after the threshold, once per unanswered request
not ok 5 - alerts: changed is true only on a transition, not when a holding alert reads differently
not ok 6 - alerts: a window looking back does not resolve a live alert
# tests 8 / # pass 3 / # fail 5

Restoring alerts.js gives 8/8 again (and 113/113 for the plugin suite).

Not run

  • npm run verify as one command: this worktree is a main tarball with no installed toolchain (npm ci/npm run harness in core/); its steps that cover this change are the ones listed above. No core/ file changed, so the core suite is untouched, and no patches/ds-bot/ regeneration is needed (the plugin is not part of core/); CI's package job still runs both checks.
  • The smoke test / a real desktop window: no Electron or setsid here. The endpoint is host-side only; the panel does not read it yet (not in the issue's scope).

Risk

A plugin-only, read-only change: no core/, no patches/ds-bot/, no security surface, no new config key, and nothing is written to the trace. The new state is one tracker per plugin instance, in memory, bounded by what the window already caps (one alert per Bot condition plus at most the 20 open requests window.stuck carries); it dies with the process, so a restart starts with no alerts rather than replaying old ones. The one behaviour worth knowing: stuck uses a 15 min default that the issue did not fix, it is a constructor argument (stuckMs) and is documented in the invariant; and a window that looks back is answered stale instead of changing the state.

A read-only timeline/alerts endpoint folds the window into {open, resolved, changed}:
one alert per holding condition (errors, waiting, stuck) with its since, resolved when
it clears and kept readable for a minute. A look-back window never resolves a live alert.
The state lives in memory; nothing new reaches the trace.
@Birfy

Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at ce44cc9
Written-by: Reviewer

Scope matches issue #584 exactly: new plugins/timeline/src/alerts.js (pure createAlertTracker), the timeline/alerts endpoint in timeline.js, test/alerts.test.js, docs/subsystems/timeline.md, one changelog fragment. No client/panel change, which is what the issue asks for. Read-only: adapter.handle(ALERTS_ENDPOINT, alertView) over the existing window, registered and removed on dispose, nothing written to the trace or a Session.

Ran:

  • cd plugins/timeline && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/resolve.mjs --test --test-concurrency=2 "test/*.test.js" → 113/113 pass at this tip
  • cd plugins/timeline && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/resolve.mjs --test test/alerts.test.js → 8/8 pass
  • red: registration line const offAlerts = adapter.handle(ALERTS_ENDPOINT, alertView) deleted from timeline.js → 7/8, the only failure is the endpoint is registered read-only …
  • red: clearing loop in fold deleted (alerts never resolve) → 4/8 fail (resolves once and stays readable, stuck … opens once resolve half, a window looking back, re-opens with a fresh since)
  • red: stuck threshold check replaced by waitedMs >= 0 → 1/8 fails (stuck handoff opens only after the threshold)
  • red: stale guard if (at < lastAt) disabled → 1/8 fails (a window looking back does not resolve a live alert)
  • red: open.get(id) holding branch removed (every poll re-opens) → 3/8 fail (one alert per condition, stuck, changed only on a transition)
  • red, whole source change reverted (alerts.js deleted, timeline.js back to main, tests kept) → alerts.test.js fails to import (ERR_MODULE_NOT_FOUND), 0/1

Not run: root npm run verify, the core suite and the smoke test (this PR touches no core file; CI tests-ok is green at this tip), panel/browser checks (no client change).

Three non-blocking notes, all already documented in invariant 12 rather than defects here:

  1. stuck is derived from the window's capped list of open requests, so a request pushed out of that list resolves while it is in fact still waiting. Inherent to folding the window; the doc says so.
  2. A resolution that merely expires out of the grace window does not set changed, so a reader keyed on changed alone will not see the drop. By design (only transitions count).
  3. Alert state is per process and dies on restart; a restarted host opens alerts fresh instead of replaying old ones. Documented, and the right default for a read-only view.

@Birfy
Birfy added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 9d26108 Oct 22, 2026
9 checks passed
@Birfy
Birfy deleted the feat/584-timeline-alerts branch October 22, 2026 10:14
HugoXK pushed a commit to HugoXK/FlyBot that referenced this pull request Oct 23, 2026
…lowLab#735)

Written-by: Dev

Fixes ThinkFlowLab#582.

Tip SHA: 6cd23a1

## Reproduce

The window already answers `timeline/window` with per-Bot turns,
failures, busy time, tokens, tool use, unanswered requests and the
longest open wait, but nothing turns it into a report: a person reads
the panel and retypes what they saw. There is no endpoint to ask for a
report and no control to copy one.

## What changed

- `plugins/timeline/src/digest.js` (new, pure): `buildDigest(window, {
title, now })` resolves `{ title, range, live, bots, team, markdown,
line }` — per Bot its turns and failures, busy share, tokens
(in/out/cached), top five tools and unanswered requests, then the team
rollup (turns, failures, tokens, calls) and the longest still-open wait
— plus `formatCount` (`840`, `12.3k`, `1.2M`) and `formatMs` (`40s`,
`12m`, `1h05m`). It reads only what the window carries: `usage`,
`toolUse` and `stuck` when present, and it falls back to measuring the
waiting exchanges against `now` when `stuck` is absent, so an older host
gets a shorter report rather than a broken one.
- `plugins/timeline/src/timeline.js`: `DIGEST_ENDPOINT =
'timeline/digest'`, wired the way `timeline/health` is (same
`window(body)` answer, `{ windowMs?, to?, title? }`, title trimmed to
80), returned as `digest` and unregistered in `dispose()`.
- `plugins/timeline/client.js`: `DIGEST_ENDPOINT`, `store.digest()`
(asks for the window the panel is showing, on demand — the poller never
does), a `copyText` helper over `navigator.clipboard`, and a **Copy
report** button in the header that says `Copying…` then `Copied`. A page
without a clipboard, or one that refuses it, shows `Could not copy the
report: …` instead of claiming a copy; the button stays available.
- `plugins/timeline/test/digest.test.js` (new, 8 cases) and three cases
in `test/panel.test.js` (the data layer's request and its "no report"
rejection, the copy, and the refused clipboard).
`docs/subsystems/timeline.md` gains the endpoint, the local-clock rule
and the button; `changelog.d/582-timeline-digest.added.md` is the
fragment.

### INC at `5f1f91a9`, answered

- **The endpoint was not pinned at the host.** `digest.test.js` now
builds a real timeline on `mockAdapter()` and asks `timeline/digest`
through `adapter.endpoints`, then checks `dispose()` removes it (the
shape `alerts.test.js` uses). Deleting either host line now fails that
case.
- **The report printed UTC while the panel header prints local time.**
`clock()` in `digest.js` now formats with `getHours()`/`getMinutes()`,
like `fmtClock` in `client.js`, so a report copied at 17:05 says 17:05.
The test file runs in `Asia/Shanghai` (`process.env.TZ` at the top) so a
UTC formatter cannot pass by accident on a UTC runner; one case pins
`Date.UTC(2026-10-22 09:05)` to `17:05–17:20`.

## What I ran

Rebased onto main `d5fefe01` (it conflicted with ThinkFlowLab#731, which landed
`plugins/timeline/src/alerts.js` and its endpoint in the same file).
Locally, in a full checkout at that base with this branch's files,
`DSH_HARNESS=/opt/flybot`:

```
$ cd plugins/timeline && TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/resolve.mjs --test --test-concurrency=2 "test/*.test.js"
# tests 124
# pass 124
# fail 0
$ TZ=UTC DSH_HARNESS=/opt/flybot node --import ./test/resolve.mjs --test test/digest.test.js
# tests 8
# pass 8
# fail 0
$ node --check src/digest.js && node --check test/digest.test.js
(no output)
```

The 17 pre-existing panel cases stay green, including the
header/tab/close ones that now render one more button.

## Red without the source change

Seven cuts, each restored afterwards:

- delete `src/digest.js` → `digest.test.js` cannot import it (8 cases
red).
- delete the two `Copy report` renderings from `client.js` → **18/20**,
red: `ThinkFlowLab#582: Copy report copies the Markdown and says so` and `ThinkFlowLab#582: a
refused clipboard is reported, not swallowed`.
- delete `store.digest()` → **19/20**, red: `ThinkFlowLab#582: the data layer asks
for the report at the window the panel shows`.
- put `clock()` back on `toISOString()` → **122/124**, red: `ThinkFlowLab#582:
per-Bot sections, the team rollup and the longest wait` and `ThinkFlowLab#582: the
report names local hours, like the panel header`.
- delete both host lines (`adapter.handle(DIGEST_ENDPOINT, digest)` and
`offDigest()`) → **123/124**, red: `ThinkFlowLab#582: the endpoint is registered
read-only and removed on dispose` (the endpoint is missing).
- delete only `offDigest()` from `dispose()` → **123/124**, same case
(the endpoint survives dispose).

## Not run

- Root `npm run verify` and the core suite: this box has no `core/`
sources (only the plugin, plus `DSH_HARNESS` for `@deepseek-ai/*`), so
the plugin suite ran against an assembled checkout and the rest is left
to CI.
- `npm run lint`, `arch-docs`: no full checkout with `origin/main`;
`docs/subsystems/timeline.md` changes in the same PR, which is what
`arch-docs` asks for.
- The panel in a real browser: the copy path is driven through an
installed `navigator.clipboard`, not through a live page. No check in a
zone other than the two above was run.

## Risk

T2 by the repository's own rules (`plugins/timeline/**`), so it needs a
PASS at the tip. The change is read-only and additive: one new endpoint,
one new button, one new pure module. It does not touch `core/`,
`ds-bot/` or `patches/`, and it adds no configuration. A host that
predates `usage`/`toolUse`/`stuck` still answers with a shorter report
rather than an error.
@Birfy Birfy removed the agent-pr Pull request opened by an agent label Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Timeline: durable alerts (open once, resolve when cleared)

1 participant