fix(github-scan): auto-revert github-scan:human on new human comment (#358) - #359
Conversation
When the daemon polls an item classified `human` and observes a qualifying human comment posted strictly after the `github-scan:human` label was applied, strip the label so the classifier naturally re-derives `new` on the next cycle and the dispatcher picks the item up. Guards (issue #358): 1. Comment author MUST NOT be the agent itself. 2. Comment body length > 20 chars (filters thumbs-up / "ok" acks). 3. Reactions alone do NOT count as a comment. 4. Comment created_at MUST be strictly after the label-event timestamp. The fix is a new `runtime/auto-revert.ts` module wired into `pollOnce` between `enrichWithLabels` and `classifyEntries`. The classifier itself is unchanged. Production passes the daemon's resolved `identity.login` as `agentLogin` so the own-comment guard is exact; when identity resolution failed (degraded mode), the auto-revert is skipped entirely. Updated the agent prompt in `daemon/runner.ts` to document the new human → new transition so agents know they can safely stop on `github-scan:human` and trust the daemon to re-queue on reply. Refs #358 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bingran-you
left a comment
There was a problem hiding this comment.
LGTM overall — clean separation between the pure decision function and the I/O-bound driver, all five acceptance cases from #358 are covered, and the in-place entry.labels mutation neatly avoids the need for a per-cycle tombstone. A few non-blocking observations:
Pagination cap (worth a follow-up, not a blocker)
fetchHumanLabelAppliedAt and fetchIssueComments both fetch only the first page (per_page=100) of the timeline / comments respectively (packages/github-scan/src/github-scan/engine/runtime/auto-revert.ts:99,145). For a long-lived issue with > 100 timeline events between the labeled event and the head of the timeline, the latest github-scan:human labeled event could fall off the page and fetchHumanLabelAppliedAt would return null — auto-revert would then be silently skipped (a warning fires, but the human handoff stays stuck). Same shape for the comments fetch. Realistic for typical issues; could bite for noisy long-running threads. Could either page or — for the timeline specifically — query in reverse via ?page=N&per_page=100 only when the first page doesn't contain the event.
Silent removeLabel failure
gh.removeLabel swallows non-zero exits (runtime/gh.ts:174). If the REST call fails after we've mutated entry.labels in place, the same cycle will (correctly) re-classify as new and dispatch — but on the next cycle the GraphQL re-read will still show github-scan:human, the auto-revert path will re-fire, and the agent gets re-dispatched. Probably tolerable (the agent will idempotently respond to the same comment), but worth thinking about whether the in-memory mutation should be conditional on removeLabel actually succeeding. A quick check on removeLabel's return + early-exit on failure would close this.
Body length threshold
comment.body.trim().length <= AUTO_REVERT_MIN_BODY_CHARS (≤ 20) means strictly more than 20 characters qualifies, which matches the PR description and the issue spec. Just noting in case anyone reads the constant name and assumes "at least 20".
Nit — agent-prompt wording
The new prompt line in runner.ts:200 says "anyone other than {login}" but the actual guard is case-insensitive on the login and requires > 20 chars and requires post-label timestamp. If an agent ever reasons about whether to set github-scan:human based on this prompt, the simplification is fine; if they ever debug why a revert didn't fire, the abbreviated description could mislead. Minor.
Approving — none of the above blocks merge. The pagination one is the only one I'd consider opening a follow-up issue for once you've seen this in production for a bit.
This reply was drafted by breeze, an autonomous agent running on behalf of the account owner.
End-to-end verificationRan a real-GitHub smoke test of this PR against a separate daemon on Test target: Test setup note: my only Positive case (auto-revert fires)
Negative case (guards hold)
Real-world surprise worth notingThe poller candidate set comes only from Test pollution cleaned: cc @bingran-you |
…rator (#360) (#361) Refs #360. Surfaced from the live #359 smoke ([test report](first-tree-ai/first-tree#359 (comment))) where the operator's `gh auth` user (`serenakeyitan`) was the same as the daemon's agent identity, so every operator comment was filtered as own-comment and auto-revert never fired. ## Summary Adds a `--agent-login <login>` CLI flag (with env and config-file fallbacks) so the operator's GitHub identity can be declared independently of the daemon's `gh auth` user. The auto-revert own-comment guard from #358/#359 now uses this resolved identity. ## Resolution order | # | Source | Wins over | |---|---|---| | 1 | CLI flag `--agent-login <login>` | env, yaml, gh auth | | 2 | Env var `GITHUB_SCAN_AGENT_LOGIN` | yaml, gh auth | | 3 | Yaml `agent_login` / `agentLogin` (in `~/.first-tree/github-scan/config.yaml`) | gh auth | | 4 | Daemon's `gh auth` identity | (final fallback — preserves zero-config dogfood) | ## Implementation - `runtime/config.ts`: adds `agentLogin` to `DaemonConfig`, `DaemonCliOverrides`, the yaml schema, and the existing 4-tier `loadGitHubScanDaemonConfig` resolver. - `daemon/runner-skeleton.ts`: parses `--agent-login` flag (both `--agent-login alt-bot` and `--agent-login=alt-bot`), passes `config.agentLogin ?? identity?.login` into `runPoller` / `runPollerOnce`. Logs an explicit override line when the resolved identity differs from `gh auth`. - `cli.ts`: surfaces `--agent-login <login>` in `run`, `daemon`, and `start` help text. - `auto-revert.ts` already takes `agentLogin` as a parameter (PR #359), so no change needed there — the resolved value just flows through. ## Tests - 6 new config tests (all 4 resolution tiers + camelCase yaml + empty-CLI-no-clobber) - 2 new `parseDaemonArgs` tests (both `--agent-login` forms + empty-value rejection) - 1 new auto-revert test verifying guard 1 (own-comment) uses the *resolved* identity, NOT the `gh auth` user — this is the exact #360 scenario - All existing 11 auto-revert tests + existing config tests still pass - `pnpm -r test` (531 tests), `pnpm lint`, `pnpm typecheck` all pass ## Live smoke (3 cases — all green) Tested against `first-tree-ai/first-tree-website#12` on a separate daemon (port 7879, separate `GITHUB_SCAN_DIR`) without disturbing the running prod daemon on 7878. Full transcript posted as a follow-up comment. | Case | Setup | Auto-revert expected | Result | |---|---|---|---| | 1 | `--agent-login some-other-login` | YES (operator comment is "human" now) | YES — label stripped, status=`new` | | 2 | `GITHUB_SCAN_AGENT_LOGIN=some-other-login`, no flag | YES | YES — label stripped | | 3 | No flag, no env | NO (gh auth fallback → `serenakeyitan` IS the agent → own-comment filter) | NO — label retained | cc @bingran-you --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AUTO_REVERT_MIN_BODY_CHARS guard — blocks LGTM and short Chinese replies
#382
Refs #358.
Summary
When the daemon polls an item classified
humanand a qualifying human comment is observed after thegithub-scan:humanlabel was applied, the daemon strips the label so the (unchanged) classifier naturally re-derivesnewand the dispatcher picks it up on the next cycle.This closes the silent-failure handoff documented in #358 (e.g. agent-team-foundation/first-tree-website#10) where a human replies in the issue and reasonably expects the agent to read the decision and continue working — instead of having to also manually strip the label.
Implementation
packages/github-scan/src/github-scan/engine/runtime/auto-revert.tsexporting:shouldAutoRevertHuman(input)— pure decision function applying the four guards.autoRevertHumanLabels(entries, deps)— driver that fetches the timeline + comments, calls the existinggh.removeLabel, and mutatesentry.labelsin place.pollOnce(packages/github-scan/src/github-scan/engine/daemon/poller.ts) betweenenrichWithLabelsandclassifyEntries— so when guards pass, classification on the same cycle re-derivesnew(no extra round trip needed).runner-skeleton.tspasses the daemon's resolvedidentity.loginasagentLogin. When identity resolution fails (degraded mode), auto-revert is skipped — fail-safe.entry.labelsin the same cycle, and on the next cycle the live GraphQL label-set no longer containsgithub-scan:human, so the entry skips the auto-revert branch entirely. No per-poll tombstone needed.daemon/runner.tsto document thehuman → newtransition so agents know they can stop ongithub-scan:humanand trust the daemon to re-queue on reply.Guards (from issue #358)
own-comment ignoredtestshort ack ignoredtest ("ok thx","👍")reaction ignoredtest (empty body shape)created_atstrictly after label timestamppre-label commenttest + a separate==strict-inequality testgenuine reply triggers reverttestAcceptance-criteria coverage
packages/github-scan/tests/github-scan/github-scan-auto-revert.test.ts— 11 tests, 5 acceptance + 2 extra guard edges + 4 driver tests:gh.removeLabelis called andentry.labelsis mutated when guards passgithub-scan:humanValidation
pnpm -r test— 522 tests pass across 38 filespnpm lintpnpm typecheckcc @bingran-you for review.