feat(notify): done-signal trigger — notify when an AFK run opens its PR/change request (#100) #110

Merged
dominik.polakovics merged 1 commit from afk/100 into main 2026-07-10 18:23:28 +02:00

Fires one Web Push notification when the reaper detects an AFK run's done-signal — "the agent finished; review and merge is on you now."

What changed

  • Seam (internal/afk/afk.go, internal/afk/notify.go): afk.Options.Notify func(Notification) — nil-safe, injected, the exact AFKRunEnded / needs-input-trigger idiom. afk.Notification mirrors push.Payload field-for-field so afk never imports push (ADR-0038 import boundary).
  • Fire site (internal/afk/reaper.go): inside reapRun, beside the metrics report, only when the outcome is success — it rides the reaper's idempotent claim, so exactly once per run with no extra debounce state. Deaths, timeouts, neutral Stop, three-strikes pause: no send. Manual instances never reach the reaper.
  • Pull threading (internal/tracker/pulls.go): new DonePull(pulls, head) (PullRef, bool) returns the winning open-or-merged head-matching pull; PRPresent is now its boolean projection, and both project from one shared bestPull collision loop so they can never diverge. reapActiveRuns threads the tracker handle + matched pull into reapRun.
  • Payload: title <repo>~<label> opened PR #<n> (forge) / … opened change request #<n> (builtin — the glossary reserves "PR" for the forge object); body = the pull's title via the heavier Pull(n) detail call, degrading to the bare <noun> #<n> form on fetch error or empty title (the send still fires, the reap is unaffected); tag = run ID (replaces the same run's needs-input item on the lock screen); route /runs/<id>, always PWA-internal, never the forge URL.
  • Wiring (cmd/lab/main.go): closure over pushSender.Broadcast, which is async — the reaper never blocks on gateway I/O.
  • CONTEXT.md: done-signal-trigger domain term + relationships bullet.

Acceptance criteria → proof

  • One send with title/body/tag/route, hermetic: TestReap_successNotifiesDoneSignal (fake tracker + capture notifier; also asserts the second sweep does not re-fire).
  • Both bindings equivalent: builtin covered above, forge via TestReap_forgeBindingSaysPR.
  • Degraded body on failed detail fetch, send still fires: TestReap_notifyDetailFetchDegrades.
  • Death / timeout / three-strikes-to-pause / neutral Stop → zero sends: TestReap_failuresDoNotNotify.
  • Nil notifier = no behavior change: the entire pre-existing afk suite runs with a nil Notify and stays green.
  • Sender never delays the reaper: the seam receives push.Sender.Broadcast, whose fire-and-forget property is #98's tested contract (TestBroadcastNeverBlocks).

Design note

The title's detail fetch (trk.Pull(n)) is synchronous on the reap path — deliberately, per the issue brief: it happens once per run, only on the success path, tolerates failure without affecting the reap, and is bounded by the tracker clients' existing 30s per-request timeout (the same bound as the Pulls listing on the same tick). Making it async would buy little and cost ordering guarantees.

go build ./..., go vet ./..., and the full go test ./... are green (the tmuxx integration tests too, once sh is on PATH — a sandbox quirk, not a code issue).

Closes #100

🤖 Generated with Claude Code

https://claude.ai/code/session_017FnXE9vzzwmj6WVrDkoknZ

Fires one Web Push notification when the reaper detects an AFK run's done-signal — "the agent finished; review and merge is on you now." ## What changed - **Seam** (`internal/afk/afk.go`, `internal/afk/notify.go`): `afk.Options.Notify func(Notification)` — nil-safe, injected, the exact `AFKRunEnded` / needs-input-trigger idiom. `afk.Notification` mirrors `push.Payload` field-for-field so afk never imports push (ADR-0038 import boundary). - **Fire site** (`internal/afk/reaper.go`): inside `reapRun`, beside the metrics report, only when the outcome is success — it rides the reaper's idempotent claim, so exactly once per run with no extra debounce state. Deaths, timeouts, neutral Stop, three-strikes pause: no send. Manual instances never reach the reaper. - **Pull threading** (`internal/tracker/pulls.go`): new `DonePull(pulls, head) (PullRef, bool)` returns the winning open-or-merged head-matching pull; `PRPresent` is now its boolean projection, and both project from one shared `bestPull` collision loop so they can never diverge. `reapActiveRuns` threads the tracker handle + matched pull into `reapRun`. - **Payload**: title `<repo>~<label> opened PR #<n>` (forge) / `… opened change request #<n>` (builtin — the glossary reserves "PR" for the forge object); body = the pull's title via the heavier `Pull(n)` detail call, degrading to the bare `<noun> #<n>` form on fetch error or empty title (the send still fires, the reap is unaffected); tag = run ID (replaces the same run's needs-input item on the lock screen); route `/runs/<id>`, always PWA-internal, never the forge URL. - **Wiring** (`cmd/lab/main.go`): closure over `pushSender.Broadcast`, which is async — the reaper never blocks on gateway I/O. - **CONTEXT.md**: done-signal-trigger domain term + relationships bullet. ## Acceptance criteria → proof - One send with title/body/tag/route, hermetic: `TestReap_successNotifiesDoneSignal` (fake tracker + capture notifier; also asserts the second sweep does not re-fire). - Both bindings equivalent: builtin covered above, forge via `TestReap_forgeBindingSaysPR`. - Degraded body on failed detail fetch, send still fires: `TestReap_notifyDetailFetchDegrades`. - Death / timeout / three-strikes-to-pause / neutral Stop → zero sends: `TestReap_failuresDoNotNotify`. - Nil notifier = no behavior change: the entire pre-existing afk suite runs with a nil `Notify` and stays green. - Sender never delays the reaper: the seam receives `push.Sender.Broadcast`, whose fire-and-forget property is #98's tested contract (`TestBroadcastNeverBlocks`). ## Design note The title's detail fetch (`trk.Pull(n)`) is synchronous on the reap path — deliberately, per the issue brief: it happens once per run, only on the success path, tolerates failure without affecting the reap, and is bounded by the tracker clients' existing 30s per-request timeout (the same bound as the `Pulls` listing on the same tick). Making it async would buy little and cost ordering guarantees. `go build ./...`, `go vet ./...`, and the full `go test ./...` are green (the tmuxx integration tests too, once `sh` is on PATH — a sandbox quirk, not a code issue). Closes #100 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017FnXE9vzzwmj6WVrDkoknZ
feat(notify): done-signal trigger — one push at the reaper's success reap (#100)
All checks were successful
ci / native (pull_request) Successful in 4m7s
47d04419c9
One Web Push broadcast when the reaper's terminal classification of an
AFK run is success: the fire site rides the idempotent claim inside
reapRun (the single terminal chokepoint), beside the AFKRunEnded metrics
report, so it sends exactly once per run with no debounce state. Deaths,
timeouts, a neutral Stop, and the three-strikes pause never send; manual
instances never reach the reaper.

Payload: title "<repo>~<label> opened PR #n" on a forge binding /
"… opened change request #n" on builtin (the glossary reserves "PR" for
the forge object); body is the pull's title via the heavier Pull(n)
detail call — the hot-path PullRef deliberately drops it — degrading to
the bare "<noun> #n" form on a fetch error or empty title (the send
still fires and the reap is unaffected; every tracker REST call is
already bounded at 30s per request, so no extra timeout is layered on).
Tag = run ID, so the done item replaces the same run's needs-input item
on the lock screen; route /runs/<id>, always PWA-internal, never the
forge URL.

The matched pull now flows to the reap: tracker.DonePull returns the
winning open-or-merged head-matching PullRef, PRPresent becomes its
boolean projection, and both project from the one bestPull collision
loop so they can never diverge; reapActiveRuns threads trk + donePull
into reapRun. The seam is an injected nil-safe func on afk.Options (the
AFKRunEnded idiom, same shape as chat's needs-input trigger); cmd/lab
wires a closure over push.Sender.Broadcast, which is async — afk never
imports push. CONTEXT.md gains the done-signal-trigger domain term.

Closes #100

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FnXE9vzzwmj6WVrDkoknZ
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!110
No description provided.