feat(notify): needs-input trigger — notifier seam at the chat tailer's conversational-state edge #99

Closed
opened 2026-07-10 15:11:39 +02:00 by dominik.polakovics · 2 comments

What to build

Push a notification when any instance (manual or AFK run) transitions into needs input or question pending, so the operator's phone/desktop buzzes exactly when they are the blocker.

  • Injected notifier seam at the tailer's transition point (internal/chat/tailer.go, the chat.State != lastState edge fed by adapter-owned ReadChat, ADR-0038) — the same injected-seam pattern as the metrics AFKRunEnded seam. NOT a bus subscriber: the event bus is lossy by design and a dropped event would silently miss a notification. The seam sees provider-agnostic state strings, so it covers claude-code and codex alike with no per-provider logic.
  • Edge-triggered into needs input / question pending only, with a short flap debounce (~2–3 s: a transition that reverts to working within the window fires nothing). No periodic re-reminders.
  • Restart re-adoption: the tailer's first tick on a re-adopted run already sitting in a notify-state re-fires the edge — deliberate (a question that arrived during downtime still reaches the operator) and harmless under tag coalescing.
  • Payload: title <repo>~<label> needs input, body = the pending question/dialog text truncated to ~150 chars, tag = <run-id> (one lock-screen item per instance; a newer question replaces the stale one), tap deep-links to /runs/:id inside the PWA (never a forge URL).
  • Sends go through the async sender from the plumbing slice; the tailer tick loop must never block on gateway I/O.

Acceptance criteria

  • Transition into needs input or question pending produces exactly one send (hermetic: fake sender records payloads); transitions between non-notify states, and out of notify states (including the dead-session sweep ending a run), produce none
  • A flap (into notify-state and back within the debounce window) produces no send
  • First-tick adoption of a run already in a notify-state produces one send
  • Payload carries title <repo>~<label> needs input, truncated question text, tag = run-id, and the /runs/:id route; tapping the notification lands in that run's chat
  • A blocked or failing sender never delays the tailer tick (seam is async at the call site)

Blocked by

## What to build Push a notification when any instance (manual or AFK run) transitions into *needs input* or *question pending*, so the operator's phone/desktop buzzes exactly when they are the blocker. - **Injected notifier seam at the tailer's transition point** (`internal/chat/tailer.go`, the `chat.State != lastState` edge fed by adapter-owned `ReadChat`, ADR-0038) — the same injected-seam pattern as the metrics `AFKRunEnded` seam. NOT a bus subscriber: the event bus is lossy by design and a dropped event would silently miss a notification. The seam sees provider-agnostic state strings, so it covers claude-code and codex alike with no per-provider logic. - **Edge-triggered into** `needs input` / `question pending` only, with a short flap debounce (~2–3 s: a transition that reverts to *working* within the window fires nothing). No periodic re-reminders. - **Restart re-adoption**: the tailer's first tick on a re-adopted run already sitting in a notify-state re-fires the edge — deliberate (a question that arrived during downtime still reaches the operator) and harmless under tag coalescing. - **Payload**: title `<repo>~<label> needs input`, body = the pending question/dialog text truncated to ~150 chars, `tag = <run-id>` (one lock-screen item per instance; a newer question replaces the stale one), tap deep-links to `/runs/:id` inside the PWA (never a forge URL). - Sends go through the async sender from the plumbing slice; the tailer tick loop must never block on gateway I/O. ## Acceptance criteria - [ ] Transition into *needs input* or *question pending* produces exactly one send (hermetic: fake sender records payloads); transitions between non-notify states, and out of notify states (including the dead-session sweep ending a run), produce none - [ ] A flap (into notify-state and back within the debounce window) produces no send - [ ] First-tick adoption of a run already in a notify-state produces one send - [ ] Payload carries title `<repo>~<label> needs input`, truncated question text, `tag = run-id`, and the `/runs/:id` route; tapping the notification lands in that run's chat - [ ] A blocked or failing sender never delays the tailer tick (seam is async at the call site) ## Blocked by - #98
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Fire a push notification when an instance transitions into a state that awaits the operator (needs_input or question), hooked as an injected seam at the chat tailer's state-transition edge. The blocker (#98) has landed: the Web Push plumbing is on main and this slice plugs the first real trigger into it.

Current behavior:
The chat tailer polls each live run through the provider's ReadChat and already tracks the previous conversational state, comparing it against the fresh read (chat.State != lastState) to publish run.messages.changed bus events. Nothing observes that transition for notification purposes. The landed push package exposes an async, never-blocking, never-erroring sender — Broadcast(Payload) fans a Payload{Title, Body, Tag, Route} out to every stored subscription, doing its own store read off the caller's goroutine — but the only caller today is the settings page's Send test endpoint.

Desired behavior:
When a run's conversational state transitions into provider.StateNeedsInput ("needs_input") or provider.StateQuestion ("question") from any other state, one notification is broadcast:

  • Title: <repo>~<label> needs input — the run's session name is already the <repo>~<label> string, so derive it from there rather than adding a store lookup to the tail loop (repo name is not otherwise in scope at this site).
  • Body: the pending question text, truncated to ~150 characters: when the chat carries a pending dialog (the question state), use the dialog's Prompt; for a plain needs_input transition use the latest assistant text, or a generic "needs your input" body if no text is available.
  • Tag: the run ID — one lock-screen item per instance; a newer question replaces a stale one via tag coalescing.
  • Route: the run's chat route (/runs/<id>) — always a PWA-internal path, never a forge URL.

Semantics:

  • Edge-triggered only. Transitions between non-notify states, transitions out of notify states, identity re-reads, and a run ending (including the dead-session sweep) fire nothing. No periodic re-reminders.
  • Flap debounce (~2–3 s): a transition into a notify state that reverts to a non-notify state within the window fires nothing. There is no existing debounce helper to reuse; the established pattern is the injected now func() time.Time clock (faked in tests with the shared fake clock). Note the tailer's poll ticker is real-time (~1 s) and not clock-injected — design the debounce so its decision logic is testable with the injected clock rather than the ticker.
  • Restart re-adoption re-fires deliberately: the tailer's first read of a re-adopted run seeds from an empty previous state, so a run already sitting in a notify state fires once on adoption — a question that arrived during downtime still reaches the operator, and tag coalescing makes it harmless.
  • The tick loop never blocks on gateway I/O. The landed sender is already fire-and-forget at the call site, so this holds by construction — the seam must not add any synchronous gateway work.

Key interfaces:

  • push.Sender is a concrete struct (no interface) injected as a nil-safe pointer elsewhere (the HTTP server leaves push routes unmounted when nil) — mirror that: the chat service's options gain an optional notifier, and a nil notifier means the trigger is simply absent. For hermetic trigger tests, either define a minimal local notifier interface (e.g. something satisfying Broadcast(push.Payload)) to record payloads, or follow the landed recipe: real sender + httptest fake gateway + Flush().
  • provider.StateNeedsInput / provider.StateQuestion — the two notify states; the seam sees provider-agnostic state strings, so claude-code and codex are covered with no per-provider logic.
  • provider.Chat.PendingDialog (Dialog.Prompt) — the question text source at the transition site.
  • This is an injected seam at the tailer's transition edge, not an event-bus subscriber — the bus is lossy by design and a dropped event would silently swallow a notification.

Acceptance criteria:

  • A transition into needs_input or question produces exactly one broadcast (hermetic: fake notifier or fake gateway records payloads); transitions between non-notify states, out of notify states, and a run ending via the dead-session sweep produce none
  • A flap (into a notify state and back within the debounce window) produces no send, verified with the injected clock
  • First-tick adoption of a run already in a notify state produces exactly one send
  • Payload carries title <repo>~<label> needs input, the question/dialog text truncated to ~150 chars, tag = run ID, and the run's chat route; tapping the notification lands in that run's chat
  • A nil notifier disables the trigger without any other behavior change
  • The tailer tick path performs no synchronous gateway I/O (the seam call is fire-and-forget at the call site)

Out of scope:

  • The done-signal trigger (#100) — separate seam at the reaper
  • Terse/content-free mode (#101) — payloads here carry full content
  • Periodic re-reminders or escalation
  • Per-device or per-run notification targeting (broadcast-to-all is the v1 model)
  • Changes to the push package beyond (optionally) a narrow notifier interface — sender semantics, subscription lifecycle, and service-worker behavior are #98's landed contract
  • Focus/"am-I-looking" suppression (deliberately rejected: a service worker that swallows a push gets its subscription revoked by iOS Safari)
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Fire a push notification when an instance transitions into a state that awaits the operator (`needs_input` or `question`), hooked as an injected seam at the chat tailer's state-transition edge. The blocker (#98) has landed: the Web Push plumbing is on main and this slice plugs the first real trigger into it. **Current behavior:** The chat tailer polls each live run through the provider's `ReadChat` and already tracks the previous conversational state, comparing it against the fresh read (`chat.State != lastState`) to publish `run.messages.changed` bus events. Nothing observes that transition for notification purposes. The landed push package exposes an async, never-blocking, never-erroring sender — `Broadcast(Payload)` fans a `Payload{Title, Body, Tag, Route}` out to every stored subscription, doing its own store read off the caller's goroutine — but the only caller today is the settings page's *Send test* endpoint. **Desired behavior:** When a run's conversational state transitions **into** `provider.StateNeedsInput` ("needs_input") or `provider.StateQuestion` ("question") from any other state, one notification is broadcast: - **Title:** `<repo>~<label> needs input` — the run's session name is already the `<repo>~<label>` string, so derive it from there rather than adding a store lookup to the tail loop (repo name is not otherwise in scope at this site). - **Body:** the pending question text, truncated to ~150 characters: when the chat carries a pending dialog (the `question` state), use the dialog's `Prompt`; for a plain `needs_input` transition use the latest assistant text, or a generic "needs your input" body if no text is available. - **Tag:** the run ID — one lock-screen item per instance; a newer question replaces a stale one via tag coalescing. - **Route:** the run's chat route (`/runs/<id>`) — always a PWA-internal path, never a forge URL. Semantics: - **Edge-triggered only.** Transitions between non-notify states, transitions out of notify states, identity re-reads, and a run ending (including the dead-session sweep) fire nothing. No periodic re-reminders. - **Flap debounce (~2–3 s):** a transition into a notify state that reverts to a non-notify state within the window fires nothing. There is no existing debounce helper to reuse; the established pattern is the injected `now func() time.Time` clock (faked in tests with the shared fake clock). Note the tailer's poll ticker is real-time (~1 s) and not clock-injected — design the debounce so its decision logic is testable with the injected clock rather than the ticker. - **Restart re-adoption re-fires deliberately:** the tailer's first read of a re-adopted run seeds from an empty previous state, so a run already sitting in a notify state fires once on adoption — a question that arrived during downtime still reaches the operator, and tag coalescing makes it harmless. - **The tick loop never blocks on gateway I/O.** The landed sender is already fire-and-forget at the call site, so this holds by construction — the seam must not add any synchronous gateway work. **Key interfaces:** - `push.Sender` is a concrete struct (no interface) injected as a nil-safe pointer elsewhere (the HTTP server leaves push routes unmounted when nil) — mirror that: the chat service's options gain an optional notifier, and a nil notifier means the trigger is simply absent. For hermetic trigger tests, either define a minimal local notifier interface (e.g. something satisfying `Broadcast(push.Payload)`) to record payloads, or follow the landed recipe: real sender + httptest fake gateway + `Flush()`. - `provider.StateNeedsInput` / `provider.StateQuestion` — the two notify states; the seam sees provider-agnostic state strings, so claude-code and codex are covered with no per-provider logic. - `provider.Chat.PendingDialog` (`Dialog.Prompt`) — the question text source at the transition site. - This is an injected seam at the tailer's transition edge, **not** an event-bus subscriber — the bus is lossy by design and a dropped event would silently swallow a notification. **Acceptance criteria:** - [ ] A transition into `needs_input` or `question` produces exactly one broadcast (hermetic: fake notifier or fake gateway records payloads); transitions between non-notify states, out of notify states, and a run ending via the dead-session sweep produce none - [ ] A flap (into a notify state and back within the debounce window) produces no send, verified with the injected clock - [ ] First-tick adoption of a run already in a notify state produces exactly one send - [ ] Payload carries title `<repo>~<label> needs input`, the question/dialog text truncated to ~150 chars, tag = run ID, and the run's chat route; tapping the notification lands in that run's chat - [ ] A nil notifier disables the trigger without any other behavior change - [ ] The tailer tick path performs no synchronous gateway I/O (the seam call is fire-and-forget at the call site) **Out of scope:** - The done-signal trigger (#100) — separate seam at the reaper - Terse/content-free mode (#101) — payloads here carry full content - Periodic re-reminders or escalation - Per-device or per-run notification targeting (broadcast-to-all is the v1 model) - Changes to the push package beyond (optionally) a narrow notifier interface — sender semantics, subscription lifecycle, and service-worker behavior are #98's landed contract - Focus/"am-I-looking" suppression (deliberately rejected: a service worker that swallows a push gets its subscription revoked by iOS Safari)
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PR #103Closes #99: PASS

  • CI: ci / native (pull_request) success (7m31s) — relied on as the verification signal; not re-run.
  • Mergeable: 0 commits behind origin/main; git merge-tree shows no conflicts.
  • Conventions: Conventional-Commits title; valid Closes #99; head afk/99.
  • Acceptance criteria: all met — edge-trigger into needs_input/question, ~2s flap debounce, first-tick adoption re-fire, payload (<repo>~<label> needs input / body precedence / tag=run-id / /runs/:id), async never-blocking sender. internal/chat does not import internal/push (nil-safe injected closure seam).
  • Logic: notifyGate observe/due state machine reviewed line-by-line — arm-on-enter, refresh-payload-without-extending-deadline on stay, disarm-on-leave, one send per episode; real-clock deadline fires on quiet/failed-read ticks. Tailer read-error refactor preserves the "no bookkeeping on failed read" retry invariant.
  • Out of scope (noted): author flagged a pre-existing -race data race in internal/push (#98 code, untouched here) — recommend filing as a follow-up bug.

Verdict: PASS, pending the operator's free-text merge go-ahead.

> *This was generated by AI while landing a PR.* **Landing audit — PR #103 → `Closes #99`: PASS** - **CI**: `ci / native (pull_request)` success (7m31s) — relied on as the verification signal; not re-run. - **Mergeable**: 0 commits behind `origin/main`; `git merge-tree` shows no conflicts. - **Conventions**: Conventional-Commits title; valid `Closes #99`; head `afk/99`. - **Acceptance criteria**: all met — edge-trigger into `needs_input`/`question`, ~2s flap debounce, first-tick adoption re-fire, payload (`<repo>~<label> needs input` / body precedence / `tag=run-id` / `/runs/:id`), async never-blocking sender. `internal/chat` does not import `internal/push` (nil-safe injected closure seam). - **Logic**: `notifyGate` observe/due state machine reviewed line-by-line — arm-on-enter, refresh-payload-without-extending-deadline on stay, disarm-on-leave, one send per episode; real-clock deadline fires on quiet/failed-read ticks. Tailer read-error refactor preserves the "no bookkeeping on failed read" retry invariant. - **Out of scope (noted)**: author flagged a pre-existing `-race` data race in `internal/push` (#98 code, untouched here) — recommend filing as a follow-up bug. Verdict: **PASS**, pending the operator's free-text merge go-ahead.
Sign in to join this conversation.
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#99
No description provided.