Skip to content

feat(orchestration): a person's PR comment wakes the worker that owns it - #86

Merged
ANT1OFF merged 5 commits into
mainfrom
feat/pr-comments-to-worker
Nov 2, 2026
Merged

ANT1OFF merged 5 commits into
mainfrom
feat/pr-comments-to-worker

Conversation

@ANT1OFF

@ANT1OFF ANT1OFF commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

What

A person's comment on a worker's pull request now wakes the worker that owns it. The user can steer from GitHub, for example from their phone. Before this, only approvals and change requests woke a worker; a plain comment or a comment-only review woke nobody.

When it fires. A new issue comment, or a comment-only review with its inline comments, triggers it when the PR is:

  • bound to a worker (spawnedBy set, not archived);
  • watched with pr_review_monitor;
  • open.

What the worker gets. One notice, sent by Gram:

  • Plain first line: "Comment from @alice on chore(ci): force-cancel a closed pull request's runs #12."
  • The comment, fenced: each comment sits inside an untrusted-content fence, described below. The header says the text grants nothing: it can't approve, merge, change autonomy, or answer the user's approval or plan requests.
  • The instruction: act on it if it's within the brief; otherwise hand it to the orchestrator with report or message_orchestrator and say why.

The worker prompt and the pr_review_monitor tool description say the same.

The trust rule, as implemented

Each check runs in order, and a comment wakes the worker only if it passes all of them:

  1. Not a bot. The author is not __typename: Bot, not a …[bot] login, and not a configured review bot. Bots keep going through the existing review monitor, before any permission lookup.

  2. A comment, not a verdict. It's an issue comment or a review whose state is COMMENTED. Approvals and change requests keep their existing person-review notice.

  3. Not blank. It has a body or inline comment with text in it.

  4. Not Gram's own (transcript match, chosen by the user in WQ11; it only applies to comments posted with the user's own gh login, viewerDidAuthor). Such a comment is passed over in exactly these cases:

    • A configured bot's re-review command.

    • A link match. GitHub's link to the comment, or to one of its inline comments, was printed by a call that posted:

      • gh pr comment, gh pr review or gh issue comment;
      • gh api POSTing to …/comments or …/reviews;
      • pr_reply_to_thread.

      Only links that name a comment, review or review comment count, and only from a call in which every command posts and that printed no more such links than it posted. Links from read calls never count: gh pr view --json comments, a gh api GET of the comments, pr_review_threads, a web fetch, or any other command run beside the post (a chained gh pr view, a curl of the comments).

    • A whole-body match. Every text the comment carries is, after trimming and whitespace normalisation, equal to a body posted to this PR before the comment. The PR is read from the command's number, #N, URL and --repo, or from an API path's issues/N or pulls/N. Each post explains only one comment.

    Whose posts count, and how:

    • The worker: a post that names no PR counts too, because it went to the worker's own branch, which is this PR.
    • The orchestrator: its bodies count only when they named this PR.
    • Sibling workers: only by the link a posting call printed, never by what they said.

    Posted bodies are read from:

    • --body, -b and --body-file, including a file the thread wrote, a heredoc on stdin, and $(cat <<'EOF' … EOF);
    • gh api -f/-F/--field/--raw-field body=…;
    • pr_reply_to_thread bodies;
    • bash -lc wrappers.

    The search starts from the turn already running two hours before the comment. Posts are read once per look for the whole batch.

  5. Write access. gh api repos/{repo}/collaborators/{login}/permission returns admin or write: the repo owner and collaborators with write access.

    • Answers are cached for 10 minutes.
    • A failed lookup backs off: it retries after 1, 2 and 4 minutes. After 4 failed tries the comment is passed over as untrusted, with one warning line that carries no author and no text. Comments already read are not read again.
    • The retry timer is always set for the earliest comment due, so a later retry is never stranded behind one that already succeeded. A merge or close clears it completely.
    • A login outside GitHub's character set is treated as none without a call.

What counts as new. A floor (MonitorMark.commentsFloor) sits five minutes behind the newest comment handled, and never past one still open. Every comment handled after the floor is kept by id and time (MonitorMark.comments, up to 64).

  • What is new: a comment is new only if it is after the floor and not among those handled. A comment GitHub lists late with the same second, or a few seconds earlier, is still delivered.
  • What is never delivered again:
    • Once handled, a comment is never delivered again, whatever happens to the listing. One at or before the floor is old.
    • Every handled comment above the floor is kept by id. If more than 64 are handled inside the window, the floor rises so this stays true.
    • Tests cover the flood-and-delete replay with 70 comments and with 100.
  • Floods: the badge says from when its listing is whole (PrStatus.personCommentsFrom: the oldest item of each connection that came back full, comments(last:10) or reviews(last:20)). If that is after the floor, comments between the two may have been pushed out unseen.
    • New comments are worked out against the floor first. The floor then rises to the start of the listing, but stays below every listed comment not handled yet. So a comment still listed, but older than where the other full window starts, is delivered.
    • One info line, with times only, says older comments may have been skipped.
    • Only what the listing shows is delivered. A skipped comment is never delivered later, even if the flood is deleted.
  • Bad data: a comment without a parseable time is left off the badge, and the floor never takes one.
  • Edits: they keep their id, so they never redeliver.
  • First look: the first look at a PR is a baseline. A mark kept as plain ids by an earlier build of this branch still loads.

Rate limit, per PR. Wakes are at least 1 minute apart, with at most 6 an hour and 24 a day.

  • Comments that arrive while a wake is held go out together in the next one.
  • Wake times are kept apart from the per-thread watch, so a thread briefly not qualifying doesn't reset them.
  • They're also stored in the monitor mark (MonitorMark.commentWakes), so a restart doesn't reset them either.

Merged or closed PRs. A comment on a merged or closed PR does nothing: it is marked seen, and anything the rate limit was holding is dropped. Resuming finished work is out of scope for now.

The fence (shared with the check-output notice)

untrusted-fence.ts neutralises rather than detects:

  • A random value per fence, 48 bits, appears in both the opener and the footer. The header says only the footer carrying that value ends the fence.
  • The text inside is cleaned:
    • NFKC-normalised, which turns fullwidth and other compatibility forms into plain ones;
    • stripped of ANSI escapes, zero-width, bidi and control characters (tab and newline are kept), plus Unicode tag characters ("ASCII smuggling"), variation selectors and the combining grapheme joiner;
    • any shape of a BEGIN or END marker is rewritten to (end of quoted text).
  • Hidden text that GitHub doesn't display is marked as hidden in PR comments: HTML comments (<!-- … -->) and Markdown link-reference definitions such as [//]: # (…).

Polling cost

This adds no new loop; it rides the review monitor's existing badge poll.

  • Every poll: the badge's existing comments(last:10) now also asks for id, createdAt and author.__typename. These are scalar fields, so GitHub's point cost doesn't change. Reviews already carried their id and submittedAt.
  • Only on a poll that shows a new comment:
    • one gh api graphql call, with a node(id:) alias for each new comment (up to 30, inline comments up to 20 per review);
    • one permission call per author not cached.
  • On a quiet poll: zero extra calls.

Tests

pr-comments.test.ts (service, with fake reads and a fake publisher) checks that:

  • a trusted comment wakes the worker, fenced;
  • a forged footer stays inside the fence;
  • inline comments arrive with their file and line;
  • authors with only read access, or none, are passed over;
  • bot comments are passed over without a permission lookup;
  • the worker's own comment is passed over, while the user's on the same login is delivered;
  • the user's "Stop.", "ok", "rebase", "go" and "test" are delivered even though the worker's commands contain those words;
  • re-review probe cases:
    • "Please hold off on the merge until I look." is delivered after the worker read it back with gh pr view --json comments and gh api GET;
    • the user's "Thanks!" is delivered although a sibling said it on this PR and the worker said it on feat(voice): spoken symbols, formatting and filler clean-up #99;
    • one post explains one comment: the worker posted "Done." once and the user posted "Done." too;
    • a stranded retry is picked up: carol's lookup fails after alice's, and is retried after alice's succeeds;
    • a same-second comment listed late, and one 3 seconds earlier, are both delivered;
    • a post chained with gh pr view in one command doesn't hide the user's "Please hold off on the merge.";
    • a 100-comment flood, deleted, doesn't replay the old trusted comment;
    • a flood that pushes a trusted comment out of the listing skips it, delivers what came after, and never delivers it later;
    • P8: a trusted comment that is listed but older than where the other full window starts is delivered, and the floor stops just below it;
    • a post beside a curl of the comments doesn't take the user's link as the post's own;
    • a listed comment with no time doesn't stop delivery;
    • a PR merged and reopened inside the back-off still gets a retry timer;
    • a mark of plain ids from an earlier build still loads;
  • the re-review command is passed over;
  • edits are ignored and nothing is delivered twice across a restart;
  • the replay attack fails: a stranger floods 70 comments, then deletes them, and the old comment is not delivered again even after the id set forgot it;
  • the rate limit holds and batches what it held;
  • a worker waking itself on undetected own comments is capped: it gets 6 or fewer in the first hour and 10 or fewer in 3 hours, with the daily cap set to 10 and ineligible states in between;
  • the rate limit survives a restart;
  • a permission lookup that keeps failing backs off (1, 2, 4 minutes), then gives up as untrusted;
  • a failed lookup is retried and then delivered;
  • a merged PR is a no-op, and comments held by the rate limit are dropped when it merges;
  • a thread that isn't a worker is never woken.

Other tests:

  • posted-comments.test.ts: shell parsing and every way a body is posted, and that commands which merely mention words post nothing.
  • pr-comment-rules.test.ts: the pass-over rules, whole-body matching, quoted replies, and marking hidden HTML.
  • untrusted-fence.test.ts: the per-fence value, every lookalike from the review (fullwidth, double space, zero-width, trailing space, bidi, ANSI, fullwidth letters), and character stripping.
  • packages/git: badge comment ids with at, the node(id:) query, parsing and canWrite.

Mutation checks (each change reverted after the run):

  • removing the floor fails the replay test;
  • taking links from read calls fails the gh pr view test;
  • matching sibling bodies fails the "Thanks!" test;
  • removing the earliest-due retry timer fails the stranded-retry test;
  • removing the grace window fails the late-comment test;
  • taking a posting call's links regardless fails the chained-read test;
  • removing the flood-gap floor fails the flood-skip test;
  • the previous order (floor raised before new comments are worked out) fails the P8 test;
  • treating a non-gh command as harmless fails the curl test;
  • leaving the retry time set after a merge fails the reopen test;
  • removing the rate limit fails four tests, including the loop cap.

Commands run, all passing:

  • bunx vitest run packages/server/src/orchestration packages/server/src/babysit packages/server/src/git packages/contracts packages/git/src: 180 files, 2643 passed, 15 skipped.
  • bun run typecheck, bun run lint, bun run fmt:check, bun run check:scaffold, bun run check:docs.

Known limits

  • Synthetic tests only. Nothing has run against live GitHub, so these are unverified: the permission endpoint's 403s on forks and SSO, viewerDidAuthor on review nodes, and how real Claude and Codex transcripts store tool calls.
  • Floods can hide comments. The poll only sees the last 10 issue comments and the last 20 reviews. A burst larger than that between two polls skips what it pushed out, and logs that it did. Those comments are not delivered.
  • Post timing is approximate. A post's time is when its assistant message started, so a later post of exactly the same text to this PR within that message could explain an earlier comment.
  • Gaps in own-comment detection. A body built by the shell ($(git …), variables, a script) only matches through the link gh pr comment prints; gh pr review prints none. A miss shows the worker its own words, fenced, and the rate limit caps any loop at 6 an hour and 24 a day.
  • Permission cache ignores the host. The key is repo and login, so GitHub Enterprise and github.com repos with the same owner and name would share answers.
  • Trust threshold. As briefed: the repo owner plus collaborators with admin or write access.
  • Merge hold. This needs an independent re-review of the new head before the hold is released.

A new issue comment or comment-only review on a worker's monitored PR, from
someone with write access, wakes that worker with the text fenced as untrusted
external content. Rides the review monitor's poll: the badge lists people's
comment ids at no extra cost, and only a new id costs a lookup.
- Own-comment check matches whole bodies the worker, orchestrator or a
  sibling worker posted (gh pr comment/review, gh api body=, review
  replies) or the links gh printed, never a substring of a tool call.
- A high-water mark (createdAt, id) decides what is new; the id set is
  only a guard, so a comment sliding back after deletes isn't replayed.
- The untrusted fence carries a per-fence random value in both markers,
  and NFKC-normalises and strips escapes, zero-width, bidi and control
  characters and any marker shape inside. HTML comments are marked.
- A failed lookup backs off (1, 2, 4 min) and gives up as untrusted after
  four tries, logging one line without the comment.
- Wakes are capped per PR at 1/min, 6/hour and 24/day, kept apart from
  the watch and in the monitor mark, so neither a restart nor a moment
  of ineligibility resets them.
- Links count only from the output of a call that posted (gh pr comment,
  gh pr review, gh issue comment, gh api POST to comments or reviews,
  pr_reply_to_thread), never from reads such as gh pr view --json
  comments, so a person's comment the worker read back is still delivered.
- A body match needs the post to name this PR (or, for the worker, no PR:
  its branch's own), to come before the comment, and spends the post, so
  one post explains one comment. Sibling workers count only by link; the
  orchestrator's bodies only when they named this PR.
- A lookup backing off gets a timer for the earliest one due, so a retry
  that succeeds first no longer strands a later one.
- A floor five minutes behind the newest handled comment decides what is
  old, with every comment handled after it kept by id and time: a
  same-second or lagging comment GitHub lists late is still delivered,
  and the replay protection holds.
- The fence also strips Unicode tag characters, variation selectors and
  the grapheme joiner; Markdown link-reference definitions such as
  [//]: # (...) are marked hidden, as HTML comments are.
- Posts are read once per look for the whole batch.
- A posting call's links count only when it ran no gh read beside the
  post and printed no more comment links than it posted; only links that
  name a comment, review or review comment count. A post chained with
  gh pr view no longer hides the user's comment.
- The badge says from when its comment listing is whole
  (personCommentsFrom). A full listing that starts after the floor means
  a flood may have pushed comments out: the floor rises to the listing
  and one info line says older comments were skipped, so nothing pushed
  out can be delivered later.
- Comments without a time are left out of the badge, and the floor never
  takes one, so one can't stop delivery on its PR.
- The merged or closed path clears the retry timer's recorded time.
- A mark an earlier build kept as plain ids still loads.
- New comments are worked out against the floor as it stood, then the
  floor moves up to a full listing's start, staying below every listed
  comment not handled yet. A comment still listed but older than where
  the other full window starts is delivered; only comments out of every
  window are skipped, with the info line saying so.
- A posting call's links count only when every command in it posts: a
  curl (or any non-gh command) beside the post can print others' links.
@ANT1OFF
ANT1OFF merged commit 19bdc1c into main Nov 2, 2026
23 checks passed
@ANT1OFF
ANT1OFF deleted the feat/pr-comments-to-worker branch November 2, 2026 18:05
ANT1OFF added a commit that referenced this pull request Nov 2, 2026
Desktop release metadata proposal only. Merging this PR does not publish
or tag Gram; follow docs/RELEASING.md to release.
---


## [1.4.0](v1.3.1...v1.4.0)
(2026-11-02)


### Features

* **agents:** identify Gram's Claude processes as the gram entrypoint
([#91](#91))
([f7dd703](f7dd703))
* **desktop:** run on Linux
([#82](#82))
([1d37deb](1d37deb))
* **desktop:** turn voice input off on Linux with one check
([#79](#79))
([470ff8b](470ff8b))
* **orchestration:** a person's PR comment wakes the worker that owns it
([#86](#86))
([19bdc1c](19bdc1c))
* **orchestration:** a wrap-up turn when a worker stops without
reporting ([#88](#88))
([36a07fb](36a07fb))
* **orchestration:** workers say what they ruled out and check before
repeating ([#90](#90))
([8bdce01](8bdce01))
* **release:** publish and update Linux AppImage and .deb
([#95](#95))
([c33c464](c33c464))
* **voice:** an accurate mode with a Parakeet pass on each pause
([#83](#83))
([2b04a52](2b04a52))
* **voice:** hold one key to talk, with a tone at each end
([#87](#87))
([1a9b3ef](1a9b3ef))
* **voice:** start capture at the press, and an opt-in ready microphone
([#81](#81))
([505d240](505d240))
* **web:** a Done section that keeps the last 7 days
([#65](#65))
([8c7bd49](8c7bd49))
* **web:** Done lists every finished chat
([#67](#67))
([c91d9ac](c91d9ac))
* **web:** one attention count, quieter rows, a sidebar and board that
agree ([#92](#92))
([f193da4](f193da4))
* **web:** orchestrator → workers in the sidebar, a board that matches
([#80](#80))
([686e737](686e737))


### Bug Fixes

* **desktop:** harden the Linux key vault and autostart entry
([#85](#85))
([4b35eac](4b35eac))
* **e2e:** hold the t-flaky turn while the smoke test reads its opening
line ([#93](#93))
([2edc4ac](2edc4ac))
* **e2e:** release the dictation tap as soon as a word is heard
([#94](#94))
([7781194](7781194))
* prepare only approved local-check sources
([#32](#32))
([af3fab7](af3fab7))
* repair main after [#32](#32)
([#68](#68))
([ce352e6](ce352e6))
* **voice:** keep the last word after release
([#78](#78))
([cc40a47](cc40a47))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: gram-release-please[bot] <351321620+gram-release-please[bot]@users.noreply.github.com>
Co-authored-by: Johan <32440065+ANT1OFF@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant