feat(notify): needs-input trigger — notifier seam at the chat tailer's conversational-state edge #99
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#99
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
internal/chat/tailer.go, thechat.State != lastStateedge fed by adapter-ownedReadChat, ADR-0038) — the same injected-seam pattern as the metricsAFKRunEndedseam. 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.needs input/question pendingonly, with a short flap debounce (~2–3 s: a transition that reverts to working within the window fires nothing). No periodic re-reminders.<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/:idinside the PWA (never a forge URL).Acceptance criteria
<repo>~<label> needs input, truncated question text,tag = run-id, and the/runs/:idroute; tapping the notification lands in that run's chatBlocked by
Agent Brief
Category: enhancement
Summary: Fire a push notification when an instance transitions into a state that awaits the operator (
needs_inputorquestion), 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
ReadChatand already tracks the previous conversational state, comparing it against the fresh read (chat.State != lastState) to publishrun.messages.changedbus events. Nothing observes that transition for notification purposes. The landed push package exposes an async, never-blocking, never-erroring sender —Broadcast(Payload)fans aPayload{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") orprovider.StateQuestion("question") from any other state, one notification is broadcast:<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).questionstate), use the dialog'sPrompt; for a plainneeds_inputtransition use the latest assistant text, or a generic "needs your input" body if no text is available./runs/<id>) — always a PWA-internal path, never a forge URL.Semantics:
now func() time.Timeclock (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.Key interfaces:
push.Senderis 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 satisfyingBroadcast(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.Acceptance criteria:
needs_inputorquestionproduces 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<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 chatOut of scope:
Landing audit — PR #103 →
Closes #99: PASSci / native (pull_request)success (7m31s) — relied on as the verification signal; not re-run.origin/main;git merge-treeshows no conflicts.Closes #99; headafk/99.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/chatdoes not importinternal/push(nil-safe injected closure seam).notifyGateobserve/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.-racedata race ininternal/push(#98 code, untouched here) — recommend filing as a follow-up bug.Verdict: PASS, pending the operator's free-text merge go-ahead.