Skip to content

feat(control): a journal of every change in the answer to "Are we in control?" - #1982

Merged
frahlg merged 3 commits into
masterfrom
feat/control-journal
Nov 8, 2026
Merged

frahlg merged 3 commits into
masterfrom
feat/control-journal

Conversation

@frahlg

@frahlg frahlg commented Nov 8, 2026

Copy link
Copy Markdown
Member

Problem

On 8 November 2026 at about 06:20 the owner's app warned that FTW was not in control: Core ordered the home battery to charge at 4.2–4.3 kW and it took 1.2–1.4 kW from 06:14 to 06:29. Afterwards nothing on the box said so.

  • control_feedback (the answer to "Are we in control?") is worked out only when a client asks. Nothing kept it.
  • Only control.not_following becomes a notification, after its threshold. Limited, blocked and quiet states never do.
  • state.events held start/stop only, with no route. The agent read events.get was in the contract but answered E_UNKNOWN_OP, and FTW Cloud's MCP server told models that events were unavailable.
  • The log ring is 2 000 lines in memory. "dispatch slot under-delivery" went to the journal at INFO only.
  • One notification_log row was lost that morning to database is locked.

The cause that morning could still be dug out of raw data: the inverter read back the 4.2 kW order (sungrow_force_w), the BMS raised no bit, and the grid sat flat at ~4.24 kW while the battery moved against the house load, so the Sungrow held an import limit. But the box could not answer "why did the app warn at 06:14?" by itself, for the owner, an agent or support.

Change

A control journal in Core.

  • Sampling. Every 5 s Core takes each controlled device's control feedback as /api/status serves it, each driver's health, whether the site may dispatch (the app's "FTW is not in control"), and the mode (api.Server.JournalVerdicts).
  • Spans. go/internal/journal turns the samples into spans per subject. A changed verdict (status, reason, severity, mode) closes the subject's span and opens the next, so a subject's spans tile its history. Each span keeps the power Core asked for and the power measured (min, max, mean), and the verdict as served when it opened and at its latest sample.
  • Settle. Each new order leaves a device "waiting" for a second or two. A change to an info verdict must hold 15 s and is then dated from when it was first seen; a warning or an alarm starts a span at once, however short, so the 3 s dispatch block at a restart is kept. Measured on the home box before this: Easee flipped following ↔ waiting about once a minute.
  • Plan slots. control.State.SlotScored hands every scored plan slot to the journal: the fleet's planned and delivered energy, the ratio and the verdict (on target, under, over, sign mismatch).
  • Storage. state.control_journal (STRICT, indexed on start and on open spans). Changes are written at the next flush; an open span's running numbers every 5 minutes. A failed write keeps spans in memory; when a batch fails each span is tried alone, and one no write takes is dropped after 20 tries. A restart closes spans the last run left open at their last update; a clean stop closes them at the stop. Kept for 366 days.
  • Reads.
    • GET /api/control/journal?since&until&subject&driver&severity&limit: spans overlapping the window, newest first, with the notifications in it.
    • Agent read events.get (ftw.events.read): the same spans and notices with sources by agent id; only Core's codes and the verdict's numbers leave the box, never names. EventsGet/Events in go/protocol gain fields compatibly; events stays for older readers.
  • Notifications. A notification_log row the database is too busy for is retried in the background (1 s to 5 min) instead of dropped.
  • Docs: docs/agents.md (events.get), docs/roadmap.md (the verified-control row).

Changeset: minor (a new capability the owner asked for on 8 Nov: "Ja! Kör igång. En ordentlig bra ny funktion där vi får riktig information hela vägen"). Please confirm the minor.

Evidence

  • New tests, all passing with -race where they touch goroutines:
    • internal/journal: tiling with the 06:14 morning, a subject that goes away, failed writes kept and written later, numbers written once a StatsEvery, an info blip shorter than Settle starts nothing while a 3 s warning does, a warning during a pending change, two flushes at once insert a span once, samples after CloseAll start nothing, a span no write takes is isolated and dropped.
    • internal/state: round trip and window overlap, open spans, close at last update, prune, a cancelled write leaves ids unset, NaN/Inf readings ignored, first_json kept on update, notices by window and by driver.
    • internal/api: verdicts cover a device, a blocked dispatch, the mode, a live and an offline source; the route answers a window with numbers and notices, an open span with a null end, and 400 for a reversed window, an unknown severity and a bad limit.
    • internal/appproto: events.get reaches an agent without names, filters by source and severity, and refuses a box without a journal, a reversed window, an unknown source and an unknown severity.
    • internal/control: SlotScored gets the 06:15 slot's numbers (24 % of plan).
    • cmd/ftw: the state adapter, a clean stop leaves nothing open, a locked notification row is written after a retry.
  • Live run on this machine (this branch, Ferroamp and Sungrow simulators): the journal opened spans for both batteries, both sources, the mode and dispatch within 5 s. After the site-meter simulator was stopped at 08:46:26, the Ferroamp battery went no_contact/readings_lost/alarm at 08:47:28 and site:dispatch went blocked/site_meter_stale/warning at 08:47:58, each closing the span before it at that second, with asked and measured power on every span.
  • make verify passes (vet, tests, build).

Review

An independent review (Sonnet) found the model sound and two things to fix before merge, both fixed in the second commit:

  • Shutdown flushed from the main goroutine while the journal's own goroutine could still flush: a new span could be inserted twice, and a sample after CloseAll reopened subjects. Shutdown now stops and joins the goroutine first, flushes take turns, and CloseAll stops later samples.
  • A NaN in a power reading made the sum NULL and failed every later batch. Non-finite readings now add nothing, and a failing batch is retried span by span.

Also from it: open spans write every 5 minutes instead of every minute, without first_json (about 2.6k small updates a day on a typical site instead of 13k); notices are filtered by driver in SQL; severity and limit are checked.

Left as they are, and documented: info verdicts that keep changing faster than 15 s never settle; a source span is the driver's health as Core's watchdog sees it, not a reading's age (in the live run the stopped meter's source stayed live while dispatch was blocked); a slot span is the fleet's average power over the slot; src can only name a source the box still has.

Related

🤖 Generated with Claude Code

frahlg and others added 2 commits November 8, 2026 08:42
…control?"

Core samples each controlled device's control feedback, each source's
freshness, whether the site may dispatch and the mode every 5 s. A
changed verdict closes the subject's span and opens the next, with the
power asked for and delivered and the verdict as served; a change to an
info verdict must hold 15 s, a warning starts a span at once. Scored
plan slots are kept with planned and delivered energy. GET
/api/control/journal and the agent read events.get answer any window.
Failed writes stay in memory for the next flush; notification_log rows
the database was too busy for are retried in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shutdown now stops the sampling goroutine before it closes and writes
the spans, and flushes take turns, so a new span is inserted once.
CloseAll stops later samples. A reading that is not a finite number adds
nothing. An open span writes its numbers every 5 minutes, without
first_json. When a batch fails, each span is tried alone, and one no
write takes is dropped after 20 tries. Notices are filtered by driver
in SQL; severity and limit are checked. Slot spans are the fleet's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-08T07:51:08.543566Z b867b4d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b867b4d30f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

for _, f := range s.controlFeedback(now) {
detail, _ := json.Marshal(f)
out = append(out, journal.Verdict{
Subject: f.Kind + ":" + f.Driver, Driver: f.Driver, Kind: f.Kind,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key persisted device history by hardware identity

When a configured driver is renamed, these persisted subjects retain the old YAML name, while events.get resolves its src filter only through currently configured sources. Consequently, querying the renamed device loses its earlier history; replacing hardware under the same driver name instead attributes the previous device's history to the replacement. The journal schema stores no hardware identity with which to distinguish these cases. Resolve and persist the stable device identity for device/source spans, retaining the driver name only as descriptive metadata. This violates AGENTS.md's requirement that persistent device state be keyed by stable hardware identity rather than a YAML name.

AGENTS.md reference: AGENTS.md:L69-L69

Useful? React with 👍 / 👎.

}
actualWh := state.slotActualWh
d := SlotDelivery{
SlotStart: state.slotActualSlotStart, SlotEnd: state.slotActualDirective.SlotEnd,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the scored slot's end across missing-plan intervals

If a slot accumulates more than 50 Wh of planned energy, the plan becomes unavailable, and the next valid directive belongs to a later slot, updateSlotDeliveryMetrics scores the accumulated slot after its missing-plan branch has cleared slotActualDirective. Reading SlotEnd here therefore produces a zero time. journal.SlotSpan persists that as a negative end timestamp, omits the power summaries because the duration is negative, and the next prune deletes the record as ancient history. Preserve the completed slot's boundary independently of the directive that is cleared during missing-plan intervals.

Useful? React with 👍 / 👎.

@frahlg
frahlg merged commit fd4426a into master Nov 8, 2026
23 checks passed
frahlg added a commit to srcfl/ftw-cloud that referenced this pull request Nov 8, 2026
* feat(mcp): events_query answers what happened at a time

A new read tool asks the box's control journal (events.get, scope
ftw.events.read) about a past window: each span in which a device, a
source, dispatch or the mode held one verdict, each scored plan slot,
and the notices the box sent. It is for questions such as why the app
said FTW was not in control this morning, what happened at 06:14, or
whether the battery did what FTW asked.

- Inputs: since and until (RFC 3339, Unix ms, or a local time read in
  the home's zone; the last 24 hours by default), source, min_severity
  (warning or alarm) and limit (at most protocol.EventsMax).
- Each span gives from and to (or ongoing) in the zone the box names in
  hello_ok, UTC without one; duration; kind, source, status, reason,
  severity and mode; askedW beside measuredW as mean, min and max; and
  the latest verdict's numbers, a *_ms time written as a time.
- When the box cut the answer, next names the until that reads on: the
  oldest start or time of each list the box cut, the later of the two.
- A box from before the journal answers E_UNKNOWN_OP for events.get with
  no field; the model hears "this box does not keep a control journal
  yet; update FTW". A refused src and E_UNAVAILABLE for the journal get
  their own words.

The stub box answers events.get with spans and notices and records each
query. Tests cover the default window and zone, a local since, Unix ms,
source, severity, limit, paging, refused arguments and the old box.
events_query stays off the public profile until it is reviewed there;
events_list stays an unsupported name.

Needs srcfl/ftw feat/control-journal (go/protocol EventSpan, Events with
spans and notices). internal/mcp grows by about 330 lines of Go without
tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(protocol): pin srcfl/ftw#1982 for events.get

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): events_query paging always gets further

A page full of spans from one moment gave a next until at that same
moment, so a caller following next never ended. It now steps past that
moment and says what that may leave out. A time at or before 1970 is
refused, since the box reads 0 as no bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant