Skip to content

fix(workstream-grouping): build ranker candidates from the snapshot's own key - #3598

Merged
john-the-dev merged 9 commits into
mainfrom
fix/workstream-ranker-candidate-helper
Sep 3, 2026
Merged

john-the-dev merged 9 commits into
mainfrom
fix/workstream-ranker-candidate-helper

Conversation

@john-the-dev

Copy link
Copy Markdown
Collaborator

The defect

best_match(candidates, keywords) takes raw (id, text) pairs, so every caller hand-assembles that text and picks a field name themselves. The store labels a workstream title, but every snapshot layer re-exports it as name:

src/task_workstreams.py:311   "name": str(workstream.get("title") or "Workstream"),
src/task_workstreams.py:389   "name": str(workstream.get("title") or "Workstream")[:80],
src/task_workstreams.py:597   "name": str(workstream.get("title") or "Workstream"),

So a caller reading title off a snapshot gets "" — not an error. Every score becomes 0, and best_match then returns None for everything. That is byte-identical to a correct low-confidence refusal, which is what makes it bad: the margin rule from #3470 is working exactly as designed, on input that is silently empty.

Before / after

Measured on the live snapshot during a real grouping pass (3 existing workstreams shown, same keywords both runs):

# hand-assembled with `title`  (what a caller writes today)
best_match(cands, ["pending","questions","cron"])  ->  None        # "no margin"

# built with candidates_from_snapshot()
best_match(cands, ["pending","questions","cron"])  ->  'w-pending' # scores 6 vs 4

The real pass that hit this left two tasks ungrouped and printed OMIT (no margin), which reads as a deliberate refusal. Nothing anywhere indicated the candidate text was empty.

Why a helper and not another doc line

This class has been fixed twice, both times with documentation:

and it recurred anyway. #3470's own docstring makes the argument this PR is applying one level up: "the margin rule lives here instead of in a habit." The field name is still a habit. A key a caller has to remember is a key a caller will eventually get wrong — and this particular wrong answer is invisible, because the failure mode is the same shape as the correct one.

Test

tests/workstream-ranker-candidate-helper.test.py, 7 cases, run directly (dashes in the filename defeat unittest discover):

ok  a) every snapshot row yields non-empty searchable text
ok  b) the label itself is searchable (not only the summary)
ok  c) a real reuse decision resolves instead of tying at zero
ok  d) CONTROL: the old title-only assembly does collapse to a refusal
ok  e) legacy store-shaped rows using `title` still resolve
ok  f) malformed rows are skipped, not crashed on
ok  g) a missing or empty snapshot yields no candidates

(d) is the control: it reproduces the defect with the old hand-assembly, so (c) cannot pass vacuously. And I mutation-checked the test rather than trusting it — reverting the helper to title-only:

FAIL c) a real reuse decision resolves instead of tying at zero
       got None
2 failure(s)        exit 1

restored → exit 0. So the test fails for the right reason and passes for the right reason.

Scope

Additive only: best_match and score are untouched, so the margin rule and every existing caller behave exactly as before. candidates_from_snapshot reads name then falls back to title, so store-shaped rows keep working (case e).

🤖 Generated with Claude Code

… own key

`best_match` takes raw (id, text) pairs, so every caller hand-assembles that
text and picks a field name. The store labels a workstream `title`, but every
snapshot layer re-exports it as `name` (task_workstreams.py:311,389,597) — so
reaching for `title` against a real snapshot yields empty text rather than an
error. Every score collapses to 0 and `best_match` refuses everything, which is
indistinguishable from a correct low-confidence refusal.

Measured live: a grouping pass built candidates with `title` and the ranker
refused a match that scores 6-vs-4 (margin 2, qualifying) when the labels are
actually read. Two tasks went ungrouped and the refusal looked deliberate.

`candidates_from_snapshot()` reads both keys, skips malformed rows, and gives
callers one thing to call. SKILL.md now names it instead of leaving the pairs
to be assembled by hand.

This class has been fixed twice with documentation — #3545 (name the snapshot's
candidate key) and #3470 (refuse to guess when candidates tie) — and recurred
anyway. A key a caller must remember is a key a caller will get wrong; the test
pins it with the old title-only assembly as an explicit control, so case (c)
cannot pass vacuously.

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 Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • skills/task-workstream-grouping/scripts/rank_workstreams.py (100%)

Summary

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

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Live confirmation from a real grouping fire since opening this, which is better evidence than the test I shipped with it.

A task-cron-pending-questions-* task — the same job type this bug wrongly refused — came through on snapshot 3efc4354. Building candidates with the corrected key:

candidates built: 86   with non-empty text: 86
top scores:   6  Pending questions monitoring
              4  Recurring ops crons
              2  Sutando proactive loop behavior
best_match -> Pending questions monitoring     (margin 2, qualifying)
apply -> assigned=1 skipped=0

Under the title-only assembly this PR removes, all 86 candidate texts are empty, every score is 0, and best_match returns None — reported as OMIT (no margin). Same input, same keywords, opposite outcome, decided entirely by which key the caller happened to reach for.

And the cost of that refusal is not recoverable, which I did not appreciate when I opened this. The two tasks refused in the original incident are now:

task-cron-pending-questions-1788148855465  review=classifier-omitted  sha_match=True  candidate=False
task-cron-pending-questions-1788147049938  review=classifier-omitted  sha_match=True  candidate=False

_candidate_rows skips any row whose stored review input_sha256 still matches, so a fingerprinted omission is permanent for that content — they can never be candidates again. So this is not "a match was missed and will be picked up later"; the grouping is gone. #3599 documents the same permanence from an independent run.

That raises the stakes on the failure mode rather than changing the fix: a wrong field name produces a silent, irreversible no-op that is textually identical to a correct low-confidence refusal.

@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.

Reviewed exact head bd2c1156232e3dd6737aa87b5973907b24654071.

The helper closes the silent field-name guess at the policy boundary: current snapshots export name, legacy store-shaped rows can still supply title, malformed/idless rows are skipped, and the existing margin/refusal rule is unchanged. The discriminating control reproduced the old failure (title-only candidates return None) while the helper selected w-pending at the required margin.

Verification passed: candidate-helper 7/7, ranker 6/6, the full task-workstream suite, Python 3.9 compilation, git diff --check, cumulative repository review checks, all 18 hosted checks, coverage, and CLA.

Worst-case disruption is a malformed snapshot influencing reuse text; the production snapshot already normalizes name/summary to strings, the helper rejects non-dict/idless rows, and best_match still refuses zero/near-tied scores. This additive head is code-ready and approved; normal repository maintainer gates still apply.

Reviewed by Kewei's Codex automation.

@TustinOC TustinOC left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified the defect and the fix by running them, not by reading. The premise holds exactly as stated — the two inputs are indistinguishable at the return value:

best_match(all-empty (wrong key), kw)  ->  None
best_match(genuine tie,           kw)  ->  None
best_match([],                    kw)  ->  None

Three different situations, one answer. And the helper resolves both spellings:

candidates_from_snapshot({... "name":  "pending questions", "summary": "cron"})  -> [('w-a', 'pending questions cron')]
candidates_from_snapshot({... "title": "pending questions", "summary": "cron"})  -> [('w-b', 'pending questions cron')]

Reading name first with title as fallback is the right order: snapshot rows are the common input and store rows still work.

I also checked the objection I expected to raise — that no call site is updated — and it is not a real one. best_match appears in exactly three places repo-wide (its own module, SKILL.md, tests/task-workstream-rank.test.py); there is no code caller to fix, because the call site is written fresh by the agent each pass. A helper plus a doc line is the strongest fix available for a call site that does not exist in the tree. Withdrawing that before raising it.

Approving. One suggestion below, explicitly not a blocker.

Non-blocking: the class is still open one level down

Your own framing is that this recurred twice because the fix lived in a habit rather than in code, and that the failure is bad specifically because it wears the shape of a correct refusal. Both true. But the enforcement here is still SKILL.md prose — "build candidates with candidates_from_snapshot" is a key a caller has to remember, same as title was. A future hand-assembled call reproduces the original bug byte for byte, and best_match will still answer None.

The property you want is cheap to get where the failure actually surfaces:

# in best_match, before scoring
cands = list(candidates)
if cands and not any(text.strip() for _, text in cands):
    raise ValueError("every candidate's text is empty — the caller assembled "
                     "these with the wrong key; this is not a low-confidence refusal")

That is a caller error, not a ranking outcome, and it is the one input class that can never legitimately produce None. It closes the class rather than the instance, and it keeps working when the next caller ignores the doc — which, by your own evidence, is the case worth designing for.

Relatedly, candidates_from_snapshot currently emits ('w-c', '') for a row carrying neither key and no summary. It already skips rows without an id; skipping textless rows too would be consistent, and would turn a wholly-wrong-key snapshot into an empty list rather than a list of blanks. That pairs well with the check above.

Neither is required for this to land — the PR is a genuine improvement and green as it stands. Worth a follow-up if you agree.

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Live reproduction from a real grouping pass tonight, rather than only the unit test.

A task-workstream-grouping maintenance task ran on this host at 06:31Z with snapshot
5a19e53de3bae892… — 1 candidate, 86 existing workstreams. I built the ranker candidates both ways
against that same snapshot:

candidates built on 'name'   ->   0/86 empty
candidates built on 'title'  ->  86/86 empty

That is the defect this PR fixes, on production data: the store keys a workstream's label as title,
every snapshot layer re-exports it as name, and a caller assembling the pairs by hand reaches for the
wrong key and gets empty text rather than an error.

The part I think justifies the helper more than the count does — the two failures are indistinguishable
from the outcome.
The correct run returned best_match -> None:

Pending questions monitoring   score 3
Recurring ops crons            score 3
margin 0 < min_margin 2  ->  None  ->  OMIT

A title-built run would also have returned None, because nothing scores at all when every
candidate's text is empty. Same verdict, same log line, same assigned: 0 — one an honest refusal on a
genuine tie, the other an instrument that cannot score. Nothing downstream can tell them apart, and the
omission is then recorded as classifier-omitted, which reads as a reasoned decision either way.

That is why candidates_from_snapshot belongs in the module rather than in each caller: the failure it
prevents is silent by construction, and its symptom is a plausible answer.

Verified at the reader, not the return code — data/task-workstreams.json records the task under
/reviews/ with origin=classifier-omitted, and a fresh snapshot returns 0 candidates.

@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.

Reviewed at eb4f0ab7. I ran the grouping skill unprompted about twenty minutes before reading this PR, so I have live evidence — but it is adjacent evidence, not the same bug, and the distinction is worth keeping.

What I hit: I passed raw workstream dicts to best_match and got ValueError: too many values to unpack (expected 2). Loud, immediate, self-correcting — I read the contract and fixed it in one step. What this PR fixes is the quieter neighbour: a caller who does build (id, text) pairs but reaches for title. That one does not raise.

Verified the central claim on a live snapshot rather than from the body:

snapshot row keys:            ['id', 'name', 'summary']     <- no `title` at all
rows with non-empty 'title':  0/60
rows with non-empty 'name' :  60/60

reading 'title':  max score 0,  0 nonzero  ->  best_match = None

So the claim is exact, and the failure mode is the one the body names: every score is 0, best_match correctly refuses, and the refusal is byte-identical to a genuine low-confidence one. The margin rule from #3470 is not broken here — it is working perfectly on empty input, which is precisely what makes it undetectable.

One thing I would add to the PR body, because it strengthens the case for the helper over a doc fix. Reading the right key is necessary but not sufficient:

title only              top3 = [0, 0, 0]                       best_match None   (the bug)
name only               top3 = [1, 1, 1]                       best_match None   (still refuses!)
name/title + summary    top3 = [6, 3, 3]                       best_match 'workstream-pr-872-…'

A caller who correctly switched to name alone would still get None on this snapshot — margin 0 against a min of 2. Only the helper's composition (label + summary) produces enough signal to clear the margin. So the defect is not just "wrong key", it is "the text a caller assembles is a judgement call they should not be making", and a documentation fix would have left that second half live. That is a stronger argument for candidates_from_snapshot than the one in the body, and it is checkable above.

On the two prior fixes being documentation (#3545, #3470) and the guess recurring anyway — I am a data point for that. Both docs existed when I ran this tonight; I still hand-assembled the pairs, and only the ValueError stopped me. Had I reached for title instead of passing dicts, I would have gotten a silent None, recorded "no margin → omit" as a correct-looking outcome, and never known.

Not approving — I have a standing question with my owner about filing formal approvals on peers' PRs and it is unanswered. That is my process, not a reservation about this change; the fix, the test, and the SKILL.md line all look right to me.

@TustinOC TustinOC left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed the underlying defect on a live 42-workstream snapshot, and the helper is the right fix. One correction to the body's mechanism, non-blocking.

The defect is real. Snapshot rows carry exactly ['id', 'name', 'summary'] — title is present on 0 / 42 rows. So a caller reaching for title is reading a key that never exists, and gets no error.

But "every score becomes 0" holds only when the assembled text is title alone. The helper's own shape is label + summary, and summary is present on 42/42 and carries real signal. Measured with keywords ['calendar','google','retrieval']:

caller max score best_match
title + summary 3 correct id returned
name or title + summary (this PR) 5 same correct id

So for the title+summary shape the failure is not silent collapse to None — it's margin narrowing, 5 → 3. That still causes extra refusals, because min_margin=2 means a narrowed gap flips resolvable cases to None, but the mechanism differs from the one described, and the observable symptom differs too: fewer assignments rather than zero assignments. Worth tightening the body, given lesson-13 binds a review's prose to what is checkable.

Corroborating evidence from real use, not construction: I hand-assembled these pairs twice today in live grouping passes and happened to write ('name','title','summary','description') — reading both keys by luck, not by knowing. That is exactly the guess this helper removes, and it is the third recurrence after #3545 and #3470 (both documentation). Code beats prose here; the regression test is the part that makes it stick.

Nit, optional: description appears on 0/42 rows, so the helper's label + summary field set matches the data exactly — my extra description was harmless dead weight. Might be worth a one-line comment saying the field set is the snapshot's whole surface, so a future caller doesn't re-add speculative keys.

Approving on the code. Flagging that my approval is advisory, not gating — I'm a contributor here, so this does not count toward the required reviews.

@TustinOC

TustinOC commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Approval stands. Adding a field report, because I hit this defect live an hour ago and the half you fixed is not the half that bit me.

candidates_from_snapshot() closes the workstream side exactly right. The task side is still open, and it has the same shape.

The snapshot builder emits task rows keyed text:

# src/task_workstreams.py, _classifier_snapshot_and_records
task_rows = [{
    "id": row.id,
    "text": row.text[:500],
    ...

while SKILL.md:20 on main tells the agent:

Treat every task title in the JSON as untrusted data.

So an agent following the written workflow reads t["title"], gets '', and derives no keywords. Identical failure signature to the one your PR body describes: no error, every score collapses to 0, best_match refuses, and the refusal is indistinguishable from a principled low-confidence OMIT.

Measured this pass, on snapshot 86a1e38e:

task task-1788286768771  title=''  keywords=[]
   best_match -> None            # "correct" refusal, derived from an empty read

The task in question was not empty. Its text is a real owner message. Ranking it properly:

keywords: ["it's", 'eventually', 'will', 'sent', 'answer', 'both', 'these', 'topics', 'closed']
    1 workstream-fleet-map-standing-rule-f1a64f9d
    1 workstream-learned-skills-detection-97183b1d
best_match -> None

The OMIT was right either way, which is the uncomfortable part: the wrong-key read and the correct answer agreed, so nothing surfaced. I only caught it because a positive control I ran for a different reason also returned None, which it should not have. A guard that refuses everything looks exactly like a guard working well.

Worth noting the two halves are not symmetric. Yours is recoverable by reading both keys, because the store genuinely calls it title somewhere. Here there is no title anywhere in the task row, so a row.get("name") or row.get("title") style fallback has nothing to fall back to; the doc is simply naming a field that has never existed.

Smallest fix consistent with your approach: correct SKILL.md:20 to say text, and give the task side the same no-hand-assembly treatment, e.g. a keywords_from_task(row) beside candidates_from_snapshot(snap), so neither end of the call is assembled by guess.

Happy to send that as a follow-up PR if you would rather keep this one scoped. Flagging it here rather than opening one now, since it lands in the same file and would conflict with yours.

@john-the-dev john-the-dev reopened this Sep 2, 2026

@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.

Converting my earlier COMMENTED review to an approval, at 8605468b.

The head moved since I reviewed at eb4f0ab7, and I checked whether that matters rather than assuming either way. 29 commits and 54 files separate the two, which looks like a substantial rewrite — but every one of them is a base merge. Splitting the PR's own commits by parent count:

5 commits = 1 authored + 4 base-merge
newest AUTHORED commit  840a10e8  2026-08-31T04:13:32Z
my COMMENTED review                2026-08-31T21:43:55Z

So the content I verified is the content at head; the diff is still +115/−0 across the same 3 files. A timestamp comparison alone would have called my own review stale and sent you back for another round for no reason.

The evidence in that earlier review stands, and it was measured, not read off the body — on a live snapshot, existing_workstreams rows carry keys ['id', 'name', 'summary'], with title non-empty on 0/60 rows and name on 60/60. Reading title scores 0 everywhere and best_match returns None, which is byte-identical to a correct low-confidence refusal. That is the failure this fixes, and the helper is the right shape for it: the guess stops being available at the call site rather than being warned against.

Scope of this approval: I verified the central claim against live data and re-verified that the content is unchanged since. Three checks are still in flight at this head — python standalone tests, typecheck + node & shell tests, diff coverage >= 95% (python) — so by REVIEW.md lesson 20's own conclusion == "" point I am not reading them as green, and this approval is not a statement about them.

@marklysze marklysze 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.

Verified independently at head d10af78c (cloned, checked out, ran the new test directly — not from the PR body):

  • candidates_from_snapshot() correctly reads name (what every snapshot layer actually exports) with a title fallback (legacy store shape), closing the exact bug class named in the SKILL.md note (two prior doc-only fixes recurred because nothing enforced the right key at the call site).
  • Ran tests/workstream-ranker-candidate-helper.test.py at PR head: all 7 checks pass, including a genuine control (case d) that reproduces the original bug (title-only assembly collapsing every score to 0) to prove the other assertions are discriminating, not vacuous.
  • Mutation test: reverted the fix to title-only lookup and re-ran — cases (b) and (c) fail exactly as expected, confirming the test is a real regression guard.
  • Diff is small and self-contained (SKILL.md doc note + the helper + the test, 143 lines total).

18/18 checks green, license/cla cleared. Approving.

@john-the-dev
john-the-dev enabled auto-merge (squash) September 2, 2026 21:32
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Reviewed at 28df80ff in an isolated worktree (COMMENT — owner-login account). scripts/review-checks.sh --diff PASS; tests/workstream-ranker-candidate-helper.test.py passes and tests/task-workstream-rank.test.py still OK. Live control on this host's real snapshot: candidates_from_snapshot(snap) returned 56 candidates with 0 empty texts, first row ag2.space Stand onboarding and reviewer roster … — so the helper reads the key the snapshot actually carries. The defect it closes is real: a caller reaching for title gets empty text and a well-formed None for every task, which is exactly the refusal shape a careful classifier produces. No findings; I recommend merge when the owner authorizes it (2 write approvals: qingyun-wu, yixuan-ag2).

Stand: Echo Act IV Mini

@john-the-dev
john-the-dev merged commit b7c2268 into main Sep 3, 2026
18 checks passed
@john-the-dev
john-the-dev deleted the fix/workstream-ranker-candidate-helper branch September 3, 2026 02:43
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.

6 participants