feat(tui): add the v activity feed -- what keel has been DOING, cycle by cycle - #235
Merged
Conversation
…le by cycle keel has been running fine for weeks and has not traded, and the dashboard makes that look like death. It cannot help: every one of its six panels reports STATE, and state is exactly what a correctly-idle deployment and a crashed one have in common -- the same zeroes, the same empty position list, the same flat equity. What is missing is the narrative. The narrative already exists. Every cycle emits `agent.cycle_start`, `agent.feed_polled`, `agent.signals_evaluated`, `engine.setup_detected` / `setup_rejected`, `guards.check_failed` and `agent.enter_evaluated` into the structured JSONL engine log, all correlated by the `cycle_id` `keel/agent.py` binds via `keel_core.telemetry.bind_cycle`. Nothing surfaced it. `v` does. A chronological feed, newest first, ONE ROW PER CYCLE, expandable to the events inside it. A quiet cycle still gets a row -- the run of quiet cycles IS the answer to "is it alive", and a feed that omitted them would reproduce the very impression it exists to fix. The source is the LOG, not the database, and not a new table. A table would have been tidier and wrong: it would start EMPTY on precisely the deployment this feature exists to explain, and stay uninformative for however many weeks it took to fill. The log is already months deep. No schema change, no migration, no engine change -- `keel/commands/activity.py` only reads. That makes `v` the one overlay that reads a file rather than the DB, so the module and the TUI docstring both say plainly why that is admissible. The dashboard's iron rule is "DB reads only; never builds a broker, never touches the network". Reading a local file is NEITHER: no broker, no socket, no name resolution. The rule exists so opening an overlay can never place an order, spend money, or block on a remote host, and a bounded local read violates none of that -- the same latitude `p` propose already takes for `config.proposals_dir`. The network-exception count stands at three. The read is BOUNDED because the log grows without limit and this rebuilds on every poll: the newest 1 MiB, 5000 lines, 200 cycles, 400 events per cycle, all named constants. On the real deployment's 815 KB log that is the whole file in 3.7 ms; against a 25 MB rotated one the cost is unchanged. When a cap bites, the feed says so rather than quietly presenting its window as history. Everything degrades to a sentence. Missing file (with the working-directory trap named, since that is the commonest cause), empty file, permissions error, a partial JSON line from a crash mid-write, a line that is valid JSON but not an object, a record with no `ts`, a record with no `cycle_id` at all (the 64 uncorrelated fetch failures in the real log are grouped by contiguity rather than dropped), a `ts` no `strftime` can format, a garbled count, a rotated log, a window holding no whole record -- each has a defined outcome and a test. Never a traceback, and never a blank overlay. The parsing, grouping and summarising are pure functions in a separate module with 116 tests built from the real event shapes; `tui.py` only styles what they render, exactly as it already does for `insights` and `admission`.
eaitbrahim
added a commit
that referenced
this pull request
Aug 11, 2026
The `v` activity feed (#235) showed every cycle the bounded read window held -- 77 of them on the real deployment, going back three weeks. The instruction is that the feed shows TODAY: "what has keel been doing" means today unless asked otherwise, and a fortnight of scrollback is not an answer to it. The scoping lives in the pure layer (`apply_scope`), not in the curses code, and takes its "now" as a parameter so the day boundary is injectable and every test of it is deterministic on whatever day it runs. "Today" is the local CALENDAR day -- derived through `datetime.date` in the same local clock `_stamp` renders timestamps in, so it survives DST (and errs early rather than late in the zones where midnight itself does not exist), and is not a rolling 24 hours, which would put yesterday's 09:00 cycle on screen every morning and drop it every afternoon. `t` inside the overlay cycles today -> 7 days -> all, and the scope resets to today on every open and every close. A widened view answers one question once; it never becomes tomorrow's default. The delicate part is that the deployment runs ONCE A DAY at 09:00, so "today" holds at most one row and none at all before 09:00 -- and a blank panel would be strictly worse than the state-only dashboard this whole feature exists to fix, since a blank panel and a dead agent look identical. So an empty day is never blank. It says, in this order: that keel has not run YET today; the timestamp of the last cycle and how long ago that was; and when the next one is due, inferred from that cycle's own time of day (or that its usual time has passed, which is the case worth acting on). Naming one timestamp is a status line, not a feed -- no historical row is rendered. The read bounds are unchanged (1 MiB / 5000 lines / 200 cycles / 400 events), and their interaction with a day filter is reported rather than glossed: `scope_fully_covered` records whether the window PROVED it reached back past midnight, and both the footer and the empty state say so when it did not, instead of letting an unread morning read as a quiet one. Real deployment log, unchanged on disk, at 11:53 today: scope: today (2026-08-11) · 1 cycle · 76 older hidden · press t to widen ▸ 2026-08-11 09:00:09 paper 0 0 0 0 0 gate rejected: choppy_regime (PAXG-USD) and the same log as it stood at 07:00 this morning, before that cycle ran: keel has not run yet today. Last cycle: 2026-08-10 09:00:01 -- yesterday, 21h 59m ago. Next cycle due today around 09:00 local -- in 2h 00m. Gates: ruff clean, mypy clean, 2664 passed / 1 skipped (from 2607 on main -- 57 new tests, same single expected skip). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Aug 11, 2026
…the CTS scoring fix (#241) A minor bump, not a patch, for three reasons that each require operator action or change behaviour the deployment is currently relying on. SCHEMA. `SCHEMA_VERSION` goes 9 -> 10 (#223). Both deployed databases are at 9 and must be migrated before this build can use them. BEHAVIOUR REQUIRING OPERATOR ACTION. #223 adds a second attested claim -- what CONTRACT a venue listing is, not only what the underlying asset is. It fails closed with no backfill, deliberately, so after this lands `keel assets screen` REJECTS every product with `instrument_wrapper: UNATTESTED` until `keel assets attest-instrument` is run once per product. Live trading is unaffected: rail 1 gates buys on `config.allowlist`, not on the screen. LIVE SCORING CHANGED. #227 fixed `is_round_number`, which returned True for every 2dp-quoted price and so handed BTC/ETH/PAXG a free CTS point on every bar. Scores on those three assets are genuinely lower under this build than under 0.5.7. Also ships: the Robinhood crypto adapter behind the broker port (#216/#218/#222/#229, not wired to the live path), the TUI activity feed (#235/#237), the CTS factor collinearity study (#224), `Preview.synthetic` at the confirm gate (#221), rail 9 seeing a bracket's own stop (#212), and CI gating merges on the `test` check (#234/#238). Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
keel has been running fine for weeks and has not traded. The dashboard makes that look like death — and it cannot help, because every one of its six panels reports state, and state is exactly what a correctly-idle deployment and a crashed one have in common: the same zeroes, the same empty position list, the same flat equity.
What is missing is the narrative, and the narrative already exists. Every cycle emits
agent.cycle_start,agent.feed_polled,agent.signals_evaluated,engine.setup_detected/setup_rejected,guards.check_failed,agent.enter_evaluatedinto the structured JSONL engine log, all correlated by thecycle_idkeel/agent.pybinds viakeel_core.telemetry.bind_cycle. Nothing surfaced it.What this adds
v— a chronological activity feed, newest first, one row per engine cycle, expandable to the events inside it. A quiet cycle still gets a row: the run of quiet cycles is the answer to "is it alive", and a feed that omitted them would reproduce the very impression it exists to fix.keel/commands/activity.py— new, pure, no curses: bounded tail read, parse, group bycycle_id, summarise, render to plain text. The whole testable core.keel/commands/tui.py— thevoverlay, styling what those renderers already produced, exactly asi/s/p/ddo forinsights/admission. New keyv(verified free), help section, footer row.Real-data proof
Run against the actual deployment log,
~/keel/logs/keel.log(815 KB, read-only, untouched):That is the answer to "why hasn't it traded", in eight lines: it looks every day, it found exactly one setup in a week, two rails vetoed it, and the account reads have been failing separately the whole time.
Enter on the 2026-08-08 row:
And the 2026-08-11 gate rejection:
Design decisions
Source is the log, not the database, and not a new table. A table would have been tidier and wrong: it would start empty on precisely the deployment this exists to explain, and stay uninformative for however many weeks it took to fill. The log is already months deep. No schema change, no migration, no engine change.
Why a file read is admissible under the TUI's iron rule. The rule (in
tui.py's own docstring, for thesoverlay) is "DB reads only; never builds a broker, never touches the network." Reading a local file is neither: no broker is constructed, no socket opened, no name resolved. The rule exists so that opening an overlay can never place an order, spend money, or block on a remote host — a bounded read of a file on the same disk violates none of that. It is the same latitudeppropose already takes forconfig.proposals_dir. The network-exception count stands unchanged at three, and a test asserts the overlay adds zero broker constructions.The read is bounded, because the log grows without limit and the overlay rebuilds every poll. A dashboard whose responsiveness degrades with deployment age would be a worse bug than the one being fixed. Named constants: newest 1 MiB, 5000 lines, 200 cycles, 400 events/cycle. 1 MiB covers the whole current 815 KB log while staying a fixed few-ms read against a 25 MB rotated one. When a cap bites the feed says so instead of presenting its window as history.
Log path comes from
logging.filein config.yaml (keel_core.config.LoggingConfig, defaultlogs/keel.log) — the same settingconfigure_loggingattaches its handler to, so no new setting was invented. It is relative, resolved against the working directory, which is why running from~/keelworks and why a missing file reports the resolved absolute path and names the working-directory trap explicitly.Degradation — each with a test
<abs path>" + the cwd trap + how to set an absolutelogging.filelogging.verbose: falsebeing the default that records only errorsunparseable— distinguished from empty, because the fixes differcycle_id(the 64 fetch failures)tsts=inf/nan/ year 5,000,000strftimeraises on these, and the renderer runs on the repaint pathsignal_count: "inf"int(inf)raisesOverflowError, notValueError— caught, so one bad line cannot empty a whole feed.getwith a?fallback; an unknown event renders its ownkey=valuefieldserrors="replace"— one replacement character, not a dead overlayoversized, notempty— "engine log is empty (815357 bytes)" would be self-contradictoryopen_state()raising mid-pollcurses.wrapperA broken log always degrades to an informative message. Never a traceback, never a blank overlay — a blank overlay would be indistinguishable from the dead-looking dashboard this replaces.
Keys
vopens (verified unused). Inside:up/kanddown/jmove the selected row,PgUp/PgDn/Home/Endby page or to either end,Enter/Spaceexpand or collapse,q/Esc/vclose. Up/down move a row rather than a line because a row can be one line or seventy — scrolling by lines through an expanded cycle would make selecting the next cycle a matter of counting its events.Style follows the dashboard's existing convention: quiet is muted, withheld/vetoed is
warn(the system working as designed, not an emergency — the same distinction_admission_line_styledraws for a REJECT), a real fill isok, an ERROR run isalert.Gates
Baseline on
mainwas 2456 passed / 1 skipped; this adds 151 tests and the same single expected skip.🤖 Generated with Claude Code