hub: box presence from the wait channel + delete a switched-off box at once (R-30, D2)

Slice 1: intent.Hub records one start/end per GET /api/v1/wait request and answers
Presence(customer, now): connected (hold open, or one started < 333 s = 243 s cadence + 90 s
grace ago), not connected since T, or unknown (hub up < 333 s; in memory only). The host page
shows "Box connection". One DEBUG line per presence change.

Slice 2 (operator ruling D2, 2026-10-08, 09 §3 decision 186): a host that is online by its report
clock but whose box has had no wait-channel connection for >= 360 s may be deleted at once after
the tick "I checked: the box is off". Presence is re-read at POST time; the tick alone, unknown
presence, a connected box or a shorter gap keep today's 409. The delete logs the operator channel
and saves one host_deleted_box_off event. RESET and the customer-delete cascade are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-08 14:28:42 +02:00
parent e6184353db
commit d604e624a3
9 changed files with 685 additions and 12 deletions
+5
View File
@@ -48,6 +48,11 @@ func (h *Handler) handleWait(w http.ResponseWriter, r *http.Request) {
return
}
// R-30: box presence — recorded ONCE per request (intent.Wait below runs once per heartbeat
// window), and ended on every exit path.
h.intentHub.MarkWaitStart(customerID)
defer h.intentHub.MarkWaitEnd(customerID)
var lastSeen uint64
if s := r.URL.Query().Get("gen"); s != "" {
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
+27
View File
@@ -181,3 +181,30 @@ func TestWait_CustomerIsolation(t *testing.T) {
t.Fatalf("A should time out on its own gen 0 (isolation); body = %q", got)
}
}
// R-30 slice 1: each wait REQUEST marks one start and one end on the intent hub's presence — open
// while held, ended on return. Without the marks the box reads "never seen"; without the end mark it
// would read connected forever.
func TestWait_MarksBoxPresence(t *testing.T) {
h, st, hub := newWaitHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c", RetrievalPassword: "pw", APIKey: "CKEY", ConfigJSON: "{}"}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
t0 := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
clock := t0.Add(-time.Hour)
hub.SetClock(func() time.Time { return clock }) // born an hour before t0
clock = t0
shrinkWaitTiming(t, 2*time.Second, 500*time.Millisecond)
ch := doWaitAsync(h, "CKEY", "0")
time.Sleep(50 * time.Millisecond)
if p := hub.Presence("c", t0.Add(time.Hour)); p.State != intent.PresenceConnected {
t.Fatalf("while the wait is held: %+v, want connected (open hold)", p)
}
hub.Bump("c")
recvRR(t, ch, time.Second)
p := hub.Presence("c", t0.Add(intent.PresenceConnectedWindow+time.Second))
if p.State != intent.PresenceNotConnected || p.NeverSeen || !p.Since.Equal(t0) {
t.Fatalf("after the wait returned: %+v, want not_connected since t0 (start and end both marked)", p)
}
}