Design: dispatch-lifecycle emoji reactions
Phase 2 of 3 (requirements → design → tasks). Derives from
requirements.md.
Overview
A single new module, cli/the_loop/reactions.py, plus three small wiring points in the dispatcher. The dispatcher is deliberately the integration seam: both ingress paths (webhook receiver and poller) funnel every event through Dispatcher._worker → _dispatch_one, so reacting there covers issue comments, PR comments, poll-synthesized comments and presence/spawn events with one implementation — and events that are dropped before dispatch (authz, dedup, spawn policy, PR-close) naturally get no reaction (AC1.6).
sequenceDiagram
participant GH as GitHub
participant D as Dispatcher._worker
participant R as GitHubReactor (gh api)
participant H as Harness session
GH->>D: routed event (webhook or poll)
D->>R: react(routed, "started")
R->>GH: 👀 on comment / issue / PR (best-effort)
D->>H: _dispatch_one → resume / deliver / spawn
alt dispatch ok
D->>R: react(routed, "completed")
R->>GH: 🎉
else dispatch failed / crashed
D->>R: react(routed, "error")
R->>GH: 😕
end1. reactions.py — config, target resolution, reactor
ReactionConfig
Dataclass mirror of routing.reactions (enabled default True, started="eyes", completed="hooray", error="confused", gh_binary="gh"), with from_mapping following the existing *Config.from_mapping idiom and a content_for(state) accessor. The state names are module constants (STATE_STARTED/COMPLETED/ERROR).
Target resolution — target_from_event(routed) -> Optional[ReactionTarget]
Pure function (unit-testable, no I/O), returning where the reaction lands or None for "no-op" (AC3.1/3.2):
- Provider guard: no
githubwork item amongrouted.work_items→None(a future Jira provider's events no-op by construction). repository.full_name→owner/repo; each segment must match[A-Za-z0-9._-]+orNone.payload["comment"]present → react on the comment:- webhook payloads: prefer
node_id(GraphQL id, works for issue comments AND review comments) → GraphQL target; - numeric
id(webhook without node_id) → REST targetrepos/{o}/{r}/{issues|pulls}/comments/{id}/reactions(thepullsform forpull_request_review_commentevents); - poll-path comments carry the GraphQL node id in
id(seeGhClient._comment_from_json) → GraphQL target. Node ids must match[A-Za-z0-9_=+/-]+.
- webhook payloads: prefer
- else
issue/pull_requestnumber present → REST targetrepos/{o}/{r}/issues/{number}/reactions(GitHub treats PRs as issues for reactions), covering presence/labeled/spawn events andpull_request_review(a review body is not reactable on GitHub; the PR is). - else (
workflow_run, malformed) →None.
ReactionTarget is a frozen dataclass: owner, repo, and either rest_path or node_id.
GitHubReactor
Shells the operator's gh CLI — the same auth posture as the poller (GhClient): no token of its own, inherits gh's enterprise config. Injectable runner=subprocess.run + timeout (default 30 s) for tests, mirroring GhClient.
react(routed, state) -> bool short-circuits, in order: disabled → content "" → content not in the fixed palette (warn) → no target → gh not on PATH (warn once, AC3.3). Then:
- REST target:
gh api --method POST <rest_path> -f content=<name> - GraphQL target:
gh api graphql -f query='mutation($subjectId:ID!,$content:ReactionContent!){addReaction(input:{subjectId:$subjectId,content:$content}){clientMutationId}}' -f subjectId=<node> -f content=<ENUM>
with the REST↔GraphQL name mapping in a module REACTION_CONTENTS dict (eyes→EYES, +1→THUMBS_UP, …). Success emits reaction.added (debug log); any failure (non-zero exit, OSError, timeout) emits reaction.failed at warning level and returns False — never raises (AC1.5).
2. Dispatcher wiring
RoutingConfiggainsreactions: ReactionConfig(parsed infrom_mapping), so the poller inherits it for free andDispatcher.reloadhot-swaps it with the rest of the soft policy (AC2.4) — the reactor is rebuilt on reload unless a caller injected one (same override pattern asworkspace).Dispatcher.__init__gains an optionalreactorparameter (tests inject a fake); defaultGitHubReactor(config.reactions)._workerwraps the dispatch:react(started)before_dispatch_one, thenreact(completed)/react(error)from its boolean outcome (exception path included). To report that outcome,_dispatch_one,_spawn_for,_spawn_tmuxand_respawn_tmuxnow returnbool(True exactly on the paths that emitdispatch.succeeded/session.spawned/session.respawned) — a pure signature change, no behavioural edits.- PR-close and all pre-dispatch drops happen in
handle()before any enqueue, so they never reach the reactor (AC1.6).
3. Config + schema + observability
cli-config.schema.json:routing.reactionsobject (additionalProperties: false);started/completed/errorenum = 8 palette names +""; defaults documented, including why ✅/⁉️ are unavailable..the-loop/cli-config.yaml: reactions block,enabled: true(dogfood).eventlog.EVENT_TYPES+=reaction.added,reaction.failed(the observability reference defers toEVENT_TYPESas the catalog's source of truth, so registering there is sufficient).
Decisions
- Dispatcher-level, not router/poller-level. One seam covers both ingress paths and inherits their guards; the poller's bounded-retry ledger (issue-80) is untouched — a retried event just re-adds an (idempotent) reaction.
ghCLI, not a bundled HTTP client. Keeps the zero-runtime-dependency guarantee and the established auth posture; reactions are best-effort, so the extra subprocess cost (~2 per event) inside the throttled worker is acceptable.- Completed ≠ removing 👀. The started reaction stays — reactions are an append-only visible trail (👀 then 🎉), and removal would need reaction-id bookkeeping for no user value.
- Default on (owner decision). Drafted default-off (first daemon write surface → conservative opt-in); the owner explicitly chose default-on at PR #85 review — visibility out of the box is the feature's point, and the write is reaction-only, best-effort, and a no-op without
gh. Tests stay hermetic via a conftest autouse fixture that stubs the dispatcher's default reactor (reaction tests inject their own).
Error handling
Every failure inside the reactor degrades to a logged no-op; the only warnings that can repeat per-event are actual gh invocation failures (reaction.failed). Dispatch outcome, dedup and retry semantics are byte-for-byte unchanged.
Testing strategy
- Unit (
cli/tests/test_reactions.py): config parsing/defaults;target_from_eventacross webhook comment (node_id and numeric-id), review comment, poll comment (node id inid), presence issue/PR, review,workflow_run→ None, non-github provider → None, malformed owner/id → None; reactor argv construction (REST + GraphQL) via fake runner; no-op short-circuits (disabled, empty content, unknown content, missing gh warn-once); failure →Falsewithout raising. - Integration (Gherkin-docstringed, in
test_webhook_routing_integration.pystyle): dispatch success → started+completed on the comment; dispatch failure → started+error; spawn (labeled) → reactions on the issue; unauthorized/duplicate events → no reactions. Fake reactor records calls — no network.