feat(notify): needs-input trigger — edge-triggered, debounced push at the chat tailer's state edge (#99) #103

Merged
dominik.polakovics merged 1 commit from afk/99 into main 2026-07-10 17:45:17 +02:00

Closes #99

What

When a run's conversational state transitions into needs_input or question, one Web Push notification is broadcast — the first real trigger plugged into #98's plumbing.

  • Injected seam, not a bus subscriber (internal/chat/notify.go + the tailer's transition edge): chat.Options.Notify func(Notification) follows the AFKRunEnded func-field idiom — nil-safe (nil = trigger absent, zero behavior change), injected by cmd/lab as a closure over push.Sender.Broadcast so internal/chat never imports internal/push. Broadcast is async, so the tick loop never blocks on gateway I/O.
  • Edge-triggered + flap debounce (2s): a per-tailer notifyGate on the injected Options.Now clock arms on the edge and fires at the end of the first tick past the deadline — after that tick's read, so a revert observed on the deadline tick cancels instead of firing. needs_input↔question is one episode (newest body wins, deadline unchanged); leaving the set disarms and re-arms the next episode; no periodic re-reminders.
  • Re-adoption re-fires deliberately (empty first-tick prev counts as non-notify); a run ending (incl. the dead-session sweep) cancels the goroutine and the armed gate dies unfired.
  • Payload: title <repo>~<label> needs input (session name verbatim, no store lookup), body = dialog prompt → newest visible assistant text (thinking blocks and non-text kinds never reach a lock screen) → needs your input, truncated rune-safely to 150; tag = run ID (lock-screen coalescing); route /runs/<id>, always PWA-internal.
  • CONTEXT.md: new needs-input trigger domain term + relationship bullet.

Acceptance criteria → tests (internal/chat/notify_test.go)

  • exactly one send per transition; non-notify transitions and run-end silent → TestNotifyGate_edgeFiresOnceAfterWindow, _nonNotifyNeverArms, TestTailer_notifiesOnceOnTransition, _nonNotifyTransitionSilent, _runEndsBeforeDebounceFiresNothing
  • flap fires nothing (injected fake clock) → TestNotifyGate_flapFiresNothing
  • first-tick adoption fires once → TestNotifyGate_adoptionEdgeFires, TestTailer_notifiesOnceOnAdoption
  • payload title/body/tag/route + truncation + thinking-block exclusion → TestNotifyGate_payloadFields, _bodyPrecedenceAndTruncation, _betweenNotifyStatesSameEpisode
  • nil notifier = trigger absent → TestTailer_nilNotifierIsAbsent
  • no synchronous gateway I/O → seam calls Broadcast (async by #98's contract) at the call site

Verification

go test ./... all 35 packages ok; go test -race -count=1 ./internal/chat/ ok; golangci-lint run 0 issues; go build ./cmd/... ok; gofmt clean. Reviewed twice (line-by-line + independent adversarial pass); the review's findings — thinking-text leak into the body, a one-poll debounce blind spot, dead latch state — are all fixed in this diff.

Found out of scope: pre-existing data race in internal/push (#98 code, untouched here)

go test -race ./internal/push/ fails on main: Broadcast marshals the payload once and hands the same []byte to every dispatch goroutine (sender.go:103-113), and webpush-go v1.4.0 does bytes.NewBuffer(message) then appends padding into the slice's spare capacity (webpush.go:~179-186) — with json.Marshal routinely returning cap > len, N subscriptions = N goroutines writing the same backing array. Fix is one line (s.dispatch(sub, bytes.Clone(body))), but the issue scopes push-package changes out and ticket filing was outside this run's permissions — please file it as a follow-up bug. Repro: go test -race -run TestBroadcastHappyPath ./internal/push/.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AMDvLm1axUjmT6VdMW19vY

Closes #99 ## What When a run's conversational state transitions **into** `needs_input` or `question`, one Web Push notification is broadcast — the first real trigger plugged into #98's plumbing. - **Injected seam, not a bus subscriber** (`internal/chat/notify.go` + the tailer's transition edge): `chat.Options.Notify func(Notification)` follows the `AFKRunEnded` func-field idiom — nil-safe (nil = trigger absent, zero behavior change), injected by `cmd/lab` as a closure over `push.Sender.Broadcast` so `internal/chat` never imports `internal/push`. Broadcast is async, so the tick loop never blocks on gateway I/O. - **Edge-triggered + flap debounce (2s)**: a per-tailer `notifyGate` on the injected `Options.Now` clock arms on the edge and fires at the end of the first tick past the deadline — after that tick's read, so a revert observed on the deadline tick cancels instead of firing. `needs_input↔question` is one episode (newest body wins, deadline unchanged); leaving the set disarms and re-arms the next episode; no periodic re-reminders. - **Re-adoption re-fires deliberately** (empty first-tick prev counts as non-notify); a run ending (incl. the dead-session sweep) cancels the goroutine and the armed gate dies unfired. - **Payload**: title `<repo>~<label> needs input` (session name verbatim, no store lookup), body = dialog prompt → newest *visible* assistant text (thinking blocks and non-text kinds never reach a lock screen) → `needs your input`, truncated rune-safely to 150; `tag` = run ID (lock-screen coalescing); route `/runs/<id>`, always PWA-internal. - `CONTEXT.md`: new **needs-input trigger** domain term + relationship bullet. ## Acceptance criteria → tests (`internal/chat/notify_test.go`) - exactly one send per transition; non-notify transitions and run-end silent → `TestNotifyGate_edgeFiresOnceAfterWindow`, `_nonNotifyNeverArms`, `TestTailer_notifiesOnceOnTransition`, `_nonNotifyTransitionSilent`, `_runEndsBeforeDebounceFiresNothing` - flap fires nothing (injected fake clock) → `TestNotifyGate_flapFiresNothing` - first-tick adoption fires once → `TestNotifyGate_adoptionEdgeFires`, `TestTailer_notifiesOnceOnAdoption` - payload title/body/tag/route + truncation + thinking-block exclusion → `TestNotifyGate_payloadFields`, `_bodyPrecedenceAndTruncation`, `_betweenNotifyStatesSameEpisode` - nil notifier = trigger absent → `TestTailer_nilNotifierIsAbsent` - no synchronous gateway I/O → seam calls `Broadcast` (async by #98's contract) at the call site ## Verification `go test ./...` all 35 packages ok; `go test -race -count=1 ./internal/chat/` ok; `golangci-lint run` 0 issues; `go build ./cmd/...` ok; gofmt clean. Reviewed twice (line-by-line + independent adversarial pass); the review's findings — thinking-text leak into the body, a one-poll debounce blind spot, dead latch state — are all fixed in this diff. ## Found out of scope: pre-existing data race in `internal/push` (#98 code, untouched here) `go test -race ./internal/push/` fails on main: `Broadcast` marshals the payload once and hands the same `[]byte` to every dispatch goroutine (`sender.go:103-113`), and webpush-go v1.4.0 does `bytes.NewBuffer(message)` then **appends padding into the slice's spare capacity** (webpush.go:~179-186) — with `json.Marshal` routinely returning cap > len, N subscriptions = N goroutines writing the same backing array. Fix is one line (`s.dispatch(sub, bytes.Clone(body))`), but the issue scopes push-package changes out and ticket filing was outside this run's permissions — **please file it as a follow-up bug**. Repro: `go test -race -run TestBroadcastHappyPath ./internal/push/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AMDvLm1axUjmT6VdMW19vY
One Web Push broadcast when a run's conversational state settles into
needs_input/question: a notifyGate per tailer goroutine arms on the
transition edge (empty first-tick prev deliberately re-fires re-adoption),
holds through a ~2s flap debounce on the injected clock, and fires at the
end of the first tick past the deadline — after that tick's read, so a
revert observed on the deadline tick still cancels. Leaving the notify set
disarms and re-arms the next episode; a run ending dies with the goroutine
and fires nothing.

Payload: title "<repo>~<label> needs input" (the session name verbatim),
body = dialog prompt → newest visible assistant text (thinking blocks and
non-text kinds never reach a lock screen) → literal fallback, truncated
rune-safely to 150; tag = run ID for lock-screen coalescing; route
/runs/<id>, always PWA-internal.

The seam is an injected nil-safe func on chat.Options (the AFKRunEnded
idiom, not a lossy bus subscriber); cmd/lab wires a closure over
push.Sender.Broadcast, which is async — the tick loop never blocks on
gateway I/O, and chat never imports push. CONTEXT.md gains the
needs-input-trigger domain term.

Closes #99

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMDvLm1axUjmT6VdMW19vY
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab!103
No description provided.