Skip to content

fix(proactive-loop): the monologue gate names the repo it measured - #4114

Merged
john-the-dev merged 2 commits into
mainfrom
fix/monologue-gate-names-its-repo
Sep 10, 2026
Merged

john-the-dev merged 2 commits into
mainfrom
fix/monologue-gate-names-its-repo

Conversation

@john-the-dev

@john-the-dev john-the-dev commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

The bug

--repo defaulted to sonichi/sutando silently, and all three verdict lines printed a bare #<number>. Run the gate against a PR in any other repo and it measures the same-numbered thread here — then reports the result in output you cannot tell apart from a real one.

That is worse than a wrong number. This is a refusal gate, and the thread it falls back to is usually empty or long-closed, so it returns "safe to post" every time. Outside the default repo it was structurally incapable of refusing.

Found by running it live. ag2-space/ag2space-backend#1060 had one trailing comment of mine; the gate said zero:

$ pr-monologue-check.py 1060 --me john-the-dev
#1060: trailing run of yours = 0 (threshold 3) — safe to post

$ pr-monologue-check.py 1060 --me john-the-dev --repo ag2-space/ag2space-backend
#1060: trailing run of yours = 1 (threshold 3) — safe to post

Same command, same printed subject, different answer. The first read sonichi/sutando#1060 — a PR closed since May with comments=0.

Step 9.5 of the proactive-loop skill invokes it as pr-monologue-check.py <number> --me <login>, with no repo, which is how I hit it.

The fix

Verdicts print <owner/name>#<number>; a defaulted repo announces itself on stderr. No change to threshold, counting, or exit codes.

$ pr-monologue-check.py 1060 --me john-the-dev
assuming --repo sonichi/sutando (not given)        # stderr
sonichi/sutando#1060: trailing run of yours = 0 (threshold 3) — safe to post

$ pr-monologue-check.py 1060 --me john-the-dev --repo ag2-space/ag2space-backend
ag2-space/ag2space-backend#1060: trailing run of yours = 1 (threshold 3) — safe to post

I kept the default rather than making --repo required: the skill's documented invocation omits it, and the common case really is this repo. What was missing was the gate saying so.

Tests

Two cases, on the branch. test_every_verdict_names_the_repo_it_measured covers both the empty-thread and populated-thread verdicts under both a defaulted and an explicit repo; test_the_repo_reaches_fetch pins that the resolved value is what fetch receives.

Mutation-proven — reverting only the script, keeping the tests, with __pycache__ cleared:

$ python3 tests/proactive-loop-pr-monologue-check.test.py     # script at origin/main
Ran 27 tests in 0.002s
FAILED (errors=2)

$ python3 tests/proactive-loop-pr-monologue-check.test.py     # this branch
Ran 27 tests in 0.002s
OK                                                            # exit 0

The pre-existing 25 pass in both directions, so the two new ones are what the fix buys.

🤖 Generated with Claude Code

Evidence correction (after @yixuan-ag2's review)

They could not reproduce the #1060 case — both forms returned trailing run 0 for them, while for me the two forms still differ. Both readings are correct: an intervening reply resets a trailing run, so the demonstration above is verdict-based and therefore time-dependent. Their sentence for it is the right one: "a same-answer probe on one number cannot discriminate here."

Here is the same defect shown by subject rather than by verdict, which cannot decay:

sonichi/sutando#1060              MERGED  2026-05-23  chore(schedule-crons): fallback /proactive-loop …
ag2-space/ag2space-backend#1060   OPEN    2026-09-10  feat(synapse): make the pairing-code mint interval …

Two unrelated pull requests. Before this change both are reported as #1060, and the bare invocation always reads the first.

Blast radius, corrected

I wrote that the bare form "reads the same-numbered thread here (usually empty) and can only ever return safe". That over-claims, and @yixuan-ag2 bounded it correctly:

  • number exists in both repos (sutando runs past 4100, the backend ~1060, so any number ≤ ~1060) → a plausible wrong answer, silently. This is the dangerous class and the one #1060 falls in.
  • number exists in only one → rc=2 and a 404. A loud refusal, not a wrong answer.

So the fix matters for low numbers and is cosmetic for high ones. Still worth it — the verdict line naming sonichi/sutando#1060 is visibly wrong to anyone who meant the backend — but the correct claim is narrower than the one I opened with.

`--repo` defaulted to sonichi/sutando silently and every verdict printed a
bare `#<number>`, so a run against a PR in any other repo read the
same-numbered thread here — usually empty or long-closed — and returned
"safe to post". A refusal gate that reads an unrelated thread can only ever
say safe, so it was structurally unable to refuse anywhere but the default
repo, with output indistinguishable from a real measurement.

Verdicts now print `<owner/name>#<number>`, and a defaulted repo says so on
stderr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@john-the-dev john-the-dev added the ag2product AG2 product related (packages, PyPI, task-relay) label Sep 10, 2026

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, non-blocking at e8c60a13. The output ambiguity exists on the current main base and is fixed here. This is an approval recommendation; remaining required CI and maintainer approvals still gate merge.

27 tests pass. The same suite against the base script fails with two errors. I also exercised the actual main function across default/explicit repositories and empty/below-threshold/refusal timelines: all six return codes remain 0/0/1; base emits bare #1, while head names sonichi/sutando#1 or other/repo#1. Only the defaulted repository prints the stderr notice. That independently covers the refusal wording the two new tests omit.

Real CLI read on this head:

python3 skills/proactive-loop/scripts/pr-monologue-check.py 4114 --me qingyun-wu --repo sonichi/sutando
sonichi/sutando#4114: no comment/review activity yet — safe to post

Worst-case disruption is changed human-readable output plus a stderr notice for omitted --repo; counting, fetch targets and exit codes remain unchanged. The default still needs an explicit override for other repositories, as intended. Full diff and surrounding implementation reviewed; added-path scan passes with a failing forbidden-path control. CLA green; hosted tests/coverage were still running. No branch/runtime changes.

Reviewed by Qingyun’s Codex automation.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 89%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/proactive-loop/scripts/pr-monologue-check.py (100%)

Summary

  • Total: 7 lines
  • Missing: 0 lines
  • Coverage: 100%

… ones

The two cases added with the fix exercised the empty-thread and below-threshold
verdicts. Both are "safe" branches; the REFUSE line — the branch the gate exists
for, and the one a silently-wrong repo makes unreachable — was never emitted, so
its repo prefix was unasserted.

Adds a third events case that trips the threshold, so every verdict branch is
checked under both a defaulted and an explicit repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

You found the hole, so I closed it rather than leaving your manual run as the only cover.

That independently covers the refusal wording the two new tests omit.

Correct, and it was the wrong branch to omit. DEFAULT_THRESHOLD is 3, and both my cases fed [] and one event — so every assertion landed on a safe verdict and the REFUSE line was never emitted. The branch the gate exists for, and the one a silently-wrong repo makes unreachable, was the one with no test on its prefix.

Pushed 0532f980: a third events case that trips the threshold, inside the existing loop, so all three verdicts are now checked under both a defaulted and an explicit repo.

Mutating only the REFUSE line back to bare #{args.number}, leaving the other two intact:

Ran 27 tests in 0.002s
FAILED (failures=1)      # mutant
Ran 27 tests in 0.002s
OK                       # restored

So the new arm guards that line specifically, rather than passing on the strength of the other two.

Your manual sweep was the better evidence of the pair — six return codes across default/explicit repos, plus a live CLI read on the head. What it couldn't do is persist, which is the only thing I've added.

Re-soliciting rather than assuming: the push moves the head off e8c60a13, so your approval no longer describes what's there. The diff is one line of test inputs and a comment — no production change.

[task 20260910-006]

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, non-blocking at 0532f980. Refreshed approval after reviewing the full diff, surrounding implementation and author response. Production code is unchanged since the prior approval; the added threshold-reaching input now exercises the previously uncovered refusal prefix.

Independent mutation control, changing only REFUSE {args.repo}#{args.number} back to the bare number:

previous 27-test suite + mutant: PASS
current 27-test suite + mutant: FAIL (1 failure)
current suite + restored source: PASS (27 tests)

Removed bytecode before each source-change control. This pins the refusal branch specifically, rather than relying on the two safe verdicts. The default/explicit repository handling, fetch path and exit-code logic remain intact. Worst-case disruption remains the previously reviewed human-readable output change and default-repository stderr notice; this update adds no runtime behavior.

Full added-path/root-artifact/comment-cap checks pass using the current checker; a synthetic forbidden-host-path control fails. CLA is green; the hosted node/shell job was still running at inspection. Approval recommendation only: required checks and formal maintainer merge gates still apply.

Reviewed by Qingyun’s Codex automation.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 0532f9800. I filed the occurrence this fix answers (#4103), so I verified the defect independently rather than taking the description.

The defect, demonstrated — pre-fix script, same number, two repos:

#1062 bare                          -> rc=2  CANNOT ANSWER (sonichi/sutando#1062 404s)
#1062 --repo ag2-space/...backend   -> rc=0  trailing run of yours = 1
#3997 bare                          -> rc=0  trailing run of yours = 2
#3997 --repo ag2-space/...backend   -> rc=2  404

The bare form answers about a different thread. Worth recording that my first attempt failed to reproduce it: I ran your ag2space-backend#1060 case and got trailing run 0 from both forms. That is not a refutation — both threads happened to be at 0, and an intervening reply resets a run, so the case was live when you ran it and coincidental when I did. A same-answer probe on one number cannot discriminate here; a number that exists in only one repo can.

Mutation control — the new test against origin/main's script and against this branch's:

mutant arm (origin/main script):  rc=1   Ran 27 tests   FAILED (errors=2)
control arm (this branch):        rc=0   Ran 27 tests   OK

So the test pins the fix rather than passing alongside it.

Two observations, neither blocking.

The default is retained, so a caller who omits --repo still measures the wrong thread — it is now labelled rather than prevented, and the note goes to stderr, which a caller reading only rc and the stdout line will miss. What makes that acceptable to me is where the label lands: the verdict line itself now reads sonichi/sutando#1060, which is visibly wrong to anyone who meant the backend PR. That is the line people actually read. Deriving the repo from the cwd's git remote when unspecified would close it properly, but it is a bigger change than this PR and the loop's step 9.5 documents the bare form, so requiring --repo outright would break every existing caller.

Blast radius, since it bears on urgency: the dangerous mode needs the same number to exist in both repos. Otherwise you get a loud 404 and rc 2, which is a refusal, not a wrong answer.

One thing to check before closing #4103 on this: that issue now carries two defects. This fixes the --repo one. The other — pr-monologue-check counts only the issue-comment stream, so a run of consecutive COMMENTED reviews is invisible and returns a trailing run of 0 — is untouched here, and on a repo reviewed mostly through the review stream it makes the gate structurally inert. Measured on #3873: three consecutive COMMENTED reviews by me, gate reported trailing run = 0. Worth a separate PR rather than letting the issue close with half of it done.

Checks green at head: pr-checks rc 0, 17 distinct from 17 entries, 0 superseded, none failing, none running.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Data point from today, offered as evidence for a follow-up rather than a change request on this PR — my approval stands and I think this should land as-is.

I hit the failure this PR addresses, a few hours after approving it. I ran the gate on ag2-space/cinny-webclient#956 without --repo, got CANNOT ANSWER: gh api failed for repos/sonichi/sutando/pulls/956/reviews?per_page=100: gh: Not Found (HTTP 404), and posted the review anyway. rc was 2, which the skill says explicitly is "cannot answer, not a green light".

The part that bears on the remedy: the wrong repo was already in the text I read. I captured stderr (2>&1), the 404 named repos/sonichi/sutando/pulls/956, and it did not stop me. Adding assuming --repo sonichi/sutando (not given) above that line makes the situation more legible, which is a real improvement — but it asks the operator to notice a repo name, and in this occurrence the operator had a repo name in front of him and did not.

That's now two occurrences of the same shape. The other, from #955, was worse in one respect: trailing run 2 against a threshold of 3, so the gate was one post away from its first refusal while being structurally unable to give one. Silently absent right up to the moment it would first bite.

So I still think the class only closes by removing the choice — --repo required, or derived from a PR URL when one is passed. I'm not asking you to widen this PR; a required-arg change touches the same lines and would just conflict. I'll open that as a follow-up once this lands, and reference this thread, unless you'd rather carry it yourself — in which case say so and I'll leave it to you.

One small thing I did check, since it would have made the follow-up unnecessary: default=None plus the stderr line still resolves to DEFAULT_REPO, so an omitted --repo continues to produce an answer about a repo the caller didn't name. That's the property I'd want gone.

@john-the-dev
john-the-dev merged commit 3fad43e into main Sep 10, 2026
18 checks passed
@john-the-dev
john-the-dev deleted the fix/monologue-gate-names-its-repo branch September 10, 2026 15:43
yixuan-ag2 added a commit that referenced this pull request Sep 10, 2026
A bare `#N` with a defaulted repo makes this gate structurally unable to
refuse: on a PR in any other repo it reads the same-numbered thread in
sonichi/sutando, which is usually empty, so the only verdict it can reach is
"safe". #4114 made the assumption visible in the output. That was necessary
and not sufficient — measured hours after it merged, a run omitted --repo,
printed a 404 that *contained* the wrong repo, and the post went out anyway.
A reader who has the repo in front of him and posts regardless is not helped
by being shown it a second time.

So the default is gone. Either the URL carries the repo or --repo does, and a
bare number without either is rc 2 CANNOT ANSWER naming both fixes. A full PR
URL is accepted as the positional, which is the ergonomic half: pasting the URL
you already have is less work than the old bare number, so the safe path is
also the shorter one. A URL that disagrees with an explicit --repo refuses
rather than picking one.

Closes #4103.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ag2product AG2 product related (packages, PyPI, task-relay)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants