Repository navigation
feat(control): a journal of every change in the answer to "Are we in control?" - #1982
Conversation
…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>
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 👍 / 👎.
* 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>
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.control.not_followingbecomes a notification, after its threshold. Limited, blocked and quiet states never do.state.eventsheld start/stop only, with no route. The agent readevents.getwas in the contract but answeredE_UNKNOWN_OP, and FTW Cloud's MCP server told models that events were unavailable.notification_logrow was lost that morning todatabase 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.
/api/statusserves it, each driver's health, whether the site may dispatch (the app's "FTW is not in control"), and the mode (api.Server.JournalVerdicts).go/internal/journalturns 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.control.State.SlotScoredhands 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).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.GET /api/control/journal?since&until&subject&driver&severity&limit: spans overlapping the window, newest first, with the notifications in it.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/Eventsingo/protocolgain fields compatibly;eventsstays for older readers.notification_logrow the database is too busy for is retried in the background (1 s to 5 min) instead of dropped.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
-racewhere 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 aStatsEvery, 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 afterCloseAllstart 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_jsonkept 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:SlotScoredgets 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.no_contact/readings_lost/alarmat 08:47:28 andsite:dispatchwentblocked/site_meter_stale/warningat 08:47:58, each closing the span before it at that second, with asked and measured power on every span.make verifypasses (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:
CloseAllreopened subjects. Shutdown now stops and joins the goroutine first, flushes take turns, andCloseAllstops later samples.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;severityandlimitare 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
livewhile dispatch was blocked); a slot span is the fleet's average power over the slot;srccan only name a source the box still has.Related
events_queryMCP tool readsevents.get(paired ftw-cloud PR to follow once this merges, since it pins the protocol module).🤖 Generated with Claude Code