Skip to content

feat(appproto): push the plan to streaming phones when the box replans - #1643

Merged
frahlg merged 5 commits into
masterfrom
claude/plan-push
Oct 15, 2026
Merged

frahlg merged 5 commits into
masterfrom
claude/plan-push

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Problem

Fredrik plugged his car in at home. The box's own page showed the new charging plan; the iPhone app did not. The app read the car's plan only when the car started or stopped drawing power, the box sent its plan message only when asked or right after a command, and the planner noticed a plug-in only at its reactive check: every 10 s, and 30 s after its last replan.

What this changes

  • The plug-in replans at once. A charger's confirmed cable (charging.connected) requests a replan, past the reactive check and its gap (cmd/ftw/plan_edges.go). The event already waits 2 s after an unplug, so a flapping cable does not storm the optimizer, and a newer request replaces one still waiting. Goals, schedules, levels and solar-only already request their own replans. A hold enters no plan (the spec builder never reads it), so it does not replan.
  • The planner says when the plan moves. mpc.Service.OnPlanChange (now a list) is told when a replan starts and the plan turns pending, when a plan is published (before the diagnostic write), and when the newest replan ends without one. Not when a replaced request ends.
  • appproto.PlanFeed sends the first change at once, each session on its own goroutine. Changes inside the next 100 ms are one follow-up. No periodic timer, only that one wait after a push.
  • A session sends one plan at a time. writePlan reads and sends under planMu, so the newest plan lands last, and skips a push of the plan the session already has (hash without the uptime). Only streaming sessions whose role carries ftw.plan.read, as the grant stands now, get pushes; a viewer, a demoted phone, an unsubscribed session and a watch exchange get nothing.
  • Plan.chargers (PlanCharger): per charger its loadpoint id, an optional carId (the session's car, empty until Core keeps cars), plug state, pending, outdated, gridDeferred, goalRev (moves when the goal, ready time, schedule or solar-only choice changes, wherever), the windows ahead, the total and the shortfall. Read from the same snapshot and windows as GET /api/loadpoints (api.MaxPlanWindows). While a new plan is made the windows are those the box goes on acting on, marked pending; the HTTP route empties them. Older apps ignore the field.
  • Nothing drops the session for size. The far slots give way first, then each charger's far windows.
  • Keyed by charger, not car: in the car contract (feat(contract): cars apart from chargers #1647, registry: cars apart from chargers, their ops and car.which ftw-webapp#92) cars means real cars. feat(cars): the car apart from the charger, in Core #1650 replans through its Replan hook when the session's car changes, and the push follows.

Measured

Laptop, loopback, a real ftwcloudd built from srcfl/ftw-cloud#15, the relay unreachable. The box ran in process with this branch's planner (Go DP), loadpoint manager on the event bus, plug-in replan, plan feed, app uplink, cloud link and tunnel, and served GET /api/loadpoints. Core's production reactive check stayed on (10 s, 30 s gap). The app was FTWKit's AppModel (srcfl/ftw-app#73) paired through FTW Cloud with Now's asks on. "On screen" means LoadpointsModel has the car plugged in with its windows and PlanShare.entry (Now's strip, the plan view, the widget record the watch gets) has the charge. The harness is not in either repo.

8 plug-ins median max
plug-in → cable on the phone (pending) 3 ms 16 ms
plug-in → plan on screen 23 ms 39 ms
plug-in → planner publishes 0–1 ms

One read of /api/loadpoints per plug-in, after the plan was on screen. The Go DP solves this plan in a millisecond; on a Pi with Energyplan the plan lands when the solve ends, and the cable and "pending" still land at once.

TestAPlugInReachesAStreamingPhoneAsAPlan (this PR) plugs in right after a replan, inside the 30 s gap: cable on the phone in ~2 ms, the plan with its windows in ~102 ms (the follow-up after the pending push).

An old box (no push, no plug-in replan): see srcfl/ftw-app#73. With FTW Cloud's cable news, plug-in → plan on screen 1.0–18.3 s, median 7.0 s, over 6 plug-ins; the worst case is the reactive check after a recent replan (up to 40 s) plus one 15 s ask, about 55 s.

Tests

  • appproto: a replan pushes the plan with the charger; the first change goes at once and a burst is one follow-up of the newest plan; the same plan is not pushed twice (a plan.get is still answered); racing pushes land the newest plan last; a viewer and a demoted phone get nothing; nothing before the stream or after close; a watch exchange is not on the feed; 193 slots × two chargers and 24 chargers × 32 windows fit; TestThePlanWithChargersVector pins the bytes the app decodes.
  • mpc: OnPlanChange hears a start, a publish and a failed newest replan, every listener, and not a replaced request.
  • cmd/ftw: the plug-in test above; a pending plan keeps the windows the box acts on and drops the shortfall.
  • go test -race on appproto, mpc, api, appuplink, cmd/ftw, and protocol: pass.

Privacy

The relay and FTW Cloud now see bulk frames when the box replans, not only after a request or a command. Scheduled replans come every 15 minutes anyway; a plug-in is now visible as timing. Lane 0 is unchanged.

Coordination

Pair

App side: srcfl/ftw-app#73.

🤖 Generated with Claude Code

frahlg and others added 2 commits October 15, 2026 19:08
The app read the car's plan only when the car started or stopped drawing,
so a car plugged in for a night charge kept the old plan on the phone
while the box's own page showed the new one.

The planner now says when what a plan reader sees moves: a published
plan, or the newest replan ending without one. One feed per box waits
250 ms, so a change that replans twice is one push, skips while a newer
plan is being made, and sends the session plan to every streaming phone
whose role may read the plan. The plan carries each car's windows, plug
state, pending/outdated and shortfall, read the way GET /api/loadpoints
reads them.

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

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:56:03.291577Z ca632c1 Draft marked ready
ℹ️ 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: ca632c1bb9

ℹ️ 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".

// it when the replacement fails. A later retry must publish a new
// plan before dispatch resumes; queuing work alone is not enough.
s.failedReplanGeneration = request.generation
changed = s.planChanged

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 Mark failed-replan pushes as stale

When the newest replan fails within mpc.MaxPlanAge of the previous plan, this callback pushes a change even though sendPlan still builds the payload from Plans.Latest(), and planFrom marks staleness only by plan age. The phone therefore receives the old slots with stale: false while Core has stopped dispatching that plan; sites without configured chargers do not even receive a per-car Outdated hint. Pass the PlanSnapshot().Outdated state into the wire plan before sending the failure notification.

AGENTS.md reference: AGENTS.md:L22-L23

Useful? React with 👍 / 👎.

Comment thread go/internal/appproto/handler.go Outdated
Comment on lines +1126 to +1128
if h.cfg.PlanCars != nil {
body.Cars = h.cfg.PlanCars.PlanCars(h.cfg.Clock.Now())
}

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 Bound car data before retrying slot truncation

For configurations with sufficiently many loadpoints, windows, or long loadpoint IDs, Cars alone can exceed the largest 16 KiB bulk bucket. The loop below only truncates Slots, so it eventually reaches one slot and returns an error without ever reducing the oversized car payload; an unsolicited push is silently lost, while a plan.get error propagates from Handle and closes the app session. Cap or progressively trim the car/window data as part of the same size-fitting loop.

Useful? React with 👍 / 👎.

…r beside it

A car is its own entity apart from the charger (#1647), and in
that contract cars means real cars. The plan's per-charger share is now
chargers/PlanCharger, keyed by the loadpoint id, with an optional carId:
the session's car, empty until Core keeps cars. Whatever applies
session.car.set requests a replan, and the plan follows as every replan
does. The vector pins carId too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-app that referenced this pull request Oct 15, 2026
The app read the car's plan from /api/loadpoints only when the car
started or stopped drawing, so a car plugged in for a night charge kept
the old plan on the phone while the box's page showed the new one.

srcfl/ftw#1643 pushes the session plan when the box replans, with each
car's windows, cable, pending/outdated and shortfall. The app takes them
at once: Now's strip, the plan view, the car view and PlanShare (the
widgets and the watch) move with the push, and a cable that moved is
read once for the rest of the car. A box whose plan comes without cars
is read each quarter while a car is plugged in, and every 15 s while it
says the plan is being made or for 30 s after FTW Cloud says a cable
moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-app that referenced this pull request Oct 15, 2026
…de it

srcfl/ftw#1643 now sends chargers/PlanCharger keyed by the loadpoint id,
with an optional carId (the session's car, empty until Core keeps cars),
since in the car contract (srcfl/ftw#1647) cars means real cars. The
vector Core pins carries carId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1643 (S2, S3, N1, N3, N4, N6, N8, N9; S1 for the app):

- A charger's confirmed cable (charging.connected) requests a replan at
  once, past the reactive check's 10 s and its 30 s gap. A hold enters no
  plan, and goals and schedules already request their own replans.
- The planner tells its listeners when a replan starts, not only when a
  plan lands or a replan fails, so the phone hears the cable and pending
  at once; while pending the windows stay those the box acts on.
  OnPlanChange appends.
- The feed sends the first change at once and a burst inside the 100 ms
  settle as one follow-up, each session on its own goroutine.
- A session reads and sends its plan under one lock, so the newest lands
  last, and skips a push of the plan it already has.
- The far windows give way after the far slots, never the session.
- Each charger carries gridDeferred and goalRev, so the push is enough for
  the car view and a goal changed elsewhere is read once.

Measured in TestAPlugInReachesAStreamingPhoneAsAPlan with the production
reactive check, the plug-in right after a replan: cable on the phone in
~2 ms, the plan with its windows in ~102 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-app that referenced this pull request Oct 15, 2026
The app read the car's plan from /api/loadpoints only when the car
started or stopped drawing, so a car plugged in for a night charge kept
the old plan on the phone while the box's page showed the new one.

srcfl/ftw#1643 pushes the session plan when the box replans, with each
car's windows, cable, pending/outdated and shortfall. The app takes them
at once: Now's strip, the plan view, the car view and PlanShare (the
widgets and the watch) move with the push, and a cable that moved is
read once for the rest of the car. A box whose plan comes without cars
is read each quarter while a car is plugged in, and every 15 s while it
says the plan is being made or for 30 s after FTW Cloud says a cable
moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-app that referenced this pull request Oct 15, 2026
…de it

srcfl/ftw#1643 now sends chargers/PlanCharger keyed by the loadpoint id,
with an optional carId (the session's car, empty until Core keeps cars),
since in the car contract (srcfl/ftw#1647) cars means real cars. The
vector Core pins carries carId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit to srcfl/ftw-app that referenced this pull request Oct 15, 2026
…able

Review of app#73 (S1, N13) and the paired Core changes in srcfl/ftw#1643:

- Each charger on the plan now carries gridDeferred and goalRev. The car
  view takes gridDeferred from the push; a goal revision that moves (a
  goal changed on the box's page or another phone) reads the car once.
- While a new plan is made the push says pending and keeps the windows
  the box goes on acting on, so Now's strip does not blink.
- A read that was on the wire while a newer push came keeps the push's
  cable as well as its plan.
- An older box is asked every 15 s for a minute after a cable moved: it
  notices a cable within 10 s, or 40 s after its own replan.
- The demo box says pending when its replan starts, as Core now does,
  and can hold an answer back (answerDelayMs) to test a stale read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 6a40212 into master Oct 15, 2026
18 checks passed
frahlg added a commit to srcfl/ftw-app that referenced this pull request Oct 15, 2026
* iPhone: the plan reaches the car the moment the box replans

The app read the car's plan from /api/loadpoints only when the car
started or stopped drawing, so a car plugged in for a night charge kept
the old plan on the phone while the box's page showed the new one.

srcfl/ftw#1643 pushes the session plan when the box replans, with each
car's windows, cable, pending/outdated and shortfall. The app takes them
at once: Now's strip, the plan view, the car view and PlanShare (the
widgets and the watch) move with the push, and a cable that moved is
read once for the rest of the car. A box whose plan comes without cars
is read each quarter while a car is plugged in, and every 15 s while it
says the plan is being made or for 30 s after FTW Cloud says a cable
moved.

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

* iPhone: read the plan's share by charger, with the session's car beside it

srcfl/ftw#1643 now sends chargers/PlanCharger keyed by the loadpoint id,
with an optional carId (the session's car, empty until Core keeps cars),
since in the car contract (srcfl/ftw#1647) cars means real cars. The
vector Core pins carries carId.

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

* iPhone: the push is enough for the car view; a stale read keeps the cable

Review of app#73 (S1, N13) and the paired Core changes in srcfl/ftw#1643:

- Each charger on the plan now carries gridDeferred and goalRev. The car
  view takes gridDeferred from the push; a goal revision that moves (a
  goal changed on the box's page or another phone) reads the car once.
- While a new plan is made the push says pending and keeps the windows
  the box goes on acting on, so Now's strip does not blink.
- A read that was on the wire while a newer push came keeps the push's
  cable as well as its plan.
- An older box is asked every 15 s for a minute after a cable moved: it
  notices a cable within 10 s, or 40 s after its own replan.
- The demo box says pending when its replan starts, as Core now does,
  and can hold an answer back (answerDelayMs) to test a stale read.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 15, 2026
handler.go: keep both. An agent subscribes to its decided requests and
never joins the plan feed; it reads the plan by asking, so its reads stay
counted and in the audit.

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