From 7d80295415cd814fc2f6e6ae915f757b2857e838 Mon Sep 17 00:00:00 2001 From: scanner Date: Mon, 5 Oct 2026 08:53:36 -0400 Subject: [PATCH] fix: exclude hard-suppress labelled issues from agent work lists and kick claims Signed-off-by: scanner Co-authored-by: clubanderson <407614+clubanderson@users.noreply.github.com> --- changelog.d/fixed-10553.md | 1 + src/docs/labels-and-control-signals.md | 6 +-- src/pkg/github/client.go | 2 +- src/pkg/github/hard_suppress_labels_test.go | 49 +++++++++++++++++++++ src/pkg/github/labels.go | 33 +++++++++++++- 5 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixed-10553.md create mode 100644 src/pkg/github/hard_suppress_labels_test.go diff --git a/changelog.d/fixed-10553.md b/changelog.d/fixed-10553.md new file mode 100644 index 0000000000..3432aeb5a5 --- /dev/null +++ b/changelog.d/fixed-10553.md @@ -0,0 +1 @@ +- Issues labelled `needs-direction`, `needs-decision` or `needs-spec` are now filtered from the actionable work list like `needs-human`, so agent kicks no longer name, and re-claim every cycle, an issue parked on a maintainer ([#10553](https://github.com/hivecommons/hive/issues/10553)). diff --git a/src/docs/labels-and-control-signals.md b/src/docs/labels-and-control-signals.md index d44e3c699c..254797d6c3 100644 --- a/src/docs/labels-and-control-signals.md +++ b/src/docs/labels-and-control-signals.md @@ -4,7 +4,7 @@ This is the operator view of Hive labels: if you add or remove a label, this is ## How labels reach agents -Most hard gates run before an agent sees work. GitHub issue enumeration reads labels in this order: standing meta issues are excluded first, then hold labels move the item to the Hold list, then exempt labels and issue-level `needs-human` filter it out, then `project.issue_filter.require_labels` admits or rejects the issue, and only then is it actionable (`src/pkg/github/client.go:955-1045`). Pull requests use the same hold-first enumeration for the PR Hold list (`src/pkg/github/client.go:1110-1165`). That means kick prompts, dashboard actionable counts, planning-from-label, and contributor offers normally inherit the same gate. +Most hard gates run before an agent sees work. GitHub issue enumeration reads labels in this order: standing meta issues are excluded first, then hold labels move the item to the Hold list, then exempt labels and the issue-level hard-suppress labels (`needs-human`, `needs-direction`, `needs-decision`, `needs-spec`; `hardSuppressIssueLabels` in `src/pkg/github/labels.go`) filter it out, so no agent kick names, and no kick claims, a parked issue, then `project.issue_filter.require_labels` admits or rejects the issue, and only then is it actionable (`src/pkg/github/client.go:955-1045`). Pull requests use the same hold-first enumeration for the PR Hold list (`src/pkg/github/client.go:1110-1165`). That means kick prompts, dashboard actionable counts, planning-from-label, and contributor offers normally inherit the same gate. Some sweeps bypass that enumeration and list items themselves, but every one of them gates on the same hold predicate as enumeration (`Client.IsHeldLabels` / `Client.isHeld`, `src/pkg/github/client.go:1964-1972`, `src/pkg/github/client.go:2674-2676`), so the generic hold substrings and the exact dashboard `hive-pause/` label hold everywhere: @@ -68,8 +68,8 @@ The "respect hold labels" text in policy templates names the enforced set (`hold | `hive/likely-done` | Issues | PR-claim label sync and dashboard | Merged PR is verified as related while issue remains open; issue remains actionable until GitHub/operator closes or confirms. | Display-only | Hive | Claim sync after enumeration | Hive removes when actionable evidence no longer says likely done | Fixed label (`src/pkg/github/prclaims.go:1380-1435`) | | `hive/already-done` | Issues | Contributor queue and dashboard | Contributor already-done verdict/confirmation; default contributor skip label and done band. Spoke agents can still receive the issue unless another gate also filters it. | Contributor hard skip; dashboard display | Hive/contributor flow | Contributor admission | Human removes or config changes; optional close behavior | `hub.contribute_already_done_label`, `contribute_close_already_done` (`src/pkg/config/config.go:4442-4572`, `src/pkg/config/config.go:4585-4685`) | | `blocked` | Issues | Contributor queue and dashboard issue bands | Always included in contributor skip patterns and default waiting band. Does not by itself stop spoke-agent enumeration unless also exempt/held/filtered. | Contributor hard skip; display | Human | Contributor admission/dashboard render | Remove label | `hub.contribute_skip_labels`, dashboard bands (`src/pkg/config/config.go:4585-4685`, `src/docs/dashboard.md:105-140`) | -| `needs-direction` | Issues | Escalation labels, dashboard issue bands and un-park sweep | ADR-0019 escalation marker for work that needs a maintainer direction decision before continuing. The un-park sweep keeps the same "What to reply" notice as `needs-human`/`needs-decision` and accepts the same `/hive approve` or `/hive decision ` commands. See [Maintainer commands](maintainer-commands.md). | Hard suppress until directed | Hive/human | Dashboard render and un-park sweep | Human removes, or a maintainer replies `/hive approve` / `/hive decision `, which also adds `approved-direction` and never touches `hold` (`src/pkg/github/issue_unpark_command.go`) | Fixed escalation label (`src/pkg/github/labels.go`, `src/docs/adr/0019-escalation-over-stalling.md`) | -| `needs-decision` | Issues | Contributor relay, dashboard issue bands and un-park sweep | Relay can apply it when a maintainer decision is needed; contributor queue skips it. The un-park sweep keeps the "What to reply" notice and command handling documented in [Maintainer commands](maintainer-commands.md). | Contributor hard skip; display | Relay/Hive/human | Contributor admission/dashboard render | Human removes, or a maintainer replies `/hive approve` / `/hive decision `, which also adds `approved-direction` and never touches `hold` (`src/pkg/github/issue_unpark_command.go`); empty config disables relay application | `hub.contribute_needs_decision_label` (`src/pkg/config/config.go:4397-4408`, `src/pkg/config/config.go:4645-4685`) | +| `needs-direction` | Issues | Enumeration, escalation labels, dashboard issue bands and un-park sweep | ADR-0019 escalation marker for work that needs a maintainer direction decision before continuing. Filtered from enumeration like `needs-human`: no agent kick, kick claim or contributor offer. The un-park sweep keeps the same "What to reply" notice as `needs-human`/`needs-decision` and accepts the same `/hive approve` or `/hive decision ` commands. See [Maintainer commands](maintainer-commands.md). | Hard suppress until directed | Hive/human | Dashboard render and un-park sweep | Human removes, or a maintainer replies `/hive approve` / `/hive decision `, which also adds `approved-direction` and never touches `hold` (`src/pkg/github/issue_unpark_command.go`) | Fixed escalation label (`src/pkg/github/labels.go`, `src/docs/adr/0019-escalation-over-stalling.md`) | +| `needs-decision` | Issues | Enumeration, contributor relay, dashboard issue bands and un-park sweep | Relay can apply it when a maintainer decision is needed; filtered from enumeration like `needs-human`, so no agent kick, kick claim or contributor offer. The un-park sweep keeps the "What to reply" notice and command handling documented in [Maintainer commands](maintainer-commands.md). | Contributor hard skip; display | Relay/Hive/human | Contributor admission/dashboard render | Human removes, or a maintainer replies `/hive approve` / `/hive decision `, which also adds `approved-direction` and never touches `hold` (`src/pkg/github/issue_unpark_command.go`); empty config disables relay application | `hub.contribute_needs_decision_label` (`src/pkg/config/config.go:4397-4408`, `src/pkg/config/config.go:4645-4685`) | | `needs-triage`, `discussion`, `question`, `tracking`, `epic` | Issues | Contributor queue | Default contributor skip labels/patterns. | Contributor hard skip | Human/Hive | Contributor admission | Remove label or config | `hub.contribute_skip_labels`, `HIVE_CONTRIBUTE_SKIP_LABELS` (`src/pkg/config/config.go:4585-4685`) | | Contributor allow/deny label filters | Issues | Contributor queue | Hive-wide label filters can run in deny mode (skip if any label matches) or allow mode (offer only if at least one label matches); per-repo filters layer on top and can only narrow offers. Patterns use Hive wildcard/substring matching, not GitHub hold matching. | Contributor hard gate | Operator | Contributor admission | Edit filter mode/list or per-repo filter | `hub.contribute_labels_mode`, `contribute_deny_labels`, `contribute_allow_labels`, `contribute_repo_filters` (`src/pkg/config/config.go:4388-4420`, `src/pkg/config/config.go:6940-6994`, `src/pkg/config/config.go:7008-7055`) | | `1-triage`, `2-discussing`, stage labels | Issues | Dashboard repo-card bands/legend | Stage vocabulary is display-only unless another consumer names the same label. `2-discussing` ships in the default waiting band; `1-triage` is just a visible repo taxonomy label unless configured elsewhere. | Display-only | Human/repo automation | Dashboard render | Remove label or change band config | `dashboard.issue_bands` (`src/docs/dashboard.md:105-140`) | diff --git a/src/pkg/github/client.go b/src/pkg/github/client.go index f782d67c19..9341385d0d 100644 --- a/src/pkg/github/client.go +++ b/src/pkg/github/client.go @@ -1169,7 +1169,7 @@ func (c *Client) fetchIssues(ctx context.Context, repo string, now time.Time) (a continue } - if c.isExempt(labels) || hasIssueNeedsHumanLabel(labels) { + if c.isExempt(labels) || hasHardSuppressIssueLabel(labels) { breakdown.Filtered++ continue } diff --git a/src/pkg/github/hard_suppress_labels_test.go b/src/pkg/github/hard_suppress_labels_test.go new file mode 100644 index 0000000000..551ff67cc2 --- /dev/null +++ b/src/pkg/github/hard_suppress_labels_test.go @@ -0,0 +1,49 @@ +package github + +import ( + "context" + "net/http/httptest" + "testing" +) + +// A parked issue must never reach a kick work list or contributor offer, or +// the kick path re-claims it every cycle (#10553). +func TestHardSuppressLabelsExcludedFromActionableWork(t *testing.T) { + for _, label := range []string{"needs-direction", "needs-decision", "needs-spec", "needs-human", " Needs-Direction ", "hold"} { + t.Run(label, func(t *testing.T) { + issues := []wireIssue{ + {Number: 1, Title: "parked", User: wireUser{"alice"}, CreatedAt: hoursAgo(1), Labels: []wireLabel{{Name: "help wanted"}, {Name: label}}}, + {Number: 2, Title: "open work", User: wireUser{"alice"}, CreatedAt: hoursAgo(1), Labels: []wireLabel{{Name: "help wanted"}}}, + } + srv := httptest.NewServer(buildMux(t, "o", "r", issues, nil)) + defer srv.Close() + result, err := newTestClient(t, srv, "o", []string{"r"}).EnumerateActionable(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result.Issues.Items) != 1 || result.Issues.Items[0].Number != 2 { + t.Fatalf("actionable issues = %+v, want only #2", result.Issues.Items) + } + }) + } +} + +func TestHasHardSuppressIssueLabel(t *testing.T) { + for _, tc := range []struct { + labels []string + want bool + }{ + {[]string{"needs-direction"}, true}, + {[]string{"bug", "NEEDS-DECISION"}, true}, + {[]string{" needs-spec "}, true}, + {[]string{"needs-human"}, true}, + {[]string{"help wanted"}, false}, + {[]string{"needs-signal"}, false}, + {[]string{"needs-human-followup"}, false}, + {nil, false}, + } { + if got := hasHardSuppressIssueLabel(tc.labels); got != tc.want { + t.Errorf("hasHardSuppressIssueLabel(%q) = %v, want %v", tc.labels, got, tc.want) + } + } +} diff --git a/src/pkg/github/labels.go b/src/pkg/github/labels.go index bd3ef698a4..d090740fb4 100644 --- a/src/pkg/github/labels.go +++ b/src/pkg/github/labels.go @@ -1,18 +1,47 @@ package github +import "strings" + type issueLabelDefinition struct { color string description string } -const issueNeedsDirectionLabel = "needs-direction" +const ( + issueNeedsDirectionLabel = "needs-direction" + issueNeedsSpecLabel = "needs-spec" +) + +// hardSuppressIssueLabels are the escalation labels that park an issue on a +// person ("Hard suppress until directed" in docs/labels-and-control-signals.md). +// An issue carrying one never enters the actionable set, so no agent kick or +// contributor offer can name or claim it. Hold and do-not-merge labels are +// handled by the hold list and exempt filter respectively. +var hardSuppressIssueLabels = []string{ + issueNeedsHumanLabel, + issueNeedsDirectionLabel, + issueNeedsDecisionLabel, + issueNeedsSpecLabel, +} + +func hasHardSuppressIssueLabel(labels []string) bool { + for _, label := range labels { + label = strings.TrimSpace(label) + for _, suppress := range hardSuppressIssueLabels { + if strings.EqualFold(label, suppress) { + return true + } + } + } + return false +} var escalationIssueLabelDefinitions = map[string]issueLabelDefinition{ issueNeedsDirectionLabel: { color: "d4c5f9", description: "Hive needs a maintainer direction decision before continuing", }, - "needs-spec": { + issueNeedsSpecLabel: { color: "bfd4f2", description: "Hive needs a specification or acceptance criteria before continuing", },