diff --git a/.github/workflows/coverage-hourly.yml b/.github/workflows/coverage-hourly.yml index 657544d330..02cbd5edb6 100644 --- a/.github/workflows/coverage-hourly.yml +++ b/.github/workflows/coverage-hourly.yml @@ -445,6 +445,14 @@ jobs: echo -e "${REPORT}" } >> "$GITHUB_STEP_SUMMARY" + # Written once the verdict is known and before either outcome (the + # `exit 1` shortfall below or success), so it means "measurement + # reached a verdict", not "gate passed". The profile artifact cannot + # serve as this signal: a hung run leaves a partial cover-run.log + # that the always() upload still ships during cancellation cleanup + # (#12951). + echo "$GITHUB_SHA" > coverage-measured.marker + if [ -n "$BROKEN" ] || [ -n "$FAILED" ]; then echo "below_threshold=true" >> "$GITHUB_OUTPUT" { @@ -480,6 +488,15 @@ jobs: if-no-files-found: ignore retention-days: 14 + - name: Upload measurement-complete marker + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage-measured-${{ github.run_id }}-${{ github.run_attempt }} + path: src/coverage-measured.marker + if-no-files-found: ignore + retention-days: 14 + create-issue-on-coverage-drop: # Scheduled runs only. The bare `failure()` here previously fired on ANY # job failure regardless of cause, which is why issue #2191 accumulated 41 @@ -487,6 +504,12 @@ jobs: # test failure aborting the run. Now that the gate also runs on # pull_request, an unscoped condition would additionally file an issue for # every contributor's red PR. + # A cancelled coverage job carries no outputs. If it was cancelled AFTER the + # measurement finished (timeout during post-step cleanup, #12944) the + # measurement-complete marker artifact exists and filing would only produce a false "measured unknown" + # alarm, so the script below exits early. If it was cancelled BEFORE the + # marker was written (a hung suite hitting the job timeout, #12951) the + # issue is still filed so a persistent hang does not go unreported. if: always() && github.event_name == 'schedule' && needs.coverage.result != 'success' && needs.coverage.result != 'skipped' runs-on: ${{ fromJSON(vars.HIVE_RUNNER_LABELS || '["ubuntu-latest"]') }} timeout-minutes: 10 @@ -494,6 +517,7 @@ jobs: permissions: contents: read issues: write + actions: read steps: - name: Create or update coverage issue uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -513,6 +537,25 @@ jobs: const total = '${{ needs.coverage.outputs.total }}' || 'unavailable'; const shortSha = measuredSha.substring(0, 7); + // Cancelled jobs propagate no outputs, so the marker artifact is the + // only evidence that the measurement completed (#12944, #12951). The + // profile artifact is unreliable here: partial logs from a hung run + // are still uploaded during cancellation cleanup. + const cancelled = '${{ needs.coverage.result }}' === 'cancelled'; + if (cancelled) { + const markerName = 'coverage-measured-${{ github.run_id }}-${{ github.run_attempt }}'; + const arts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + per_page: 100, + }); + if (arts.some(a => a.name === markerName && !a.expired)) { + core.info('Coverage job was cancelled after the measurement completed; no issue (#12944).'); + return; + } + } + // The gate fails for two DIFFERENT reasons and they need different // fixes: a package under its floor, or a package that could not be // scored at all (tests failing / no test files). Conflating them is @@ -525,7 +568,9 @@ jobs: // Calling that "below their coverage floor" sent #9632 to agents // that added tests for a workflow failure (#9645). const neverMeasured = !'${{ needs.coverage.outputs.measured_ref }}'; - const cause = neverMeasured + const cause = cancelled + ? 'The coverage job was **cancelled before any coverage verdict reached this job** (for example a hung test suite hitting the job timeout, or a cancellation during setup). No verdict was recorded, so this is not evidence of a coverage shortfall - open the run log to see where it stopped.' + : neverMeasured ? 'The coverage job **failed before any package was measured** (workflow, setup, or runner failure). This is not a coverage shortfall and adding tests will not fix it - open the run log for the failing step.' : cannotScore ? 'One or more packages **could not be scored** (tests failing, or no test files). That is a broken-test problem, not a coverage shortfall β€” fix the tests first; the floor cannot be evaluated until they run.' @@ -537,7 +582,9 @@ jobs: // The title must not claim a shortfall the body contradicts: // triage reads titles first (#9664 audit). Both keep the // [Coverage] prefix the dedupe below matches on. - const title = neverMeasured + const title = cancelled + ? `[Coverage] gate job cancelled before a coverage verdict` + : neverMeasured ? `[Coverage] gate job failed before measuring coverage` : `[Coverage] packages below 90% threshold`; const body = `## Coverage gate failed\n\n` diff --git a/AGENTS.md b/AGENTS.md index 59a77d3d55..41da9214d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,6 @@ These rules apply to every AI agent (hive agents, Copilot CLI, Claude Code, Code ## Working style - Keep changes surgical and PR-sized; one concern per PR. -- Every PR needs a `changelog.d/-.md` fragment (`added|changed|deprecated|fixed|security`) whose body is a single `- ` bullet. `changelog.d/` is at the repository root, not under `src/` β€” `src/changelog.d/` is never compiled and the guard rejects it. +- Every PR with a user-visible `src/` code change needs a `changelog.d/-.md` fragment (`added|changed|deprecated|fixed|security`) whose body is a single `- ` bullet. Changes outside `src/` (root-level docs, `changelog.d/`, `.github/`), plus `src/docs/`, `*_test.go` files, and `src/{deploy,scripts}/test[-_]*.sh` scripts, are exempt automatically (`src/scripts/check-changelog-fragment.sh` is the source of truth for the exact set); other test-adjacent changes (e.g., `testdata/` fixtures, non-`_test.go` test helpers) and non-user-visible `src/` code changes (refactors, dependency bumps) need the `no-changelog` label instead. `changelog.d/` is at the repository root, not under `src/` β€” `src/changelog.d/` is never compiled and the guard rejects it. - Sign commits (`git commit -s`); PR titles start with an emoji (`✨` feature, `πŸ›` fix, `πŸ“–` docs, `🌱` chore, `⚠️` breaking). - Do not edit `src/docs/api-reference.md` line citations by hand; CI's citation guard reports drift and the fix is `bash src/scripts/check-api-reference-citations.sh --fix` β€” run it only when CI tells you it drifted. The required `changelog-fragment-guard` check runs it (non-fix) when a PR edits a hub or dashboard `server.go` or their `static/` pages, so the PR that causes drift goes red. diff --git a/CHANGELOG.md b/CHANGELOG.md index aef1762eeb..fce8148200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,89 @@ Hive did not historically maintain a complete changelog. This file starts a prag ## Unreleased +## 2026-10-18 (v5.186.4) + +### Fixed + +- Restart an agent whose live CLI pane stays off its input prompt (neither Working nor any prompt) after repeated undeliverable kicks, instead of leaving it wedged with every kick failing as "CLI did not reach input prompt" and, since the self-report now waits for that restart, never reported (#12916). + +## 2026-10-18 (v5.186.3) + +### Fixed + +- Review aggregation: a partial (incomplete) re-review no longer displaces a prior `changes_requested` or `reject` aggregate for the same repo/number/head/body-digest, extending the `requires_human` retention from #12909. PRs mid fix-cycle stay on the fix path instead of being sent back to re-ask missing perspectives, and the original `RecordedAt` is preserved. Fixes #12911. + +## 2026-10-18 (v5.186.2) + +### Fixed + +- Hub-side plumbing for a spoke-reported lane-scoped queue depth: the hub's agents-inactive idle rule now prefers `laneQueuedWork` over the hive-wide backlog when a spoke reports it, preventing an intentionally idle lane from being flagged when other lanes have work. Spokes do not yet report it (follow-up [#12876](https://github.com/hivecommons/hive/issues/12876)), so the alert only narrows once the producer lands ([#12866](https://github.com/hivecommons/hive/issues/12866)). +- Preserve a settled `requires_human` verdict while a replacement review of the same head and PR body is incomplete, preventing partial report refreshes from reopening the decision as review-pending ([#12878](https://github.com/hivecommons/hive/issues/12878)). +- Completes the #12899 follow-through for contributor PRs ([#12897](https://github.com/hivecommons/hive/issues/12897)): the `review_fix_withheld` audit entry now records the actual `review.fix_human_prs` value instead of a hard-coded `false`, and the review-thread kick no longer tells the agent to check out, commit to, or push a human-opened PR's branch with that setting on β€” it replies with a suggestion instead. The propose-only review prompt, operator docs, dashboard help text and config comments are reworded to match. + +## 2026-10-18 (v5.186.1) + +### Fixed + +- Route Hive-authored non-risk reviewer findings with concrete remediation through the normalized changes-requested fixer path across aggregation, stage labels, and fixer dispatch. + +## 2026-10-18 (v5.186.0) + +### Added + +- Add single-click global cadence setting templates (New Level testing, Weekly sprint, Biweekly sprint, and Daily cadence) in the dashboard Governor matrix and via `POST /api/config/governor/cadence-templates` to allow operators to quickly configure governor cadences across all agents without excessive manual clicks (#12300). +- Add `hivectl hives preset adopt` and `hivectl hives preset refresh` to review a contribution preset against your saved repository selection, record the accepted membership with preset provenance, keep personal exclusions and saved repositories unless removal is confirmed, preserve state on discovery outages, and opt in per Hive to following new repositories with `--follow-new-repos` ([#12570](https://github.com/hivecommons/hive/issues/12570)). +- Contributor relays enforce per-Hive repository selections before accepting assignments, negotiate versioned scopes, visibly skip unsupported Hives, and idle on empty selections. +- Auto-promote contributor-lane trust after a clean merged-PR history so reviewed green contributor PRs can merge at L6. +- Show held open issues and PRs as held directly in the dashboard pipeline Open segment. +- `hivectl` can now parse and validate a **contribution preset**: a versioned, credential-free YAML document a project publishes at an HTTPS URL or ships as a file to recommend Hives and repository selections (`all-current` or an explicit `owner/name` list per Hive, plus optional hubs). Loading is strict and read-only: unknown `version` majors, unknown fields, and any field carrying tokens, credentials, shell commands, local execution settings or permission changes are refused with the field named; only `https://` (and loopback `http://`) or local paths are accepted, redirects may not leave HTTPS, and documents are capped at 256 KiB. See "Contribution presets" in `src/docs/hivectl.md` ([#12803](https://github.com/hivecommons/hive/issues/12803), part of [#12570](https://github.com/hivecommons/hive/issues/12570)). +- Wire the background-only Hive-self experiment scheduler into `hive` behind a new default-off `self_experiments.scheduler` block (`enabled`, `mode: background`, `interval_s`). When enabled in background mode, each tick judges every unresolved experiment in the local Hive-self ledger and hands the decision to the executor, which latches fast-fails and reverts treatments durably; reverts are restored at startup before the first heartbeat. Experiments are registered by the hub/relay, which is not yet wired, so the ledger (`/data/nous/self-experiments.json`) stays empty until then. With the default config nothing is loaded and no goroutine starts, and governor configuration is never touched ([#12397](https://github.com/hivecommons/hive/issues/12397), [#12768](https://github.com/hivecommons/hive/issues/12768)). + +### Changed + +- docs: document HIVE_REPO_SCOPE in the env-var references (#12867) +- Let Hive-authored issues resolve non-risk engineering decision questions without self-parking at stage/085. +- Issues parked on a person now wear the new issue-side stage `stage/045-needs-decision`; `stage/085-needs-decision` is PR-only. `needs-human` on an issue implies 045, `/hive approve` / `/hive decision` still un-park to `approved-direction` and keep `needs-human`, the good-first-issue scorer and the dashboard "Needs human" bucket recognise 045, and open issues still carrying 085 are re-staged to 045 by the issue stage reconciler and a one-shot startup sweep (`/data/issue-needs-decision-stage-v1.done`). +- Document `HIVE_REPO_SCOPE` and `HIVE_REPO_SCOPE_VERSION` in the env-var reference and the contributor-relay variable table (relay/hivectl, #12804). +- Let Hive-authored review decisions resolve low-risk sequencing, naming and config-shape questions by default while preserving risk escalations. + +### Fixed + +- Document the supported in-place adoption and offline/unregistered wait settings in `hive.yaml.example` (#12777). +- Correct the offline contributor grace example to match its 15-minute default (#12821). +- contributor-relay: strip the matching shell quotes `hivectl` writes around values in `contributor.env`, so `HIVE_REPO_SCOPE` reads back as written (#12826) +- hub: the zero-spend `token-burn` fleet alert now fires only when token spend is expected (queued work or unpaused expected-active agents) and nothing stronger already explains it β€” all-paused or quiet-by-design hives, fresh advisory output, stale heartbeat stats, a pending App install, a precondition-red health verdict (App broken, login stuck, budget, agents down), or an inference-auth, GitHub-App-required or agents-inactive alert no longer also raise it ([#12844](https://github.com/hivecommons/hive/issues/12844)) +- hub: unclaimed `available` pool slots no longer raise `health-check-failing` for `project_org`, which fails by design on their seeded placeholder org/repo until the slot is claimed ([#12843](https://github.com/hivecommons/hive/issues/12843)) +- hub: the `advisory-stale` fleet alert is no longer raised from a heartbeat carrying cached or minimal stats (`statsStale`), whose `advisoryLastPostedAt` is the last-good value rather than proof the digest stopped posting ([#12842](https://github.com/hivecommons/hive/issues/12842)) +- Contributor-PR adoption now falls back to a superseding App PR when the base branch has moved past a `.github/workflows/` change since the PR's merge-base: the App token cannot push such a base-sync to the contributor branch (no `workflows` permission), so the in-place mode promised by the adoption comment was a dead end that left the PR dirty forever (#12865, #6985). +- The spoke heartbeat now reports each reviewer-lane agent's own actionable queue depth (`laneQueuedWork`), computed with the same lane routing and gates the governor uses to skip an idle lane, so the hub's idle-with-work alert can narrow from the hive-wide backlog to the agent's lane; agents outside the reviewer lane omit the field and keep the hive-wide fallback ([#12876](https://github.com/hivecommons/hive/issues/12876), follow-up to [#12875](https://github.com/hivecommons/hive/pull/12875)). +- Stage-label 085/090 entry comments are now posted once per (stage, head), so a same-head flap through `050-review-pending` no longer reposts a byte-identical comment ([#12878](https://github.com/hivecommons/hive/issues/12878)). A new head or a different human stage still explains itself once. +- review: a partly answered revisit now re-asks only the perspectives that timed out instead of re-kicking the full set (#12883) +- Make the automerge review gate's human-approval read-failure log lane-neutral (#12891). +- List PR reviews once per self-authored sweep instead of twice for requires-human heads (#12892). +- Re-home adopted contributor PRs when unsigned commits cannot be DCO-repaired in place, with fixer instructions for DCO-clean superseding branches. +- Treat conflicting contributor PRs like red CI so offline clanker authors are nudged and adopted after grace. +- Keep conflicting contributor PRs in the author-rework stage instead of ready-for-human-merge while mergeability is known to be conflicting. +- **DCO post-merge monitor: waive `a9200f3d` per maintainer disposition.** The #12707 squash on `v5` kept the scanner `Signed-off-by` instead of the author's, so the monitor kept failing. A SHA-scoped `DCO_WAIVED_COMMITS` entry records the maintainer-accepted disposition without rewriting history or widening any identity allowlist ([#12727](https://github.com/hivecommons/hive/issues/12727)). +- The issue stage sweep now closes a decomposed parent issue as completed once every tracked child (GitHub sub-issue linked by the relay) is closed, with one comment listing each child and its closing PR, and never marks a parent with open children `stage/stalled` (an existing marker comes off). The relay also writes a `` marker into each child it links (#12857). +- Require signed hive-authored fix-pr commits before push (merge commits exempt, as DCO checkers skip them) and choose in-place DCO repair versus re-home for adopted PRs on whether the unsigned commits are GitHub-linked to the hive App bot β€” never on forgeable git emails β€” and whether the branch is still pushable; the ownership split is cached with the commit check and a re-home is attempted once per head. +- Prevented reviewer/fixer follow-up commits from being pushed to non-App-authored PR branches, and keep hive-caused squash sign-off failures in the hive-owned fixing lane instead of returning them to contributors. +- `hivectl` Hive lookup by origin no longer fails for every onboarding target when a legacy profile uses a hub host the canonical identity form rejects (such as compose or Kubernetes service names with `_`); such profiles are skipped. The `HIVE_REPO_SCOPE` documentation now shows the format `hivectl` actually writes (`,` between hubs, `;` between repositories). (#12804) +- Treat trusted human APPROVED reviews on the current head as resolving `requires_human` reviewer verdicts across automerge lanes. +- Route Hive-authored non-risk incomplete-fix reviewer findings to the fixer lane instead of parking them for human decisions. +- `kick_failed_repeatedly` self-reports are now filed only after the busy-no-activity stall restart has been attempted and the session is still undeliverable (or the restart itself errored). Previously the fleet issue was filed at 1Γ— `busy_no_activity_threshold` (30m), before the 2Γ— restart bar, so it reported stalls that then self-recovered. The 30m condition stays visible as the dashboard busy condition and a warning log line; the report message now states when the restart ran and how many times (#12787). +- Apply normalized review verdicts consistently to stage labels and fixer dispatch. +- Shared CI failures now require an independent human-authored control and no green counterexample, so related agent PRs cannot hide the same PR-local defect from the escalation circuit breaker. +- Fix review dispatch so held review-pending pull requests stay in the reviewer queue and are served in review-rank order. +- List held review-pending satellite pull requests in reviewer kicks so the prompt matches the dispatched review queue. +- Derive stage role labels from author identity so non-owning lanes cannot flip trusted App-authored PRs to untrusted. +- Add regression coverage that resolved Hive-authored design questions leave the needs-decision stage label. +- Derive review-driven stage labels from one normalized verdict source to prevent 070/085 flapping. + +### Security + +- The contributor-lane auto-promotion ratchet no longer re-trusts a contributor whose registry tier is `revoked`, and counts a merged PR that a later merged revert undid as revert evidence ([#12874](https://github.com/hivecommons/hive/issues/12874)). + ## 2026-10-17 (v5.185.0) ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d80c715316..aabcbfcf5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -309,7 +309,7 @@ When a fix PR merges with `Closes #N`, `Fixes #N`, or `Resolves #N`, the issue c - Credit the issue's author with a `Co-authored-by:` trailer on the commit when the PR resolves somebody else's issue β€” see [Crediting issue authors](#crediting-issue-authors). - Describe what changed, why, and how you tested it. - Include the relevant command output or a short note such as `Not run (docs only)` when tests are not applicable. -- Add a changelog fragment under [`changelog.d/`](changelog.d/README.md) for user-visible changes β€” features, fixes, security changes, migrations, deprecations, and breaking changes (see "Changelog fragments" below). Routine refactors, test-only changes, and dependency churn are explicitly out of scope β€” add the `no-changelog` label if the advisory `changelog-fragment-guard` check asks anyway. Do **not** append to `CHANGELOG.md`'s `## Unreleased` section directly: every PR editing that one shared heading is what made unrelated PRs merge-conflict with each other ([#5675](https://github.com/hivecommons/hive/issues/5675)); fragments are compiled into [CHANGELOG.md](CHANGELOG.md) automatically at release time. +- Add a changelog fragment under [`changelog.d/`](changelog.d/README.md) for user-visible changes β€” features, fixes, security changes, migrations, deprecations, and breaking changes (see "Changelog fragments" below). Routine refactors, test-only changes, and dependency churn are explicitly out of scope β€” add the `no-changelog` label if the required `changelog-fragment-guard` check asks anyway. Do **not** append to `CHANGELOG.md`'s `## Unreleased` section directly: every PR editing that one shared heading is what made unrelated PRs merge-conflict with each other ([#5675](https://github.com/hivecommons/hive/issues/5675)); fragments are compiled into [CHANGELOG.md](CHANGELOG.md) automatically at release time. - Expect maintainers to ask for focused follow-up changes rather than broad drive-by edits. ## Changelog fragments diff --git a/NOTICE b/NOTICE index 2c20b7d7d7..f1259fb46d 100644 --- a/NOTICE +++ b/NOTICE @@ -4688,9 +4688,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: golang.org/x/net -Version: v0.60.0 +Version: v0.61.0 License: BSD-3-Clause -Source: https://cs.opensource.google/go/x/net/+/v0.60.0:LICENSE +Source: https://cs.opensource.google/go/x/net/+/v0.61.0:LICENSE Copyright 2009 The Go Authors. @@ -4758,9 +4758,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: golang.org/x/sync/errgroup -Version: v0.23.0 +Version: v0.24.0 License: BSD-3-Clause -Source: https://cs.opensource.google/go/x/sync/+/v0.23.0:LICENSE +Source: https://cs.opensource.google/go/x/sync/+/v0.24.0:LICENSE Copyright 2009 The Go Authors. @@ -4828,9 +4828,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- Package: golang.org/x/text -Version: v0.42.0 +Version: v0.43.0 License: BSD-3-Clause -Source: https://cs.opensource.google/go/x/text/+/v0.42.0:LICENSE +Source: https://cs.opensource.google/go/x/text/+/v0.43.0:LICENSE Copyright 2009 The Go Authors. diff --git a/bin/contributor-relay.js b/bin/contributor-relay.js index 4999d38f83..af979c73ac 100755 --- a/bin/contributor-relay.js +++ b/bin/contributor-relay.js @@ -1348,10 +1348,107 @@ function disabledHubFlags(env, count) { return flags.map(s => s === 'true'); } -function makeHub(url, token, disabled = false, contributorId = '') { +// null is legacy/unrestricted; [] is deliberately no work. Preserve empty +// comma slots: dropping one would attach another Hive's scope to this token. +function repoScopesFromEnv(env, count) { + if (env.HIVE_REPO_SCOPE === undefined) return Array(count).fill(null); + const entries = String(env.HIVE_REPO_SCOPE).split(','); + if (entries.length !== count) throw new Error('HIVE_REPO_SCOPE must contain one entry per HIVE_HUB'); + return entries.map(entry => { + if (entry.trim() === '*') return null; + return [...new Set(entry.split(';').map(s => s.trim()).filter(Boolean).map(id => { + const normalized = canonicalRepositoryID(id); + if (!normalized) throw new Error(`Invalid HIVE_REPO_SCOPE repository: ${id}`); + return normalized; + }))]; + }); +} + +function canonicalRepositoryID(id) { + if (typeof id !== 'string') return null; + const parts = id.toLowerCase().split('/'); + if (parts.length !== 4 || !['github', 'gitlab', 'gitea'].includes(parts[0])) return null; + if (!/^[a-z0-9.-]+(?::[0-9]+)?$/.test(parts[1]) || parts.slice(1).some(p => p === '.' || p === '..')) return null; + if (parts.slice(2).some(p => !/^[a-z0-9_.-]+$/.test(p))) return null; + return parts.join('/'); +} + +function repoScopeVersionFromEnv(env, scopes) { + const raw = env.HIVE_REPO_SCOPE_VERSION; + if (raw === undefined && scopes.every(scope => scope === null)) return 0; + if (!/^[0-9]+$/.test(String(raw)) || !Number.isSafeInteger(Number(raw))) { + throw new Error('HIVE_REPO_SCOPE_VERSION must be a nonnegative safe integer'); + } + return Number(raw); +} + +function hubHasSelectedWork(hub) { + return !!hub && !hub.disabled && !hub.authFailed && !hub.scopeUnsupported && + (hub.repoScope == null || hub.repoScope.length > 0); +} + +function hubScopeReady(hub) { + return hubHasSelectedWork(hub) && !hub.scopePending && + (hub.repoScope == null || hub.scopeAckVersion === hub.scopeVersion); +} + +function assignmentInScope(hub, msg) { + if (!hubs.includes(hub)) return false; + if (hub.repoScope == null) return true; + // Never infer an unknown forge/host from a bare owner/name. New hubs can + // supply explicit identity; the existing GitHub task URL also identifies it. + let forge = msg.forge, host = msg.host; + if (!forge && !host && typeof msg.url === 'string') { + try { + const url = new URL(msg.url); + if (url.protocol === 'https:' && url.host === 'github.com' && + url.pathname.toLowerCase().startsWith(`/${String(msg.repo).toLowerCase()}/`)) { + forge = 'github'; host = 'github.com'; + } + } catch (_) {} + } + const id = canonicalRepositoryID(`${forge}/${host}/${msg.repo}`); + return hubScopeReady(hub) && !!id && hub.repoScope.includes(id); +} + +function refuseOutOfScopeAssignment(hub, msg) { + if (assignmentInScope(hub, msg)) return false; + console.log(`Declining ${taskKey(msg)} β€” out of contributor repository scope`); + sendTo(hub, { type: 'task_declined', seq: nextSeq(), task_id: msg.task_id, + task_gen: msg.task_gen, reason: 'out_of_contributor_scope' }); + return true; +} + +function negotiateRepoScope(hub) { + hub.scopeAckVersion = null; + hub.scopePending = hub.repoScope !== null || !!hub.scopeSent; + hub.scopeUnsupported = hub.scopePending && !hub.serverCapabilities.includes('repo_scope_v1'); + if (hub.scopeUnsupported) { + if (!hub.scopeUnsupportedReported) console.log(`hive ${hub.sourceURL} cannot enforce your repository selection β€” not taking work from it`); + hub.scopeUnsupportedReported = true; + } else if (hub.scopePending) { + hub.scopeSent = true; + sendTo(hub, { type: 'repo_scope', scope_version: hub.scopeVersion, + mode: hub.repoScope === null ? 'all' : 'selected', repositories: hub.repoScope || [] }); + } + recordHubSeen(hub); +} + +function logRepoScopeIdle() { + if (hubs.every(h => h.disabled || (h.repoScope !== null && h.repoScope.length === 0))) { + console.log('no repositories selected on any hive β€” idle'); + } +} + +function makeHub(url, token, disabled = false, contributorId = '', repoScope = null, scopeVersion = 0) { return { disabled, contributorId, + repoScope, + scopeVersion, + scopeAckVersion: null, + scopePending: repoScope !== null, + scopeUnsupported: false, url: hubWsURL(url), sourceURL: hubPublicURL(url), regToken: token, @@ -1398,15 +1495,18 @@ function makeHub(url, token, disabled = false, contributorId = '') { const initialContributorIds = String(process.env.CONTRIBUTOR_ID || '').split(',').map(s => s.trim()); const initiallyDisabledHubs = disabledHubFlags(process.env, rawHubList.length); -const hubs = rawHubList.map((url, i) => makeHub(url, rawTokenList[i] || rawTokenList[0], initiallyDisabledHubs[i], initialContributorIds[i] || '')); +const initialRepoScopes = repoScopesFromEnv(process.env, rawHubList.length); +const initialScopeVersion = repoScopeVersionFromEnv(process.env, initialRepoScopes); +const hubs = rawHubList.map((url, i) => makeHub(url, rawTokenList[i] || rawTokenList[0], initiallyDisabledHubs[i], initialContributorIds[i] || '', initialRepoScopes[i], initialScopeVersion)); // Index into hubs[] of the hub we are currently soliciting work from (sent it // the last 'ready'), or that owns currentTask. Round-robins forward on an // explicit task_unavailable from the active hub; sticks with the same hub // across a completed/failed/revoked task rather than switching eagerly, since // task_unavailable is the only signal (kubestellar/hive#2436/#2546 β€” the hub // always sends it, never stays silent) that a hub genuinely has no work. -let activeHubIndex = hubs.findIndex(h => !h.disabled); -if (activeHubIndex < 0) console.log('All hive profiles are disabled β€” idle until a hive is enabled.'); +let activeHubIndex = hubs.findIndex(hubHasSelectedWork); +logRepoScopeIdle(); +if (hubs.every(h => h.disabled)) console.log('All hive profiles are disabled β€” idle until a hive is enabled.'); let commonsCompletedTasks = 0; let commonsSpreadCursor = 0; @@ -1883,7 +1983,7 @@ function invalidateAllReadyRetries(reason) { } function requestWork(hub, trigger, expectedConnectGeneration = null) { - if (!hub || hub.disabled) return false; + if (!hubScopeReady(hub)) return false; const conn = readyConnLabel(hub); if (expectedConnectGeneration !== null && hub.connectGeneration !== expectedConnectGeneration) { logSkippedWorkRequest(hub, trigger, `stale connection generation (expected ${expectedConnectGeneration}, current ${hub.connectGeneration})`); @@ -1956,7 +2056,10 @@ function sendTo(hub, msg) { // failure being fixed is a `ready` that should not have been sent. Only // `ready` is withheld: progress, completion and failure frames for work // already in flight must still reach the hub. - if (msg && msg.type === 'ready' && hub && hub.disabled) return; + if (msg && msg.type === 'ready') { + if (!hubScopeReady(hub)) return; + if (hub.repoScope !== null || hub.scopeSent) msg = { ...msg, scope_version: hub.scopeVersion }; + } if (msg && msg.type === 'ready' && quotaHoldActive()) return; // #7996: same choke point for an expired CLI login β€” see enterLoginHold. if (msg && msg.type === 'ready' && loginHoldActive()) return; @@ -2062,7 +2165,7 @@ function chooseSpreadHub() { if (hubs.length === 0) return null; const cycle = []; for (let i = 0; i < hubs.length; i++) { - if (hubs[i].authFailed || hubs[i].disabled) continue; + if (!hubHasSelectedWork(hubs[i])) continue; for (let n = 0; n < commonsRankWeight(i); n++) cycle.push(i); } if (cycle.length === 0) return null; @@ -2081,7 +2184,7 @@ function chooseNeediestHub() { let bestScore = -1; for (let i = 0; i < hubs.length; i++) { const hub = hubs[i]; - if (!hub || hub.authFailed || hub.disabled) continue; + if (!hubHasSelectedWork(hub)) continue; const score = Number.isFinite(hub.lastNeediestScore) ? hub.lastNeediestScore : (Number.isFinite(hub.lastActionableItems) ? hub.lastActionableItems : -1); @@ -2094,7 +2197,7 @@ function chooseNeediestHub() { activeHubIndex = best; return hubs[best]; } - activeHubIndex = hubs.findIndex(h => !h.authFailed && !h.disabled); + activeHubIndex = hubs.findIndex(hubHasSelectedWork); return hubs[activeHubIndex] || null; } @@ -2109,7 +2212,7 @@ function chooseHubForNextTask(reason) { default: if (reason === 'unavailable') return advanceActiveHub(hubs[activeHubIndex]); for (let i = 0; i < hubs.length; i++) { - if (!hubs[i].authFailed && !hubs[i].disabled) { + if (hubHasSelectedWork(hubs[i])) { activeHubIndex = i; return hubs[i]; } @@ -2182,7 +2285,7 @@ function hubSupportsQuotaPreflight(hub) { function advanceActiveHub(fromHub) { const fromIndex = hubs.indexOf(fromHub); const start = fromIndex >= 0 ? fromIndex : activeHubIndex; - const eligible = (hub) => hub && !hub.authFailed && !hub.disabled; + const eligible = (hub) => hubHasSelectedWork(hub); const socketOpen = (hub) => !!(hub.ws && hub.ws.readyState === 1); const passes = [ (hub) => eligible(hub) && socketOpen(hub), @@ -2229,7 +2332,9 @@ function hubListFromEnv(env) { } const contributorIds = String(env.CONTRIBUTOR_ID || '').split(',').map(s => s.trim()); const disabled = disabledHubFlags(env, hubList.length); - return hubList.map((url, i) => ({ url, token: tokenList[i] || tokenList[0], disabled: disabled[i], contributorId: contributorIds[i] || '' })); + const scopes = repoScopesFromEnv(env, hubList.length); + const scopeVersion = repoScopeVersionFromEnv(env, scopes); + return hubList.map((url, i) => ({ url, token: tokenList[i] || tokenList[0], disabled: disabled[i], contributorId: contributorIds[i] || '', repoScope: scopes[i], scopeVersion })); } function stopHub(hub, reason) { @@ -2250,6 +2355,10 @@ function stopHub(hub, reason) { } function maybeAskActiveHubForWork() { + // Standby stays connected (and negotiates its repository selection) but + // never asks for ordinary work β€” mirror the auth_ok guard here so the + // repo_scope_ack and profile-reload paths cannot leak a ready. + if (STANDBY_MODE) return; const hub = hubs[activeHubIndex]; if (!hub || currentTask || !hub.authenticated || hub.readyOutstanding) return; if (cliReadyFailed) { @@ -2287,6 +2396,16 @@ function reloadHubsFromProjection() { existing.contributorId = entry.contributorId || existing.contributorId; existing.disabled = entry.disabled; if (existing.disabled) invalidateReadyRetry(existing, 'hive disabled'); + const scopeChanged = JSON.stringify(existing.repoScope) !== JSON.stringify(entry.repoScope) || existing.scopeVersion !== entry.scopeVersion; + existing.repoScope = entry.repoScope; + // Even a hand-edited projection cannot reuse a version for a new scope. + existing.scopeVersion = scopeChanged ? Math.max(entry.scopeVersion, existing.scopeVersion + 1) : existing.scopeVersion; + if (scopeChanged) { + existing.scopeAckVersion = null; + existing.readyOutstanding = false; + invalidateReadyRetry(existing, 'repository selection changed'); + if (existing.authenticated && !tokenChanged) negotiateRepoScope(existing); + } remaining.delete(existing); if (tokenChanged) { stopHub(existing, 'registration token changed'); @@ -2297,14 +2416,15 @@ function reloadHubsFromProjection() { } return existing; } - const hub = makeHub(entry.url, entry.token, entry.disabled, entry.contributorId); + const hub = makeHub(entry.url, entry.token, entry.disabled, entry.contributorId, entry.repoScope, entry.scopeVersion); connectHub(hub); return hub; }); for (const removed of remaining) stopHub(removed, 'removed from contributor.env'); hubs.splice(0, hubs.length, ...next); - activeHubIndex = hubs.findIndex(h => !h.disabled && !h.authFailed); + activeHubIndex = hubs.findIndex(hubHasSelectedWork); if (hubs.every(h => h.disabled)) console.log('All hive profiles are disabled β€” idle until a hive is enabled.'); + logRepoScopeIdle(); hubs.forEach(refreshHubStatus); console.log(`Reloaded ${hubs.length} hive profile(s) from ${CONTRIBUTOR_ENV_FILE}; active hive is ${hubs[activeHubIndex] ? hubs[activeHubIndex].sourceURL : '(none)'}`); maybeAskActiveHubForWork(); @@ -2315,6 +2435,13 @@ function handleProfileSwitchSignal() { try { reloadHubsFromProjection(); } catch (e) { + // A broken latest projection must not leave the old, wider scope live. + for (const hub of hubs) { + hub.repoScope = []; + hub.scopeAckVersion = null; + invalidateReadyRetry(hub, 'invalid repository projection'); + } + logRepoScopeIdle(); console.error(`Failed to reload hive profiles from ${CONTRIBUTOR_ENV_FILE}: ${e.message}`); } } @@ -2344,7 +2471,17 @@ function writeHubsSeenNow() { if (!hubsSeenDirty) return; const seen = {}; for (const hub of hubs) { - if (hub.lastSeenAt) seen[hub.sourceURL || hubPublicURL(hub.url)] = hub.lastSeenAt; + if (hub.lastSeenAt) { + const key = hub.sourceURL || hubPublicURL(hub.url); + // Keep legacy timestamps byte-compatible; scoped subscriptions carry + // per-profile status so shared URLs do not hide an unsupported profile. + if (hub.repoScope !== null || hub.scopeSent) { + const previous = seen[key]; + const subscriptions = previous && typeof previous === 'object' ? previous.subscriptions : {}; + subscriptions[hub.contributorId || ''] = hub.scopeUnsupported ? 'unsupported' : (hub.scopePending ? 'pending' : (hub.repoScope === null ? 'all' : (hub.repoScope.length ? 'selected' : 'empty'))); + seen[key] = { last_seen: hub.lastSeenAt, subscriptions }; + } else if (!seen[key] || typeof seen[key] === 'string') seen[key] = hub.lastSeenAt; + } } try { fs.mkdirSync(path.dirname(HUBS_SEEN_FILE), { recursive: true, mode: 0o700 }); @@ -3873,8 +4010,12 @@ function settlePreemptingFix(ok, reason) { } console.log(`fix-pr ${fixPRLabel(fix)}: preempting fix ${ok ? 'completed' : 'failed: ' + reason}`); if (deferredFixes.length && currentTask && CONTRIBUTOR_MODE !== MODE_HEADLESS) { - const next = deferredFixes.shift(); - preemptInteractiveSession(next.task, next.hub); + while (deferredFixes.length) { + const next = deferredFixes.shift(); + if (refuseOutOfScopeAssignment(next.hub, next.task)) continue; + preemptInteractiveSession(next.task, next.hub); + break; + } } } @@ -8433,6 +8574,9 @@ function handleMessage(data, hub) { hub.connectionId = msg.connection_id || ''; hub.serverCapabilities = Array.isArray(msg.server_capabilities) ? msg.server_capabilities.slice() : []; declareStandby(hub); + hub.scopeSent = false; + negotiateRepoScope(hub); + if (!currentTask && !hubHasSelectedWork(hubs[activeHubIndex])) chooseHubForNextTask('auth_scope'); refreshHubStatus(hub); // #7924: the permission set the hub mints task credentials with, per // trust tier. Read by markIssueBlocked to decide whether a label call @@ -8485,6 +8629,25 @@ function handleMessage(data, hub) { console.log('Authenticated, but CLI is not ready yet β€” withholding ready until the CLI reaches its prompt'); } } + if (!currentTask && !hubHasSelectedWork(hub)) maybeAskActiveHubForWork(); + break; + + case 'repo_scope_ack': + if (!hub.authenticated || !hub.scopePending || msg.scope_version !== hub.scopeVersion) break; + if (msg.accepted !== true) { + console.log(`Hive ${hub.sourceURL} rejected repository selection β€” withholding work`); + hub.scopeUnsupported = true; + recordHubSeen(hub); + if (!currentTask && hub === hubs[activeHubIndex]) chooseHubForNextTask('scope_rejected'); + maybeAskActiveHubForWork(); + break; + } + hub.scopeUnsupported = false; + hub.scopeAckVersion = msg.scope_version; + hub.scopePending = false; + recordHubSeen(hub); + if (!currentTask && !hubHasSelectedWork(hubs[activeHubIndex])) chooseHubForNextTask('scope_ack'); + maybeAskActiveHubForWork(); break; case 'auth_failed': @@ -8529,6 +8692,9 @@ function handleMessage(data, hub) { // #7732: an assignment answers the `ready` it was sent for, whatever // this relay does with it below. hub.readyOutstanding = false; + // Runs before interrupts, credentials, authorized-repo recording and + // quota/continue-once accounting, using THIS hub's latest saved scope. + if (refuseOutOfScopeAssignment(hub, msg)) break; if (hub.disabled) { sendTo(hub, { type: 'task_failed', seq: nextSeq(), task_id: msg.task_id, reason: 'Hive disabled by contributor', failure_kind: 'environment' }); break; @@ -9428,6 +9594,8 @@ if (process.env.HIVE_RELAY_TEST_MODE === '1') { GIVE_UP_MEMORY_MS, parseContributorEnvFile, hubListFromEnv, + canonicalRepositoryID, + assignmentInScope, reloadHubsFromProjection, recordHubSeen, writeHubsSeenNow, diff --git a/bin/contributor-relay.test.js b/bin/contributor-relay.test.js index fa1c5055f9..cf876e0653 100644 --- a/bin/contributor-relay.test.js +++ b/bin/contributor-relay.test.js @@ -283,6 +283,8 @@ function loadRelay({ backend = 'copilot', backendBinary = null, backendPerm = '- // and takes the whole run down before its first assertion. A test that wants // several hubs passes them through `env` (MULTI_HUB_ENV). process.env.HIVE_HUB = ''; + delete process.env.HIVE_REPO_SCOPE; + delete process.env.HIVE_REPO_SCOPE_VERSION; process.env.AGENT_BACKEND = backend; process.env.AGENT_MODEL = model; process.env.AGENT_REASONING_EFFORT = reasoningEffort; @@ -3628,6 +3630,184 @@ function withImmediateTimers(fn) { try { fn(); } finally { global.setTimeout = origSetTimeout; } } +// Scope negotiation and assignment checks exercise the real message handler: +// no rejected offer may reach credentials, task accounting or fix interrupts. +const SCOPE_ENV = { ...MULTI_HUB_ENV, HIVE_REPO_SCOPE: '*,github/github.com/org/a;github/github.com/org/b', HIVE_REPO_SCOPE_VERSION: '7' }; +function scopeAuth(relay, hub, capabilities = ['repo_scope_v1']) { + relay.handleMessage(JSON.stringify({ type: 'auth_ok', contributor_id: hub.contributorId || 'c1', server_capabilities: capabilities }), hub); +} +function scopeAck(relay, hub, version = hub.scopeVersion, accepted = true) { + relay.handleMessage(JSON.stringify({ type: 'repo_scope_ack', scope_version: version, accepted }), hub); +} + +test('scope projection preserves legacy, empty and per-hub selections', () => { + const relay = loadRelay(); + try { + assert.deepStrictEqual(relay.hubListFromEnv(MULTI_HUB_ENV).map(h => h.repoScope), [null, null]); + assert.deepStrictEqual(relay.hubListFromEnv({ ...SCOPE_ENV, HIVE_REPO_SCOPE: ',*' }).map(h => h.repoScope), [[], null]); + assert.deepStrictEqual(relay.hubListFromEnv(SCOPE_ENV)[1].repoScope, ['github/github.com/org/a', 'github/github.com/org/b']); + for (const scope of ['*', '*,bad', '*,github/github.com/../a']) { + assert.throws(() => relay.hubListFromEnv({ ...SCOPE_ENV, HIVE_REPO_SCOPE: scope }), /HIVE_REPO_SCOPE/); + } + assert.throws(() => relay.hubListFromEnv({ ...SCOPE_ENV, HIVE_REPO_SCOPE_VERSION: '' }), /HIVE_REPO_SCOPE_VERSION/); + } finally { teardown(relay); } +}); + +test('scoped ready waits for matching accepted acknowledgement, including direct sends', () => { + const relay = loadRelay({ env: SCOPE_ENV }); + try { + const { hubs, sentA, sentB } = attachHubSinks(relay); + relay.setCliReady(true); + scopeAuth(relay, hubs[0]); scopeAuth(relay, hubs[1]); + assert.ok(sentA.some(m => m.type === 'ready' && m.scope_version === undefined)); + assert.deepStrictEqual(sentB.find(m => m.type === 'repo_scope').repositories, hubs[1].repoScope); + for (const [version, accepted] of [[6, true], [7, false]]) { + scopeAck(relay, hubs[1], version, accepted); + relay.sendTo(hubs[1], { type: 'ready' }); + assert.ok(!sentB.some(m => m.type === 'ready')); + } + scopeAck(relay, hubs[1]); + relay.sendTo(hubs[1], { type: 'ready' }); + assert.ok(sentB.some(m => m.type === 'ready' && m.scope_version === 7)); + assert.ok(relay.assignmentInScope(hubs[1], { forge: 'GitHub', host: 'GitHub.com', repo: 'Org/A' })); + assert.ok(relay.assignmentInScope(hubs[1], { repo: 'org/b', url: 'https://github.com/org/b/issues/2' })); + } finally { teardown(relay); } +}); + +for (const strategy of ['ranked', 'spread', 'neediest']) { + test(`${strategy} skips unsupported and empty scoped hubs without fallback`, () => { + const relay = loadRelay({ env: { ...SCOPE_ENV, HIVE_REPO_SCOPE: 'github/github.com/org/a,', HIVE_COMMONS_STRATEGY: strategy, HIVE_COMMONS_NEEDIEST_REFRESH_MS: '0' } }); + try { + const { hubs, sentA, sentB } = attachHubSinks(relay); + relay.setCliReady(true); + scopeAuth(relay, hubs[0], []); scopeAuth(relay, hubs[1]); scopeAck(relay, hubs[1]); + assert.strictEqual(hubs[0].scopeUnsupported, true); + for (const reason of ['task_complete', 'unavailable']) relay.sendReadyForNextTask(reason); + for (const hub of hubs) relay.sendTo(hub, { type: 'ready' }); + assert.ok(![...sentA, ...sentB].some(m => m.type === 'ready')); + } finally { teardown(relay); } + }); +} + +test('scope declines unknown, unselected and interrupt offers before any task side effects', () => { + const relay = loadRelay({ env: { ...SCOPE_ENV, HIVE_REPO_SCOPE: 'github/github.com/org/a,*' } }); + try { + const { hubs, sentA } = attachHubSinks(relay); + scopeAuth(relay, hubs[0]); scopeAck(relay, hubs[0]); + for (const offer of [{ repo: 'org/a' }, { forge: 'github', host: 'evil.example', repo: 'org/a' }, + { forge: 'github', host: 'github.com', repo: 'org/no' }, { kind: 'fix-pr', repo: 'org/no' }]) { + const before = relay.getTasksCompletedCount(); + relay.handleMessage(JSON.stringify({ type: 'task_assign', task_id: 'declined', task_gen: 3, number: 1, github_token: 'must-not-be-delivered', ...offer }), hubs[0]); + assert.strictEqual(relay.getCurrentTask(), null); + assert.strictEqual(relay.getTasksCompletedCount(), before); + assert.deepStrictEqual(relay.getAuthorizedRepos(), []); + assert.strictEqual(relay.__execFileCalls.length, 0); + assert.ok(sentA.some(m => m.type === 'task_declined' && m.reason === 'out_of_contributor_scope' && m.task_gen === 3)); + assert.ok(!sentA.some(m => m.type === 'task_failed' || m.type === 'task_accepted')); + } + } finally { teardown(relay); } +}); + +test('standby relay with a repository selection stays passive after repo_scope_ack', () => { + const relay = loadRelay({ env: { ...SCOPE_ENV, HIVE_STANDBY: '1' } }); + try { + const { hubs, sentA, sentB } = attachHubSinks(relay); + relay.setCliReady(true); + scopeAuth(relay, hubs[0]); scopeAuth(relay, hubs[1]); + assert.ok(sentB.some(m => m.type === 'repo_scope'), 'standby still negotiates its repository selection'); + scopeAck(relay, hubs[1]); + assert.ok(![...sentA, ...sentB].some(m => m.type === 'ready'), + 'standby must not request ordinary work after the scope is acknowledged'); + } finally { teardown(relay); } +}); + +test('one session accepts legacy Hive A and both selected repositories on Hive B', () => { + const relay = loadRelay({ env: SCOPE_ENV }); + try { + const { hubs, sentA, sentB } = attachHubSinks(relay); + scopeAuth(relay, hubs[0]); scopeAuth(relay, hubs[1]); scopeAck(relay, hubs[1]); + relay.handleMessage(JSON.stringify({ type: 'task_assign', task_id: 'a', repo: 'anything/repo', number: 1 }), hubs[0]); + assert.ok(sentA.some(m => m.type === 'task_accepted' && m.task_id === 'a')); + relay.setCurrentTask(null); + relay.sendReadyForNextTask('unavailable'); + for (const repo of ['org/a', 'org/b']) { + relay.handleMessage(JSON.stringify({ type: 'task_assign', task_id: repo, repo, forge: 'github', host: 'github.com', number: 1 }), hubs[1]); + assert.ok(sentB.some(m => m.type === 'task_accepted' && m.task_id === repo)); + relay.setCurrentTask(null); + } + } finally { relay.setCurrentTask(null); teardown(relay); } +}); + +test('clearing a live scope waits for all-mode ack and reconnect renegotiates selected scope', () => { + const envFile = path.join(__dirname, '..', '.relay-test-tmp', 'scope-all.env'); + const relay = loadRelay({ env: { ...SCOPE_ENV, HIVE_CONTRIBUTOR_ENV: envFile } }); + try { + const { hubs, sentB } = attachHubSinks(relay); + scopeAuth(relay, hubs[1]); scopeAck(relay, hubs[1]); + // A fresh auth requires a fresh ack even at the same selection version. + scopeAuth(relay, hubs[1]); + relay.sendTo(hubs[1], { type: 'ready' }); + assert.ok(!sentB.some(m => m.type === 'ready')); + scopeAck(relay, hubs[1]); + fs.writeFileSync(envFile, Object.entries({ ...SCOPE_ENV, HIVE_REPO_SCOPE: '*,*', HIVE_REPO_SCOPE_VERSION: '8' }).map(([k,v]) => `${k}=${v}`).join('\n')); + relay.reloadHubsFromProjection(); + assert.ok(sentB.some(m => m.type === 'repo_scope' && m.mode === 'all' && m.scope_version === 8)); + relay.sendTo(hubs[1], { type: 'ready' }); + assert.ok(!sentB.some(m => m.type === 'ready')); + scopeAck(relay, hubs[1]); + relay.sendTo(hubs[1], { type: 'ready' }); + assert.ok(sentB.some(m => m.type === 'ready' && m.scope_version === 8)); + } finally { teardown(relay); fs.rmSync(envFile, { force: true }); } +}); + +test('unsupported active hub advances to a scoped hub that acknowledged first', () => { + const relay = loadRelay({ env: { ...SCOPE_ENV, HIVE_REPO_SCOPE: 'github/github.com/org/a,github/github.com/org/b' } }); + try { + const { hubs, sentB } = attachHubSinks(relay); + relay.setCliReady(true); + scopeAuth(relay, hubs[1]); scopeAck(relay, hubs[1]); + assert.ok(!sentB.some(m => m.type === 'ready')); + scopeAuth(relay, hubs[0], []); + assert.ok(sentB.some(m => m.type === 'ready' && m.scope_version === 7)); + } finally { teardown(relay); } +}); + +test('unsupported scope is persisted per contributor alongside last-seen time', () => { + const seenFile = path.join(__dirname, '..', '.relay-test-tmp', 'scope-seen.json'); + const relay = loadRelay({ env: { ...SCOPE_ENV, CONTRIBUTOR_ID: 'c1,c2', HIVE_HUBS_SEEN_FILE: seenFile } }); + try { + const { hubs } = attachHubSinks(relay); + scopeAuth(relay, hubs[1], []); + relay.writeHubsSeenNow(); + const seen = JSON.parse(fs.readFileSync(seenFile, 'utf8')); + assert.strictEqual(seen[hubs[1].sourceURL].subscriptions.c2, 'unsupported'); + assert.ok(seen[hubs[1].sourceURL].last_seen); + } finally { teardown(relay); fs.rmSync(seenFile, { force: true }); } +}); + +test('reload tightens scope before stale offers and preserves an executing lease', () => { + const envFile = path.join(__dirname, '..', '.relay-test-tmp', 'scope-reload.env'); + const relay = loadRelay({ env: { ...SCOPE_ENV, HIVE_CONTRIBUTOR_ENV: envFile } }); + try { + const { hubs, sentB } = attachHubSinks(relay); + scopeAuth(relay, hubs[1]); scopeAck(relay, hubs[1]); + const running = { task_id: 'running', repo: 'org/b', _hub: hubs[1] }; + relay.setCurrentTask(running); + fs.writeFileSync(envFile, Object.entries({ ...SCOPE_ENV, HIVE_REPO_SCOPE: '*,github/github.com/org/a', HIVE_REPO_SCOPE_VERSION: '8' }).map(([k,v]) => `${k}=${v}`).join('\n')); + relay.reloadHubsFromProjection(); + assert.strictEqual(relay.getCurrentTask(), running); + assert.strictEqual(hubs[1].scopeVersion, 8); + scopeAck(relay, hubs[1], 7); + assert.strictEqual(hubs[1].scopeAckVersion, null); + relay.handleMessage(JSON.stringify({ type: 'task_assign', kind: 'fix-pr', task_id: 'late', repo: 'org/b', forge: 'github', host: 'github.com' }), hubs[1]); + assert.ok(sentB.some(m => m.type === 'task_declined' && m.task_id === 'late')); + assert.strictEqual(relay.getCurrentTask(), running); + fs.writeFileSync(envFile, 'HIVE_HUB=broken'); + relay.handleProfileSwitchSignal(); + assert.ok(hubs.every(h => Array.isArray(h.repoScope) && h.repoScope.length === 0)); + } finally { relay.setCurrentTask(null); teardown(relay); fs.rmSync(envFile, { force: true }); } +}); + test('SIGUSR1 reload switches the active hub from contributor.env without dropping an in-flight task', () => { const scratchRoot = path.join(__dirname, '..', '.relay-test-tmp'); fs.mkdirSync(scratchRoot, { recursive: true }); diff --git a/changelog.d/README.md b/changelog.d/README.md index b800dc2b7a..81f5c42cd4 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -29,10 +29,13 @@ deleting the fragments in the same release commit. `a fragment must start with a '- ' entry bullet (or a '' marker) β€” it IS the entry; the compiler owns the ### headings`. - **What qualifies:** the same rule as always (top of `CHANGELOG.md`): user-visible features, fixes, security changes, migrations, deprecations, - breaking changes. Routine refactors, test-only changes, and dependency churn - need no fragment; docs-only changes are exempt too. Put a `no-changelog` - label on the PR if the guard asks for a fragment for a change that is not - user-visible. + breaking changes. Auto-exempt: anything outside `src/` (root-level docs, + this directory, `.github/`), plus `src/docs/`, `*_test.go` files, and + `src/{deploy,scripts}/test[-_]*.sh` β€” `src/scripts/check-changelog-fragment.sh` + is the source of truth for the exact set. All other changes + need no fragment only if non-user-visible (routine refactors, dependency churn, + or test-adjacent code like `testdata/` or non-`_test.go` test helpers) β€” put + a `no-changelog` label on such PRs. - The `` escape hatch (`src/docs/releases.md`) may ride in a fragment as its first line; it is compiled into `Unreleased` verbatim and honoured by diff --git a/changelog.d/added-12300-cadence-templates.md b/changelog.d/added-12300-cadence-templates.md deleted file mode 100644 index 5c9644c944..0000000000 --- a/changelog.d/added-12300-cadence-templates.md +++ /dev/null @@ -1 +0,0 @@ -- Add single-click global cadence setting templates (New Level testing, Weekly sprint, Biweekly sprint, and Daily cadence) in the dashboard Governor matrix and via `POST /api/config/governor/cadence-templates` to allow operators to quickly configure governor cadences across all agents without excessive manual clicks (#12300). diff --git a/changelog.d/added-dashboard-open-hold-segment.md b/changelog.d/added-dashboard-open-hold-segment.md deleted file mode 100644 index 1949318256..0000000000 --- a/changelog.d/added-dashboard-open-hold-segment.md +++ /dev/null @@ -1 +0,0 @@ -- Show held open issues and PRs as held directly in the dashboard pipeline Open segment. diff --git a/changelog.d/added-hivectl-contribution-preset.md b/changelog.d/added-hivectl-contribution-preset.md deleted file mode 100644 index a3563a59ad..0000000000 --- a/changelog.d/added-hivectl-contribution-preset.md +++ /dev/null @@ -1 +0,0 @@ -- `hivectl` can now parse and validate a **contribution preset**: a versioned, credential-free YAML document a project publishes at an HTTPS URL or ships as a file to recommend Hives and repository selections (`all-current` or an explicit `owner/name` list per Hive, plus optional hubs). Loading is strict and read-only: unknown `version` majors, unknown fields, and any field carrying tokens, credentials, shell commands, local execution settings or permission changes are refused with the field named; only `https://` (and loopback `http://`) or local paths are accepted, redirects may not leave HTTPS, and documents are capped at 256 KiB. See "Contribution presets" in `src/docs/hivectl.md` ([#12803](https://github.com/hivecommons/hive/issues/12803), part of [#12570](https://github.com/hivecommons/hive/issues/12570)). diff --git a/changelog.d/added-nous-scheduler-tick.md b/changelog.d/added-nous-scheduler-tick.md deleted file mode 100644 index fde60a0c80..0000000000 --- a/changelog.d/added-nous-scheduler-tick.md +++ /dev/null @@ -1 +0,0 @@ -- Wire the background-only Hive-self experiment scheduler into `hive` behind a new default-off `self_experiments.scheduler` block (`enabled`, `mode: background`, `interval_s`). When enabled in background mode, each tick judges every unresolved experiment in the local Hive-self ledger and hands the decision to the executor, which latches fast-fails and reverts treatments durably; reverts are restored at startup before the first heartbeat. Experiments are registered by the hub/relay, which is not yet wired, so the ledger (`/data/nous/self-experiments.json`) stays empty until then. With the default config nothing is loaded and no goroutine starts, and governor configuration is never touched ([#12397](https://github.com/hivecommons/hive/issues/12397), [#12768](https://github.com/hivecommons/hive/issues/12768)). diff --git a/changelog.d/changed-12903-comment-needs-decision-policy.md b/changelog.d/changed-12903-comment-needs-decision-policy.md new file mode 100644 index 0000000000..6f6c81edbc --- /dev/null +++ b/changelog.d/changed-12903-comment-needs-decision-policy.md @@ -0,0 +1 @@ +- `project.agent_issue_decision_policy` now also gates `hive-open-issue comment … --needs-decision`: under the default `resolve` a Hive-authored non-risk comment records `Decision taken by hive: …` instead of parking the existing issue, matching the issue and label paths from v5 ([#12852](https://github.com/hivecommons/hive/issues/12852)); typed `--step-kind` owner steps always park (hivecommons/hive#12903). diff --git a/changelog.d/changed-12923-off-prompt-fixture.md b/changelog.d/changed-12923-off-prompt-fixture.md new file mode 100644 index 0000000000..5d25addc33 --- /dev/null +++ b/changelog.d/changed-12923-off-prompt-fixture.md @@ -0,0 +1,2 @@ + +- Keep the busy-visibility regression cases aligned by reusing the off-prompt pane fixture when checking the recovery threshold (#12923). diff --git a/changelog.d/changed-12941.md b/changelog.d/changed-12941.md new file mode 100644 index 0000000000..5ef911d284 --- /dev/null +++ b/changelog.d/changed-12941.md @@ -0,0 +1 @@ +- build(deps): bump golang.org/x/net from 0.60.0 to 0.61.0 in /src (#12941) diff --git a/changelog.d/changed-self-authored-decision-policy.md b/changelog.d/changed-self-authored-decision-policy.md deleted file mode 100644 index 076ecbb86f..0000000000 --- a/changelog.d/changed-self-authored-decision-policy.md +++ /dev/null @@ -1 +0,0 @@ -- Let Hive-authored review decisions resolve low-risk sequencing, naming and config-shape questions by default while preserving risk escalations. diff --git a/changelog.d/fixed-12777-adoption-example.md b/changelog.d/fixed-12777-adoption-example.md deleted file mode 100644 index 3602099f6b..0000000000 --- a/changelog.d/fixed-12777-adoption-example.md +++ /dev/null @@ -1 +0,0 @@ -- Document the supported in-place adoption and offline/unregistered wait settings in `hive.yaml.example` (#12777). diff --git a/changelog.d/fixed-12826-relay-env-quotes.md b/changelog.d/fixed-12826-relay-env-quotes.md deleted file mode 100644 index 10d11a4b2d..0000000000 --- a/changelog.d/fixed-12826-relay-env-quotes.md +++ /dev/null @@ -1 +0,0 @@ -- contributor-relay: strip the matching shell quotes `hivectl` writes around values in `contributor.env`, so `HIVE_REPO_SCOPE` reads back as written (#12826) diff --git a/changelog.d/fixed-adoption-dco-rehome.md b/changelog.d/fixed-adoption-dco-rehome.md deleted file mode 100644 index 79e0597200..0000000000 --- a/changelog.d/fixed-adoption-dco-rehome.md +++ /dev/null @@ -1 +0,0 @@ -- Re-home adopted contributor PRs when unsigned commits cannot be DCO-repaired in place, with fixer instructions for DCO-clean superseding branches. diff --git a/changelog.d/fixed-agent-now-node-path.md b/changelog.d/fixed-agent-now-node-path.md new file mode 100644 index 0000000000..cb7c087619 --- /dev/null +++ b/changelog.d/fixed-agent-now-node-path.md @@ -0,0 +1 @@ +- Dashboard agent "Now:" line tests report when their JavaScript fixture cannot run because Node.js is missing from PATH, instead of failing the test run ([#12924](https://github.com/hivecommons/hive/issues/12924)). diff --git a/changelog.d/fixed-ci-failing-fork-push-gate.md b/changelog.d/fixed-ci-failing-fork-push-gate.md new file mode 100644 index 0000000000..1b322f2a44 --- /dev/null +++ b/changelog.d/fixed-ci-failing-fork-push-gate.md @@ -0,0 +1 @@ +- CI_FAILING repair queue keeps non-adopted fork PRs comment-only even when the fork allows maintainer edits; only stage-074 adopted fork PRs are listed as push-repairable, and those name the contributor branch. diff --git a/changelog.d/fixed-conflicting-offline-contributor.md b/changelog.d/fixed-conflicting-offline-contributor.md deleted file mode 100644 index e2409286f7..0000000000 --- a/changelog.d/fixed-conflicting-offline-contributor.md +++ /dev/null @@ -1 +0,0 @@ -- Treat conflicting contributor PRs like red CI so offline clanker authors are nudged and adopted after grace. diff --git a/changelog.d/fixed-consent-screen-stall-restart.md b/changelog.d/fixed-consent-screen-stall-restart.md new file mode 100644 index 0000000000..8b9694d940 --- /dev/null +++ b/changelog.d/fixed-consent-screen-stall-restart.md @@ -0,0 +1 @@ +- Restart an agent on a non-inference backend whose live CLI pane stays on a consent or blocking-prompt menu after repeated undeliverable kicks, instead of leaving it wedged with every kick failing as "CLI did not reach input prompt" and never reported (#12921). diff --git a/changelog.d/fixed-dco-waiver-12727.md b/changelog.d/fixed-dco-waiver-12727.md deleted file mode 100644 index 077487dab2..0000000000 --- a/changelog.d/fixed-dco-waiver-12727.md +++ /dev/null @@ -1 +0,0 @@ -- **DCO post-merge monitor: waive `a9200f3d` per maintainer disposition.** The #12707 squash on `v5` kept the scanner `Signed-off-by` instead of the author's, so the monitor kept failing. A SHA-scoped `DCO_WAIVED_COMMITS` entry records the maintainer-accepted disposition without rewriting history or widening any identity allowlist ([#12727](https://github.com/hivecommons/hive/issues/12727)). diff --git a/changelog.d/fixed-model-discovery-credential-missing-selfreport.md b/changelog.d/fixed-model-discovery-credential-missing-selfreport.md new file mode 100644 index 0000000000..0c13c3588b --- /dev/null +++ b/changelog.d/fixed-model-discovery-credential-missing-selfreport.md @@ -0,0 +1 @@ +- Stop filing a fleet self-report when copilot model discovery fails only because the operator has not completed the dashboard Copilot Login; the condition is still written to the local audit log and warn line, now with `state=credential_missing` and a `recovery` field naming the Copilot Login, and any earlier real discovery failure is cleared from the tracker so it cannot file with stale text (#12947). diff --git a/changelog.d/fixed-role-flap-trusted-author-lane.md b/changelog.d/fixed-role-flap-trusted-author-lane.md deleted file mode 100644 index 4f7c77e859..0000000000 --- a/changelog.d/fixed-role-flap-trusted-author-lane.md +++ /dev/null @@ -1 +0,0 @@ -- Derive stage role labels from author identity so non-owning lanes cannot flip trusted App-authored PRs to untrusted. diff --git a/config/contributor.env.example b/config/contributor.env.example index 06da9ef03c..ab86927d99 100644 --- a/config/contributor.env.example +++ b/config/contributor.env.example @@ -62,3 +62,11 @@ AGENT_BACKEND=claude # contributor (#2549). Pi uses --print --mode json; backends # without a verified one-shot entry point refuse loudly. # CONTRIBUTOR_MODE=interactive + +# Optional generated per-Hive repository selection, aligned with HIVE_HUB. +# Missing key or * means legacy unrestricted; empty entry means NO work. +# IDs use forge/host/owner/name; separate IDs with ; and Hive entries with ,. +# Scoped Hives must advertise repo_scope_v1 and acknowledge before polling. +# HIVE_REPO_SCOPE=*,github/github.com/org/a;github/github.com/org/b +# Monotonic projection version (required when scope is selected, even empty). +# HIVE_REPO_SCOPE_VERSION=1 diff --git a/src/Dockerfile b/src/Dockerfile index 4f1db13303..02deef4f88 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -83,7 +83,7 @@ RUN --mount=type=cache,id=hive-go-build,target=/tmp/gobuild \ # supply-chain risk. Refresh deliberately (same technique as golang above, # repo library/node, tag 26-slim) and update both the tag and the @sha256 # digest together in BOTH FROM node:26-slim lines in this file. -FROM mirror.gcr.io/library/node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS tmux-builder +FROM mirror.gcr.io/library/node:26-slim@sha256:0d1e33586362bb9e608c4e751e7a9e7edb55c9ac11461ed5666af696cc461dd4 AS tmux-builder ARG TARGETARCH ARG TMUX_VERSION=3.5a ARG TMUX_SHA256_AMD64=16216bd0877170dfcc64157085ba9013610b12b082548c7c9542cc0103198951 @@ -111,7 +111,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # (and the rewrite RUN below) from the branch-scoped GHA cache β€” so a fresh # binary was rebuilt and then discarded, and the OLD binary shipped. See the # stale-binary note in .github/workflows/docker.yml. -FROM mirror.gcr.io/library/node:26-slim@sha256:ec7758ee051e457b468b32bde57b0879010b325bb9862718e9615225ce4aaae1 AS runtime +FROM mirror.gcr.io/library/node:26-slim@sha256:0d1e33586362bb9e608c4e751e7a9e7edb55c9ac11461ed5666af696cc461dd4 AS runtime # --- Layer 1: system packages (rarely changes) --- # util-linux provides `setpriv`, used by the entrypoint to drop to `dev` with an @@ -280,7 +280,7 @@ RUN npm install -g --ignore-scripts --no-fund --no-audit @openai/codex@${CODEX_V echo "WARN: OpenAI Codex CLI install failed β€” skipping" # --- Layer 7: Claude Code (most frequent updates) --- -ARG CLAUDE_CODE_VERSION=2.1.302 +ARG CLAUDE_CODE_VERSION=2.1.303 # --ignore-scripts (kept, deliberately: no arbitrary postinstall runs during the # build) skips claude-code's install.cjs, which is what LINKS the platform # native binary into the package's bin/. The binary itself ships in the @@ -349,9 +349,9 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ # --- Layer 8.27: OMP CLI (Oh My Pi β€” backend: omp) --- # Mirrored from Dockerfile.contributor so hub agents can launch backend: omp. # Downloads are optional like Goose/agy, but checksum mismatches are fatal. -ARG OMP_VERSION=18.11.0 -ARG OMP_SHA256_AMD64=c23a58ad8a92f4e738aa12dc904f3b0a1ac7035b953b9bf5d39b71a226179f6f -ARG OMP_SHA256_ARM64=299e0b63c482d78f00f2bae75000815ad6890cb6192cbc9f66c8d98e9ce70b80 +ARG OMP_VERSION=18.12.1 +ARG OMP_SHA256_AMD64=16181401e616d7d895bae31fa97476f1513d4080807d60d3a1a04006191a1bb3 +ARG OMP_SHA256_ARM64=16545d7e747b31dbc66f2bcd63f24f12dc1dc7e944767b09b0df6591bf826a93 RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ && case "$ARCH" in \ amd64|x86_64) OMP_ASSET=omp-linux-x64; OMP_SHA256="$OMP_SHA256_AMD64" ;; \ diff --git a/src/Dockerfile.contributor b/src/Dockerfile.contributor index d60eb6aa17..70a4f20a8e 100644 --- a/src/Dockerfile.contributor +++ b/src/Dockerfile.contributor @@ -236,7 +236,7 @@ RUN mkdir -p /etc/apt/keyrings \ # npm cache is cleaned in the same layer it's populated (issue #2613, # Observation 2 "also minor") so the cache doesn't linger in this layer's # diff, mirroring the cleanup already done for the bobshell install below. -ARG CLAUDE_CODE_VERSION=2.1.302 +ARG CLAUDE_CODE_VERSION=2.1.303 ARG COPILOT_VERSION=1.0.88 # --ignore-scripts (kept, deliberately: no arbitrary postinstall runs during # the build) skips claude-code's install.cjs, which is what LINKS the platform @@ -458,9 +458,9 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ # No credential is baked in: omp reads the selected model's provider # credential from its documented environment variables (ANTHROPIC_API_KEY, # OPENAI_API_KEY, …), which the Justfile's container arm already forwards. -ARG OMP_VERSION=18.11.0 -ARG OMP_SHA256_AMD64=c23a58ad8a92f4e738aa12dc904f3b0a1ac7035b953b9bf5d39b71a226179f6f -ARG OMP_SHA256_ARM64=299e0b63c482d78f00f2bae75000815ad6890cb6192cbc9f66c8d98e9ce70b80 +ARG OMP_VERSION=18.12.1 +ARG OMP_SHA256_AMD64=16181401e616d7d895bae31fa97476f1513d4080807d60d3a1a04006191a1bb3 +ARG OMP_SHA256_ARM64=16545d7e747b31dbc66f2bcd63f24f12dc1dc7e944767b09b0df6591bf826a93 RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ && case "$ARCH" in \ amd64) OMP_ASSET=omp-linux-x64; OMP_SHA256="$OMP_SHA256_AMD64" ;; \ diff --git a/src/cmd/hive/boot_heartbeat_deps_test.go b/src/cmd/hive/boot_heartbeat_deps_test.go index b06a62b2ff..e63b83e1ab 100644 --- a/src/cmd/hive/boot_heartbeat_deps_test.go +++ b/src/cmd/hive/boot_heartbeat_deps_test.go @@ -15,6 +15,7 @@ import ( "github.com/hivecommons/hive/pkg/dashboard" "github.com/hivecommons/hive/pkg/governor" spoke "github.com/hivecommons/hive/pkg/hub/spoke" + "github.com/hivecommons/hive/pkg/scheduler" ) // bootHeartbeatFake records what bootHeartbeatWith asked for. The collector @@ -345,3 +346,38 @@ func TestBootHeartbeatWith_TaskStatusSwitchPreservesControlPlane(t *testing.T) { } func heartbeatTaskPushBool(v bool) *bool { return &v } + +// #12876: heartbeatAgents must carry the governor's per-lane queue depth on +// both the full and the inline paths; an agent outside the reviewer lane stays +// nil so the hub falls back to the hive-wide backlog. +func TestHeartbeatAgents_CarriesLaneQueuedWork(t *testing.T) { + f := newBootHeartbeatFake() + cfg := bootHeartbeatConfig() + level := 5 + cfg.ACMMLevel = &level + cfg.Agents = map[string]config.AgentConfig{ + "adjudicator": {Role: "reviewer", Mode: "ISSUES_AND_PRS"}, + "scanner": {Mode: "ISSUES_AND_PRS"}, + } + b := newBootHeartbeatBoot(t, f, cfg) + b.sched = scheduler.New(cfg, b.logger) + b.agentMgr.AddAgent("adjudicator", cfg.Agents["adjudicator"]) + b.agentMgr.AddAgent("scanner", cfg.Agents["scanner"]) + + for _, blocking := range []bool{true, false} { + byName := map[string]spoke.AgentSummary{} + for _, a := range b.heartbeatAgents(governor.State{}, "busy", blocking) { + byName[a.Name] = a + } + lane, ok := byName["adjudicator"] + if !ok { + t.Fatalf("blocking=%v: adjudicator missing from heartbeat agents", blocking) + } + if lane.LaneQueuedWork == nil { + t.Errorf("blocking=%v: reviewer-lane agent must report a lane depth", blocking) + } + if other := byName["scanner"]; other.LaneQueuedWork != nil { + t.Errorf("blocking=%v: non-lane agent reported lane depth %d, want nil", blocking, *other.LaneQueuedWork) + } + } +} diff --git a/src/cmd/hive/contributor_lane_wiring_test.go b/src/cmd/hive/contributor_lane_wiring_test.go index 9cd02a87e7..f2bb72a872 100644 --- a/src/cmd/hive/contributor_lane_wiring_test.go +++ b/src/cmd/hive/contributor_lane_wiring_test.go @@ -15,7 +15,7 @@ func TestContributorLanePolicyFuncReadsConfig(t *testing.T) { } cfg := &config.Config{HiveID: "hive-prod"} policy := contributorLanePolicyFunc(cfg)() - if !policy.Enabled || policy.HiveID != "hive-prod" || policy.Soak != time.Duration(config.DefaultContributorLaneSoakMinutes)*time.Minute || !policy.RequireReviewerVerdict { + if !policy.Enabled || policy.HiveID != "hive-prod" || policy.Soak != time.Duration(config.DefaultContributorLaneSoakMinutes)*time.Minute || !policy.RequireReviewerVerdict || policy.AutoPromoteAfterMergedPR != config.DefaultContributorTierAutoPromoteAfterMerged { t.Fatalf("default policy = %+v", policy) } for _, label := range config.DefaultContributorLaneExcludeLabels { @@ -28,8 +28,9 @@ func TestContributorLanePolicyFuncReadsConfig(t *testing.T) { } off := false cfg.AutoMerge.ContributorLane = config.ContributorLaneAutoMergeConfig{Enabled: &off, SoakMinutes: 15, RequireReviewerVerdict: &off, Repos: []string{"Widget"}, RequireTiers: []string{"Advisor"}} + cfg.AutoMerge.ContributorTier = config.ContributorTierAutoMergeConfig{AutoPromoteAfterMerged: 9} policy = contributorLanePolicyFunc(cfg)() - if policy.Enabled || policy.Soak != 15*time.Minute || policy.RequireReviewerVerdict || !policy.Repos["widget"] { + if policy.Enabled || policy.Soak != 15*time.Minute || policy.RequireReviewerVerdict || !policy.Repos["widget"] || policy.AutoPromoteAfterMergedPR != 9 { t.Fatalf("explicit policy = %+v", policy) } if !policy.RequireTiers["advisor"] || len(policy.RequireTiers) != 1 { @@ -94,6 +95,24 @@ func TestContributorLaneVerdictFunc(t *testing.T) { } } +func TestContributorLaneVerdictFromArtifactNormalizesLegacyRequiresHuman(t *testing.T) { + artifact := review.Artifact{Items: []review.Aggregate{{ + Repo: "acme/widget", + Number: 7, + HeadSHA: "sha7", + Verdict: review.VerdictRequiresHuman, + RequiresHuman: true, + HumanSummaries: []string{"correctness requires human: field is plumbed but never populated; add the producer."}, + Perspectives: map[review.Perspective]review.Verdict{review.PerspectiveCorrectness: review.VerdictRequiresHuman}, + }}} + + got := contributorLaneVerdictFromArtifact(artifact, "acme/widget", 7, "sha7", false) + + if got.Verdict != string(review.VerdictChangesRequested) || got.Approved || got.DecisionRequested { + t.Fatalf("verdict = %+v, want normalized changes_requested", got) + } +} + func TestContributorLaneVerdictFromArtifactPrefersNewestHeadAggregate(t *testing.T) { old := time.Date(2026, 10, 17, 1, 0, 0, 0, time.UTC) newer := old.Add(time.Hour) diff --git a/src/cmd/hive/escalation_gates_test.go b/src/cmd/hive/escalation_gates_test.go index 128b854306..aa7cbe2deb 100644 --- a/src/cmd/hive/escalation_gates_test.go +++ b/src/cmd/hive/escalation_gates_test.go @@ -98,9 +98,13 @@ func TestRecordRedStaleness(t *testing.T) { store, clock := newTestEscalationStore(t) cfg := escalationTestConfig() + human := redPR("widgets", 8, "some-human", "def") + // Keep the human red while making its failure independent of the + // agent's; matching failures now correctly classify as shared CI. + human.FailingChecks = []string{"lint"} recordRedStaleness(cfg, actionableWith( redPR("widgets", 7, "hive-agent", "abc"), - redPR("widgets", 8, "some-human", "def"), + human, )) if store.StaleRed("acme/widgets", 7, "abc") { t.Error("fresh red must not read as stale") diff --git a/src/cmd/hive/github_client_config.go b/src/cmd/hive/github_client_config.go index 2871c2d35a..64020a99b6 100644 --- a/src/cmd/hive/github_client_config.go +++ b/src/cmd/hive/github_client_config.go @@ -115,6 +115,7 @@ func (b *boot) applyGitHubClientConfigHooks(client *github.Client) { // gates which issues become actionable at all, so it must be installed // even when no exempt labels are configured. client.SetIssueFilter(b.cfg.Project.IssueFilter) + client.SetAgentIssueDecisionPolicy(b.cfg.Project.EffectiveAgentIssueDecisionPolicy()) client.SetReporterConfirmationEnabledFunc(func() bool { return b.cfg.Issues.ReporterConfirmationEnabled() }) diff --git a/src/cmd/hive/hive_pr_observations_test.go b/src/cmd/hive/hive_pr_observations_test.go index 5a5d2b07a4..4802f86f8e 100644 --- a/src/cmd/hive/hive_pr_observations_test.go +++ b/src/cmd/hive/hive_pr_observations_test.go @@ -96,6 +96,67 @@ func TestHivePRObservationsRedRequiresFailingCheck(t *testing.T) { } } +// A matching failure on an unrelated human PR proves the check is broken +// repository-wide, so the agent PR must not consume its fix-loop budget. +func TestHivePRObservationsHoldsFailureWithIndependentControl(t *testing.T) { + cfg := observationsTestConfig() + actionable := &github.ActionableResult{PRs: github.PRResult{Items: []github.PullRequest{ + {Repo: "hive", Number: 1, Author: "hive-bee", AuthorNonHuman: true, + CIStatus: "failure", FailingChecks: []string{"test"}}, + {Repo: "hive", Number: 2, Author: "human-dev", + CIStatus: "failure", FailingChecks: []string{"test"}}, + }}} + + obs := hivePRObservations(cfg, actionable, nil) + + if len(obs) != 1 || !obs[0].Shared { + t.Fatalf("agent observation = %+v, want one shared failure", obs) + } +} + +// Held PRs remain open CI evidence even though the hold gate excludes them +// from escalation candidates. +func TestHivePRObservationsUsesHeldIndependentControl(t *testing.T) { + cfg := observationsTestConfig() + actionable := &github.ActionableResult{PRs: github.PRResult{ + Items: []github.PullRequest{{ + Repo: "hive", Number: 1, Author: "hive-bee", AuthorNonHuman: true, + CIStatus: "failure", FailingChecks: []string{"test"}, + }}, + Held: []github.PullRequest{{ + Repo: "hive", Number: 2, Author: "human-dev", + CIStatus: "failure", FailingChecks: []string{"test"}, + }}, + }} + + obs := hivePRObservations(cfg, actionable, nil) + + if len(obs) != 1 || !obs[0].Shared { + t.Fatalf("agent observation = %+v, want held PR to confirm shared failure", obs) + } +} + +// Neither another Hive PR nor a human-authored contributor-relay PR is +// independent evidence of a repository-wide outage: both can carry the same +// generated defect. +func TestHivePRObservationsKeepsFailureWithoutIndependentControl(t *testing.T) { + cfg := observationsTestConfig() + actionable := &github.ActionableResult{PRs: github.PRResult{Items: []github.PullRequest{ + {Repo: "hive", Number: 1, Author: "hive-bee", AuthorNonHuman: true, + CIStatus: "failure", FailingChecks: []string{"test"}}, + {Repo: "hive", Number: 2, Author: "copilot-swe-agent[bot]", AuthorNonHuman: true, + CIStatus: "failure", FailingChecks: []string{"test"}}, + {Repo: "hive", Number: 3, Author: "relay-user", HiveAttributed: true, + CIStatus: "failure", FailingChecks: []string{"test"}}, + }}} + + obs := hivePRObservations(cfg, actionable, nil) + + if len(obs) != 2 || obs[0].Shared || obs[1].Shared { + t.Fatalf("agent observations = %+v, want two PR-local failures", obs) + } +} + // Pending must be threaded through so a CI-in-flight window is a no-op // observation for the escalation ledger (#5617, gap G2) β€” neither red (no // attempt counted) nor green (no history/staleness wiped). diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index ebf041fccc..7baf1c419a 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -1306,9 +1306,14 @@ func (b *boot) wireBootClosures() { if ac, ok := b.cfg.Agents[name]; (ok && ac.OnDemand) || b.onDemandFromPack[name] { mode = "on_demand" } + var laneQueued *int + if b.sched != nil { + laneQueued = b.sched.ReviewerLaneQueuedWork(name) + } if includeBlockingChecks { - agents = append(agents, spoke.NewAgentSummary(name, string(proc.State), mode, - spoke.AgentActivityFor(b.agentMgr, b.cfg, govState, currentMode, name, proc, b.onDemandFromPack))) + full := spoke.AgentActivityFor(b.agentMgr, b.cfg, govState, currentMode, name, proc, b.onDemandFromPack) + full.LaneQueuedWork = laneQueued + agents = append(agents, spoke.NewAgentSummary(name, string(proc.State), mode, full)) continue } act := spoke.AgentActivity{ @@ -1321,6 +1326,7 @@ func (b *boot) wireBootClosures() { QuotaExhausted: proc.QuotaExhausted, LastActivityAt: proc.LastPaneChange, KickInterval: spoke.HeartbeatKickInterval(govState, name, proc, b.onDemandFromPack), + LaneQueuedWork: laneQueued, Backend: proc.Config.Backend, Enabled: proc.Config.Enabled, CanOpenIssue: proc.LaunchedMode.CanCreateIssues(), @@ -6356,6 +6362,13 @@ func (b *boot) runLoopWith(deps runLoopDeps) { if _, err := b.ghClient.MigrateOriginLabels(b.ctx, github.OriginLabelMigrationOptions{Logger: b.logger}); err != nil { b.logger.Warn("origin provenance label migration incomplete; open items may still carry retired names such as "+github.LegacyOriginReviewLabel+" or "+github.LegacyFromReviewLabel, "error", err) } + // Issues park at stage/045-needs-decision; stage/085 is PR-only + // (owner decision). Open issues still wearing 085 are re-staged + // once; the issue reconciler also does it per cycle for the issues + // it enumerates, so a failure here only delays the rest. + if _, err := b.ghClient.MigrateIssueNeedsDecisionStage(b.ctx, github.IssueNeedsDecisionMigrationOptions{Logger: b.logger}); err != nil { + b.logger.Warn("issue needs-decision stage migration incomplete; open issues may still carry "+github.PRNeedsDecisionStageLabel+" instead of "+github.NeedsDecisionStageLabel, "error", err) + } // The hive/ provenance label is retired (#12539): the origin // marker in every agent-written body carries the same fact. Runs // after the hold migration so that migration still sees the label @@ -8559,29 +8572,38 @@ func getEscalationStore() *escalation.Store { } // hivePRObservations projects the enumerated hive-authored PRs into escalation -// observations (repo fully-qualified, Red == a required check failed). Shared by -// recordRedStaleness and the reaper so both classify PRs identically. A PR is -// "red" here strictly per HasFailingRequiredCheck β€” GENERIC check state, never a -// specific linter or language. +// observations (repo fully-qualified, Red == a required check failed). Shared +// by recordRedStaleness and the reaper so both classify PRs identically. // // owners maps "org/repo#number" to the agent that opened the PR (see // auditPRAgents); pass nil when ownership is not needed. Shared/base CI -// breakage is classified across the WHOLE pass before the observations are -// returned, so every consumer agrees on which failures belong to this PR -// (hivecommons/hive#9473). +// breakage is classified across every PR in the pass before human and +// non-Hive observations are removed. A green PR is counter-evidence, and an +// independent human-authored PR must confirm the shared failure. func hivePRObservations(cfg *config.Config, actionable *github.ActionableResult, owners map[string]string) []escalation.Observation { if actionable == nil { return nil } - var obs []escalation.Observation - for _, pr := range actionable.PRs.Items { - if !isHiveAgentAuthor(cfg, pr.Author) { - continue + all := allEscalationObservations(cfg, actionable, owners) + agentObs := all[:0] + for i, pr := range actionable.PRs.Items { + if isHiveAgentAuthor(cfg, pr.Author) { + agentObs = append(agentObs, all[i]) } - obs = append(obs, escalationObservation(cfg, pr, owners)) } - escalation.MarkSharedFailures(obs) - return obs + return agentObs +} + +func allEscalationObservations(cfg *config.Config, actionable *github.ActionableResult, owners map[string]string) []escalation.Observation { + all := make([]escalation.Observation, 0, len(actionable.PRs.Items)+len(actionable.PRs.Held)) + for _, pr := range actionable.PRs.Items { + all = append(all, escalationObservation(cfg, pr, owners)) + } + for _, pr := range actionable.PRs.Held { + all = append(all, escalationObservation(cfg, pr, owners)) + } + escalation.MarkSharedFailures(all) + return all } // escalationObservation projects one enumerated PR into the fix-loop ledger's @@ -8601,18 +8623,19 @@ func escalationObservation(cfg *config.Config, pr github.PullRequest, owners map } red := pr.HasFailingRequiredCheck() return escalation.Observation{ - Repo: repo, - Number: pr.Number, - HeadSHA: pr.HeadSHA, - HeadTree: pr.HeadTree, - Red: red, - Pending: !red && pr.CIStatus != "success", - CIRunning: red && pr.CIChecksRunning, - Labeled: escalation.HasNeedsHumanLabel(pr.Labels), - Excerpt: pr.CIFailureExcerpt, - Labels: pr.Labels, - FailingChecks: pr.FailingChecks, - Agent: owners[escalation.Key(repo, pr.Number)], + Repo: repo, + Number: pr.Number, + HeadSHA: pr.HeadSHA, + HeadTree: pr.HeadTree, + Red: red, + Pending: !red && pr.CIStatus != "success", + CIRunning: red && pr.CIChecksRunning, + Labeled: escalation.HasNeedsHumanLabel(pr.Labels), + Excerpt: pr.CIFailureExcerpt, + Labels: pr.Labels, + FailingChecks: pr.FailingChecks, + Agent: owners[escalation.Key(repo, pr.Number)], + IndependentControl: isIndependentCIControl(cfg, pr), } } @@ -8663,6 +8686,13 @@ func isHiveAgentAuthor(cfg *config.Config, author string) bool { return strings.HasSuffix(author, "[bot]") && !dependencyBots[author] } +// isIndependentCIControl reports whether a PR supplies human-authored evidence +// independent of Hive. Bot-authored and Hive-attributed contributor-relay PRs +// cannot prove that a failure also affects unrelated human work. +func isIndependentCIControl(cfg *config.Config, pr github.PullRequest) bool { + return !isHiveAgentAuthor(cfg, pr.Author) && !pr.AuthorNonHuman && !pr.HiveAttributed +} + // recordRedStaleness updates the shared staleness clock (first-seen-red per red // head SHA) for every hive-authored PR in this pass. It must run before the // claim guard and the reaper so their StaleRed() reads reflect the current tick. @@ -8980,31 +9010,29 @@ func runEscalationSweep( } getEscalationStore() - var obs []escalation.Observation type prMeta struct{ checks []string } meta := map[string]prMeta{} owners := auditPRAgents(cfg.Project.Org, time.Now().Add(-auditPRAttributionWindow), "") - prs := append([]github.PullRequest(nil), actionable.PRs.Items...) - for _, pr := range actionable.PRs.Held { - if pr.ReporterTrustReason != "" { - prs = append(prs, pr) - } - } - for _, pr := range prs { - if !isHiveAgentAuthor(cfg, pr.Author) && pr.ReporterTrustReason == "" { - continue + all := allEscalationObservations(cfg, actionable, owners) + itemCount := len(actionable.PRs.Items) + + obs := all[:0] + for i, o := range all { + var pr *github.PullRequest + if i < itemCount { + pr = &actionable.PRs.Items[i] + } else { + pr = &actionable.PRs.Held[i-itemCount] } - o := escalationObservation(cfg, pr, owners) if pr.ReporterTrustReason != "" { escalationStore.SetReporterTrustReason(o.Repo, o.Number, pr.ReporterTrustReason) } + if (i >= itemCount || !isHiveAgentAuthor(cfg, pr.Author)) && pr.ReporterTrustReason == "" { + continue + } obs = append(obs, o) - meta[escalation.Key(o.Repo, o.Number)] = prMeta{checks: pr.FailingChecks} + meta[escalation.Key(o.Repo, o.Number)] = prMeta{checks: o.FailingChecks} } - // Classify shared/base CI breakage across the whole pass before counting: - // a check failing on this PR and on at least SharedFailureSiblings others - // is an incident, not a failed fix attempt (hivecommons/hive#9473). - escalation.MarkSharedFailures(obs) results := escalationStore.Sweep(obs, cfg.Escalation.EffectiveThreshold()) for _, o := range obs { @@ -10334,8 +10362,31 @@ func planReviewDispatch(cfg *config.Config, actionable *github.ActionableResult, logger.Warn("review verdict artifact unavailable for dispatch planning", "error", err) } prAgents := auditPRAgents(cfg.Project.Org, time.Now().Add(-auditPRAttributionWindow), "") - prs := make([]review.PullRequest, 0, len(actionable.PRs.Items)) - for _, pr := range actionable.PRs.Items { + candidates := make([]github.PullRequest, 0, len(actionable.PRs.Items)+len(actionable.PRs.Held)) + seenPRs := map[string]bool{} + addCandidates := func(items []github.PullRequest) { + for _, pr := range items { + fullRepo := fullRepoName(pr.Repo, cfg.Project.Org) + key := github.ReviewQueueKey(fullRepo, pr.Number) + if fullRepo == "" || pr.Number <= 0 || seenPRs[key] { + continue + } + seenPRs[key] = true + candidates = append(candidates, pr) + } + } + addCandidates(actionable.PRs.Items) + addCandidates(actionable.PRs.Held) + sort.SliceStable(candidates, func(i, j int) bool { + ri, rj := candidates[i].ReviewRank, candidates[j].ReviewRank + if ri > 0 && rj > 0 && ri != rj { + return ri < rj + } + return ri > 0 && rj <= 0 + }) + + prs := make([]review.PullRequest, 0, len(candidates)) + for _, pr := range candidates { lane := classify.Classify(github.Issue{Title: pr.Title, Labels: pr.Labels}).Lane fullRepo := fullRepoName(pr.Repo, cfg.Project.Org) prs = append(prs, review.PullRequest{ @@ -10573,18 +10624,19 @@ type reviewFixAuditor interface { // auditWithheldReviewFixes puts every fix the planner refused this cycle on // the audit trail (#8421). A refusal is silent by design β€” the PR just does // not get a bot commit β€” and silence is the one thing an operator cannot -// investigate, so each one names the PR, who opened it, and the setting -// that would have allowed the push. The planner reports each head once, so +// investigate, so each one names the PR, who opened it, and the +// review.fix_human_prs value at the time (on no longer permits the push: +// hivecommons/hive#12897). The planner reports each head once, so // this does not repeat on every eval cycle. func auditWithheldReviewFixes(plan review.DispatchPlan, auditor reviewFixAuditor, logger *slog.Logger) { for _, w := range plan.WithheldFixes { - logger.Info("review fix withheld: PR not hive-authored and "+w.Setting+" is off; review published, nothing pushed", - "repo", w.Repo, "pr", w.Number, "head_sha", w.HeadSHA, "author", w.Author, "setting", w.Setting) + logger.Info("review fix withheld: PR not hive-authored; review published, nothing pushed", + "repo", w.Repo, "pr", w.Number, "head_sha", w.HeadSHA, "author", w.Author, "setting", w.Setting, "setting_enabled", w.SettingEnabled) if auditor == nil { continue } auditor.AuditLog("governor", "review_fix_withheld", - fmt.Sprintf("pr=%s#%d head=%s author=%s setting=%s=false reason=%s", w.Repo, w.Number, w.HeadSHA, w.Author, w.Setting, w.Reason), "") + fmt.Sprintf("pr=%s#%d head=%s author=%s setting=%s=%t reason=%s", w.Repo, w.Number, w.HeadSHA, w.Author, w.Setting, w.SettingEnabled, w.Reason), "") } } diff --git a/src/cmd/hive/merge_eligibility.go b/src/cmd/hive/merge_eligibility.go index 0a5604f8b0..4a7d37821e 100644 --- a/src/cmd/hive/merge_eligibility.go +++ b/src/cmd/hive/merge_eligibility.go @@ -95,15 +95,17 @@ func contributorLanePolicyFunc(cfg *config.Config) func() automerge.ContributorL return automerge.ContributorLanePolicy{} } p := cfg.AutoMerge.ContributorLane + tier := cfg.AutoMerge.ContributorTier return automerge.ContributorLanePolicy{ - Enabled: p.IsEnabled(), - HiveID: cfg.HiveID, - Soak: time.Duration(p.EffectiveSoakMinutes()) * time.Minute, - RequireReviewerVerdict: p.EffectiveRequireReviewerVerdict(), - Repos: p.RepoSet(), - ExcludeLabels: p.ExcludeLabelSet(), - RequireTiers: p.RequireTierSet(), - RegistryTiersSufficient: p.EffectiveRegistryTiersSufficient(), + Enabled: p.IsEnabled(), + HiveID: cfg.HiveID, + Soak: time.Duration(p.EffectiveSoakMinutes()) * time.Minute, + RequireReviewerVerdict: p.EffectiveRequireReviewerVerdict(), + Repos: p.RepoSet(), + ExcludeLabels: p.ExcludeLabelSet(), + RequireTiers: p.RequireTierSet(), + RegistryTiersSufficient: p.EffectiveRegistryTiersSufficient(), + AutoPromoteAfterMergedPR: tier.EffectiveAutoPromoteAfterMerged(), } } } @@ -229,6 +231,7 @@ func contributorLaneVerdictFromArtifact(artifact review.Artifact, repo string, n var match review.Aggregate var ok bool for _, item := range artifact.Items { + item = item.Normalize() if item.Number == number && strings.EqualFold(strings.TrimSpace(item.Repo), strings.TrimSpace(repo)) && strings.TrimSpace(item.HeadSHA) == strings.TrimSpace(headSHA) { if !ok || item.RecordedAt.After(match.RecordedAt) { match = item @@ -238,12 +241,15 @@ func contributorLaneVerdictFromArtifact(artifact review.Artifact, repo string, n } if ok { return automerge.ContributorLaneVerdict{ - Configured: true, - Approved: approved && match.MergeEligible && match.Verdict == review.VerdictApprove, - Verdict: string(match.Verdict), - DecisionRequested: match.DecisionRequested, - DecisionQuestion: match.DecisionQuestion, - HumanSummaries: append([]string(nil), match.HumanSummaries...), + Configured: true, + Approved: approved && match.MergeEligible && match.Verdict == review.VerdictApprove, + Verdict: string(match.Verdict), + DecisionRequested: match.DecisionRequested, + DecisionQuestion: match.DecisionQuestion, + HumanSummaries: append([]string(nil), match.HumanSummaries...), + HiveAuthoredKnown: match.HiveAuthoredKnown, + HiveAuthored: match.HiveAuthored, + FixCycleCapReached: review.FixCycleCapReached(match), } } return automerge.ContributorLaneVerdict{Configured: true, Approved: approved} diff --git a/src/cmd/hive/merge_eligible_buckets_test.go b/src/cmd/hive/merge_eligible_buckets_test.go index 87c91b9943..8ad3cca3f6 100644 --- a/src/cmd/hive/merge_eligible_buckets_test.go +++ b/src/cmd/hive/merge_eligible_buckets_test.go @@ -543,10 +543,12 @@ func TestWriteMergeEligible_FailingEntryCarriesForkOrigin(t *testing.T) { FailingChecks: []string{"build"}, HeadRef: "sec-check-dashboard", HeadRepo: "alice/testsuite", FromFork: true}, {Repo: "testsuite", Number: 840, CIStatus: "failure", Mergeable: github.MergeableYes, HeadSHA: "f2", FailingChecks: []string{"build"}, HeadRef: "hive/fix-840", HeadRepo: "projectbluefin/testsuite"}, + {Repo: "testsuite", Number: 841, CIStatus: "failure", Mergeable: github.MergeableYes, HeadSHA: "f3", + FailingChecks: []string{"signoff"}, HeadRef: "arteeh/fix", HeadRepo: "arteeh/testsuite", FromFork: true, MaintainerCanModify: true}, } _, failing := runWriteMergeEligible(t, prs, mergeEligibleInputs{org: "projectbluefin"}) - if len(failing) != 2 { - t.Fatalf("failing = %+v, want both PRs", failing) + if len(failing) != 3 { + t.Fatalf("failing = %+v, want all PRs", failing) } byNum := map[int]failingEntry{} for _, f := range failing { @@ -558,6 +560,9 @@ func TestWriteMergeEligible_FailingEntryCarriesForkOrigin(t *testing.T) { if f := byNum[840]; f.FromFork || f.HeadRepo != "projectbluefin/testsuite" || f.ReachableAction != github.ReachableActionPush { t.Errorf("same-repo PR entry = %+v, want reachable_action=push", f) } + if f := byNum[841]; !f.FromFork || f.HeadRepo != "arteeh/testsuite" || f.ReachableAction != github.ReachableActionPush { + t.Errorf("maintainer-editable fork PR entry = %+v, want reachable_action=push", f) + } } // An escalated PR that is conflicted (CI never ran, so nothing is red) or diff --git a/src/cmd/hive/review_dispatch_wiring_test.go b/src/cmd/hive/review_dispatch_wiring_test.go index 5521a53f6a..a71149f424 100644 --- a/src/cmd/hive/review_dispatch_wiring_test.go +++ b/src/cmd/hive/review_dispatch_wiring_test.go @@ -110,6 +110,40 @@ func TestPlanReviewDispatch_KicksReviewerForAgentAuthoredPR(t *testing.T) { } } +func TestPlanReviewDispatch_IncludesHeldPRsInReviewRankOrder(t *testing.T) { + redirectReviewPaths(t) + cfg := reviewSwarmConfig() + cfg.Review.MaxParallelReviews = 1 + actionable := &github.ActionableResult{PRs: github.PRResult{ + Items: []github.PullRequest{{ + Repo: "hive", + Number: 101, + Title: "fix: active item", + Author: "hive-bot", + HeadSHA: reviewTestSHA, + ReviewRank: 2, + }}, + Held: []github.PullRequest{{ + Repo: "pluk", + Number: 257, + Title: "refactor: held review-pending item", + Author: "hive-bot", + HeadSHA: strings.Repeat("b", 40), + ReviewRank: 1, + }}, + }} + + plan := planReviewDispatch(cfg, actionable, nil, nil, restoreTestLogger()) + + if len(plan.ReviewKicks) != 1 { + t.Fatalf("expected 1 review kick, got %d", len(plan.ReviewKicks)) + } + kick := plan.ReviewKicks[0] + if kick.Repo != "kubestellar/pluk" || kick.Number != 257 { + t.Fatalf("kick = %s#%d, want held PR kubestellar/pluk#257 first", kick.Repo, kick.Number) + } +} + func TestPlanReviewDispatch_SkipsHumanAuthoredPR(t *testing.T) { redirectReviewPaths(t) plan := planReviewDispatch(reviewSwarmConfig(), actionableWithPR("some-human"), nil, nil, restoreTestLogger()) diff --git a/src/cmd/hive/review_fix_withheld_audit_test.go b/src/cmd/hive/review_fix_withheld_audit_test.go index 74a40b1950..667f532dea 100644 --- a/src/cmd/hive/review_fix_withheld_audit_test.go +++ b/src/cmd/hive/review_fix_withheld_audit_test.go @@ -54,3 +54,18 @@ func TestAuditWithheldReviewFixesNoEntriesNoAuditor(t *testing.T) { // A nil auditor (no dashboard) must not panic; the log line still fires. auditWithheldReviewFixes(review.DispatchPlan{WithheldFixes: []review.WithheldFix{{Repo: "acme/x", Number: 1, Setting: review.WithheldFixSetting}}}, nil, logger) } + +// TestAuditWithheldReviewFixesRecordsSettingOn: with fix_human_prs on the +// push is still withheld (hivecommons/hive#12897), and the audit entry must +// say the setting was on rather than claim it was off. +func TestAuditWithheldReviewFixesRecordsSettingOn(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + plan := review.DispatchPlan{WithheldFixes: []review.WithheldFix{ + {Repo: "acme/chairlift", Number: 303, HeadSHA: "b91fee3", Author: "contributor", Setting: review.WithheldFixSetting, SettingEnabled: true, Withheld: time.Now().UTC()}, + }} + auditor := &recordingFixAuditor{} + auditWithheldReviewFixes(plan, auditor, logger) + if len(auditor.entries) != 1 || !strings.Contains(auditor.entries[0], "setting=review.fix_human_prs=true") { + t.Fatalf("audit entry must record the setting as on: %v", auditor.entries) + } +} diff --git a/src/cmd/hive/review_gate.go b/src/cmd/hive/review_gate.go index 82c81e3931..61df280740 100644 --- a/src/cmd/hive/review_gate.go +++ b/src/cmd/hive/review_gate.go @@ -52,6 +52,7 @@ func selfMergeReviewGateVerdictFromArtifact(artifact review.Artifact, repo strin var match review.Aggregate found := false for _, item := range artifact.Items { + item = item.Normalize() if item.Number != number || strings.TrimSpace(item.Repo) != wantRepo || strings.TrimSpace(item.HeadSHA) != wantSHA { continue } diff --git a/src/cmd/hive/review_gate_test.go b/src/cmd/hive/review_gate_test.go index 9e880a5866..926d68f629 100644 --- a/src/cmd/hive/review_gate_test.go +++ b/src/cmd/hive/review_gate_test.go @@ -20,6 +20,9 @@ func TestSelfMergeReviewGateVerdictFromArtifact(t *testing.T) { Perspectives: map[review.Perspective]review.Verdict{"security": review.VerdictReject, "style": review.VerdictChangesRequested, "docs": review.VerdictApprove}}, {Repo: "acme/widget", Number: 7, HeadSHA: "incomplete", Verdict: review.VerdictIncomplete, Perspectives: map[review.Perspective]review.Verdict{"security": review.VerdictApprove}}, + {Repo: "acme/widget", Number: 7, HeadSHA: "legacy-human", Verdict: review.VerdictRequiresHuman, RequiresHuman: true, + HumanSummaries: []string{"correctness requires human: field is plumbed but never populated; add the producer."}, + Perspectives: map[review.Perspective]review.Verdict{"correctness": review.VerdictRequiresHuman}}, // An older changes_requested superseded by a newer approve on the same head. {Repo: "acme/widget", Number: 7, HeadSHA: "rereviewed", Verdict: review.VerdictChangesRequested, RecordedAt: old, Perspectives: map[review.Perspective]review.Verdict{"security": review.VerdictChangesRequested}}, @@ -35,6 +38,7 @@ func TestSelfMergeReviewGateVerdictFromArtifact(t *testing.T) { {name: "approved", head: "approved", want: automerge.ReviewGateVerdict{Loaded: true, Found: true, Approved: true}}, {name: "changes requested", head: "changes", want: automerge.ReviewGateVerdict{Loaded: true, Found: true, ChangesRequested: []string{"security", "style"}}}, {name: "awaiting", head: "incomplete", want: automerge.ReviewGateVerdict{Loaded: true, Found: true}}, + {name: "legacy normalized", head: "legacy-human", want: automerge.ReviewGateVerdict{Loaded: true, Found: true, ChangesRequested: []string{"correctness"}}}, {name: "newest aggregate wins", head: "rereviewed", want: automerge.ReviewGateVerdict{Loaded: true, Found: true, Approved: true}}, } for _, tt := range tests { diff --git a/src/docs/acmm-policy-matrix.md b/src/docs/acmm-policy-matrix.md index 7373475125..17479e5d1a 100644 --- a/src/docs/acmm-policy-matrix.md +++ b/src/docs/acmm-policy-matrix.md @@ -98,7 +98,7 @@ Agents open issues AND pull requests. Agent PRs get literal `hold` from the leve ### L6 β€” Fully Autonomous (15 agents) -Existing autonomous lanes can open issues, create PRs, and auto-merge on green CI. Auto-merge is off below L6; switching to L6 turns it on for every active repository, after which owners can toggle repositories individually. L6 PRs do not get the level hold, except outreach PRs into a repository the hive does not manage, which are still held for human review; outreach PRs into the hive's own repos auto-merge like any other lane's. Outreach handles community engagement. Reviewer stays advisory unless the self-authored review gate is enforcing: `review.require_approval: true` (or `governor.review_gate.mode: enforce`) makes the App self-authored sweep require same-head aggregate approval, while `requires_human`/incomplete verdicts can be cleared by a trusted human's normal GitHub Approve review on that exact head. For Hive-authored PRs, `review.self_authored_decision_policy: resolve` (the default) lets the reviewer resolve non-risk sequencing, naming, config-shape and scope questions itself; set `escalate` to park every decision question at 085. Swarm `changes_requested`/`reject` still requires a fix or direct human merge. Use actual holds or GitHub branch rules for a universal merge stop. Adjudicator is the reviewer lane: it repairs, de-escalates, or recommends closing (and may close) escalated `needs-human` hive PRs, and never merges. Dedupe posts the same daily consolidation checklist as at L5 and stays recommendation-only. Telemetry and operations remain paused and use `ISSUES_AND_PRS`, so they never merge their own PRs. +Existing autonomous lanes can open issues, create PRs, and auto-merge on green CI. Auto-merge is off below L6; switching to L6 turns it on for every active repository, after which owners can toggle repositories individually. L6 PRs do not get the level hold, except outreach PRs into a repository the hive does not manage, which are still held for human review; outreach PRs into the hive's own repos auto-merge like any other lane's. Contributor PRs can merge through the contributor lane when the author has an operator-trusted tier or the conservative `auto_merge.contributor_tier.auto_promote_after_merged` clean-history ratchet admits them; reviewer verdict, CI, diff fences, soak and branch protection still apply. Outreach handles community engagement. Reviewer stays advisory unless the self-authored review gate is enforcing: `review.require_approval: true` (or `governor.review_gate.mode: enforce`) makes the App self-authored sweep require same-head aggregate approval, while `requires_human`/incomplete verdicts can be cleared by a trusted human's normal GitHub Approve review on that exact head. For Hive-authored PRs, `review.self_authored_decision_policy: resolve` (the default) lets the reviewer resolve non-risk sequencing, naming, config-shape and scope questions itself; set `escalate` to park every decision question at 085. Swarm `changes_requested`/`reject` still requires a fix or direct human merge. Use actual holds or GitHub branch rules for a universal merge stop. Adjudicator is the reviewer lane: it repairs, de-escalates, or recommends closing (and may close) escalated `needs-human` hive PRs, and never merges. Dedupe posts the same daily consolidation checklist as at L5 and stays recommendation-only. Telemetry and operations remain paused and use `ISSUES_AND_PRS`, so they never merge their own PRs. | Agent | Mode | Template | |-------|------|----------| diff --git a/src/docs/adr/0010-escalation-circuit-breaker.md b/src/docs/adr/0010-escalation-circuit-breaker.md index 14112ca507..14c3e59825 100644 --- a/src/docs/adr/0010-escalation-circuit-breaker.md +++ b/src/docs/adr/0010-escalation-circuit-breaker.md @@ -35,13 +35,17 @@ cadence window. The staleness clock starts when CI SETTLES β€” a head red on one check while others are still running is not yet stuck. The escalation comment quotes the number of kicks actually delivered. -Shared CI breakage is not a fix attempt. A failing check red on at least three -other open PRs in the same pass is an incident (a broken base branch, a runner -outage), so the pass is treated as no-information: no attempt counted, no -staleness clock, no re-engagement. Attempts are counted per head TREE, so an -empty `ci: retrigger` commit does not consume one. An escalation is un-parked -automatically β€” `needs-human` removed, ledger reset, one comment saying why β€” -when the head goes green or when the shared breakage it escalated over clears. +Shared CI breakage is not a fix attempt. A check is shared only when it fails +on every conclusive open PR in the repository and the evidence includes an +independent human-authored control. A green PR disproves a repository-wide +outage; pending PRs provide no evidence either way. This keeps multiple agent +PRs carrying the same defect from suppressing each other's escalation while +holding genuine base-branch or runner incidents after the first independent +confirmation. Shared failures consume no attempt, staleness, or re-engagement +budget. Attempts are counted per head TREE, so an empty `ci: retrigger` commit +does not consume one. An escalation is un-parked automatically β€” +`needs-human` removed, ledger reset, one comment saying why β€” when the head +goes green or when the shared breakage it escalated over clears. A PR escalating for the SECOND time β€” after the reviewer lane ([#5480]) already repaired or de-escalated it once β€” gets a structured hand-off note instead of diff --git a/src/docs/adr/0019-escalation-over-stalling.md b/src/docs/adr/0019-escalation-over-stalling.md index 74590be406..d9f84d05ed 100644 --- a/src/docs/adr/0019-escalation-over-stalling.md +++ b/src/docs/adr/0019-escalation-over-stalling.md @@ -90,3 +90,17 @@ are migrated by the issue stage reconciler (085 added, then the retired label removed) and the two label definitions are deleted from each repo once nothing open carries them. `needs-human` is unchanged: it means a human-held identity or consent is required, not that a ruling is owed. + +## Amendment (2026-10-17, owner decision: issue park is `stage/045-needs-decision`) + +Issues no longer park at `stage/085-needs-decision`; that stage is PR-only. +The issue-side park is `stage/045-needs-decision`, which sits between +`040-covered-by-pr` and the PR stages so an issue's stage never reads as +"past the merge gate". Everything in the amendment above holds with 045 in +place of 085 for issues: the relay's `--needs-decision` and `label` commands +apply 045, the retired spellings migrate to 045, and `needs-human` on an +issue now implies 045 (on a PR it still implies 085). `/hive approve` / +`/hive decision ` un-park to `approved-direction` as before and keep +`needs-human`. Open issues still wearing 085 are re-staged to 045 by the +issue stage reconciler and by a one-shot startup sweep; the 085 definition +is never deleted, PRs wear it. diff --git a/src/docs/advisory-staleness.md b/src/docs/advisory-staleness.md index 8b91394b20..15e37f3a75 100644 --- a/src/docs/advisory-staleness.md +++ b/src/docs/advisory-staleness.md @@ -48,6 +48,7 @@ never re-derives it. | Hive offline | Row pill hidden (offline state dominates) | Unchanged in the UI | counted as `hidden_stale` in diagnostics so fleet-level staleness is still visible | | Pure PR/merge hive | Suppressed | Suppressed | unchanged (expected suppression) | | Every agent paused or off-schedule, digest ageing | Flagged stale β€” the pause itself lit the pill | Suppressed β€” no agents, no findings, no digest | Gate 3 (`allAgentsQuietByDesign`, #4528); counted as `hidden_stale` in diagnostics | +| Spoke heartbeat collect timed out, beat carries cached/minimal stats (`statsStale`) | `advisory-stale` alert raised from the last-good `advisoryLastPostedAt` while fresh digests were posting | No fleet alert β€” the post time is not a fresh observation | The hub alert rule skips `StatsStale` beats (#12842); health verdict already reads "heartbeat stats stale" | ## Diagnostics diff --git a/src/docs/agent-configuration.md b/src/docs/agent-configuration.md index 5e3aeed2c4..f6f901634f 100644 --- a/src/docs/agent-configuration.md +++ b/src/docs/agent-configuration.md @@ -258,7 +258,12 @@ spec omits `launch_cmd`, Hive still builds the backend command normally. busy_no_activity_threshold: 30m # surface Working/no-transcript activity after repeated # undeliverable kicks; default 30m. At twice - # this silence the session is restarted + # this silence the session is restarted (also + # when the live CLI pane shows no input prompt + # at all, or, on non-inference backends, sits + # on a consent/blocking menu); a + # kick_failed_repeatedly self-report is filed + # only if kicks still fail after that restart max_turn_duration: 0s # optional visibility-only Working ceiling; 0 disables restart_strategy: immediate # how to bring a dead session back beads_dir: /data/beads/scanner # work-record (bead) storage; default per-agent @@ -916,6 +921,18 @@ Why a setting and not `AGENTS.md` ([#7667](https://github.com/hivecommons/hive/i Where you will see it: the agent's Prompt Template tab renders the guide where the kick will place it, so you can confirm the setting took. Prompts built in Go take the rendered guide as a parameter rather than expanding `${WRITING_GUIDE}` themselves. That includes the **contributor relay's task prompt**, since [#8124](https://github.com/hivecommons/hive/issues/8124), immediately before the instruction that tells the agent to open the PR. The guide travels with the *assigning* hive, so a relay subscribed to two hives gets each hive's guide on that hive's tasks; see [`contributor-relay.md`](contributor-relay.md#the-assigning-hives-writing-guide-travels-with-the-task). The **review swarm** also carries it in review prompts, so posted review comments use the same editorial voice as issue and PR bodies. + +### Hive-authored issue decision policy (`project.agent_issue_decision_policy`) + +`project.agent_issue_decision_policy` controls what happens when Hive's own agents try to park an issue with `hive-open-issue --needs-decision`, `hive-open-issue comment … --needs-decision` or `label … stage/045-needs-decision`. A typed `--step-kind` owner step is exempt and always parks. The default is `resolve`: Hive-authored issues do **not** park for sequencing, naming, numbering, config-shape, scope, or "approach A vs B" engineering questions. The agent should pick the simplest additive, backward-compatible option and record the choice in the issue body as `Decision taken by hive: …`; the relay downgrades non-risk parks to `stage/010-actionable` and writes an audit entry. Set `escalate` to preserve the older behavior where every Hive-authored decision request parks. + +Parking remains for risk categories: security posture, data loss, irreversible or public-facing behaviour, governance/policy/licensing, spend, credentials/secrets/infra the hive cannot access (owner-only), or a question about a human's intent that cannot be inferred. Parked issue bodies should include `Risk: ` so the maintainer can see why a person is needed. Human-origin issue filings are unaffected. + +```yaml +project: + agent_issue_decision_policy: resolve # resolve (default) or escalate +``` + ## Label policy: which issues agents may work There is exactly **one** label-policy surface for the hive's own agents β€” the **Governor Configuration β†’ Labels** tab β€” and it has two polarities: diff --git a/src/docs/audit-log.md b/src/docs/audit-log.md index 0019f77094..4ddef0ab21 100644 --- a/src/docs/audit-log.md +++ b/src/docs/audit-log.md @@ -89,12 +89,18 @@ One `system` writer worth knowing by name: CLI model discovery (#7384). When a backend's live model probe fails β€” or degrades to a catalog that is not the one the CLI uses β€” the dashboard writes `model_discovery_failed` with `backend=…, platform=, fallback=…, degraded=…, served=…, error=`, and `model_discovery_recovered` (with `previous=` the -failure it recovered from) once a clean probe answers again. Entries are -written on transitions only, never per 30 s probe, and the ordinary -not-configured fallbacks (no credential, no helper installed) are not -failures and write nothing. Before this existed, a spoke served a wrong -catalog for two days and the only record was a server log line. +probe's own message>` β€” plus `state=credential_missing, recovery=` when the failure is copilot's runtime `Not authenticated` refusal +(#12947), naming the dashboard Copilot Login that clears it β€” and +`model_discovery_recovered` (with `previous=` the failure it recovered from) +once a clean probe answers again. Entries are written on transitions only, +never per 30 s probe, and the ordinary not-configured fallbacks (no credential +found, no helper installed) are not failures and write nothing. The `Not +authenticated` case is different: the helper ran and refused, so it does write +a `model_discovery_failed` entry (with `state=`/`recovery=`) even though no +fleet self-report is filed for it (see `fleet-report.md`). Before this +existed, a spoke served a wrong catalog for two days and the only record was a +server log line. ## Rotation and retention diff --git a/src/docs/contributor-relay.md b/src/docs/contributor-relay.md index 274c16b22f..8b2310d928 100644 --- a/src/docs/contributor-relay.md +++ b/src/docs/contributor-relay.md @@ -276,6 +276,8 @@ Important environment variables: | --- | --- | --- | | `HIVE_HUB` | value from `contributor.env`, else public hub default | WebSocket hub(s) to subscribe to. Use comma-separated URLs for multi-hub mode. Direct Compose reads the registered value from the mounted config file. | | `HIVE_REGISTRATION_TOKEN` | value from `contributor.env` | Registration token(s), positional with `HIVE_HUB` when multiple hubs are listed. Required; run `just contribute-setup` first. | +| `HIVE_REPO_SCOPE` | value from `contributor.env` (written by `hivectl`), else unset | Per-hub repository scope, positional with `HIVE_HUB` (`,` between hubs, `;` between repositories within one hub; `*` = unrestricted legacy profile). Written single-quoted by `hivectl`; the relay strips the quotes when it reads the file. | +| `HIVE_REPO_SCOPE_VERSION` | value from `contributor.env` | Counter bumped by `hivectl` on every profile commit so the relay can tell the scope changed. | | `HIVE_COMMONS_STRATEGY` | `ranked` | Multi-hive routing strategy for The Commons. `ranked` solicits the first subscribed hive and falls through only when it has no work; `spread` rotates with rank weights and periodic mixing; `neediest` scores each hub's `/api/contribute/status` by `actionable_items` with a small boost for idle contributor capacity (`total_registered - active_contributors`) when those fields are present. The relay evaluates this only when it is ready for another task, never while a lease is in flight. | | `HIVE_COMMONS_SPREAD_MIX_EVERY` | `7` | In `spread`, every N completed/failed tasks the next hive is chosen randomly from the weighted rank cycle instead of by cursor. Set `0` to disable mixing. | | `HIVE_COMMONS_NEEDIEST_REFRESH_MS` | `60000` | In `neediest`, how often to refresh each subscribed hub's `/api/contribute/status` cache. Set `0` to rely on auth-time/manual refreshes only. | @@ -595,6 +597,45 @@ Two things now hold, from either side of the pane: - **The relay sweeps the checkout on every task exit** β€” completion, failure, revoke, shutdown, headless or interactive β€” *after* the agent is stopped. If `$HIVE_WORKSPACE_DIR//` is a repository with uncommitted changes (untracked files included), they are set aside with `git stash push --include-untracked -m "hive leftover (#, )"`, never reset or cleaned: the leftovers are somebody's work, and `git -C stash list` shows which task they came from. The sweep is best-effort and never fatal β€” a git still holding the index lock makes it log and move on β€” and it runs only against a directory the relay can prove is the task's checkout: `HIVE_WORKSPACE_DIR` must be set explicitly (the relay's own cwd in local mode is *your* hive checkout, and is never swept), and the per-repo directory must itself be a repository. - **The assignment prompt tells the agent to look before branching**: run `git status`, stash anything it did not write with `git stash push -u -m "hive leftover"` rather than discard or commit it, confirm the tree is clean, and prefer `git worktree add` for its task branch so the shared checkout is never its working tree. This half covers a contributor whose relay never sees the checkout. +## Contributor repository scope + +The generated `contributor.env` may narrow each Hive subscription with +`HIVE_REPO_SCOPE`. Its comma-separated entries align with `HIVE_HUB` and the +registration tokens. An absent key or `*` entry preserves legacy unrestricted +behavior. Otherwise an entry contains semicolon-separated canonical repository +IDs (`///`, lower case); an empty entry selects nothing. +`HIVE_REPO_SCOPE_VERSION` is the projection's nonnegative monotonic integer. +For example, `HIVE_REPO_SCOPE=*,github/github.com/org/a;github/github.com/org/b` +leaves the first Hive unrestricted and selects two repositories on the second. +A blank `HIVE_REPO_SCOPE=` for one Hive selects no repositories. + +A scoped subscription requires `repo_scope_v1` in `auth_ok.server_capabilities`. +The relay sends `repo_scope` with `scope_version`, `mode: "selected"`, and +`repositories`, and withholds every `ready` until a matching +`repo_scope_ack` has `accepted: true`. Subsequent `ready` frames carry that +version. A legacy Hive without the capability is marked `unsupported`, logged +once, and skipped by Commons rotation. Scope status is recorded per contributor +in `hubs-seen.json` and displayed by `hivectl hives list`; legacy timestamp +entries remain readable by the updated CLI. Update the CLI with the relay when +using scoped profiles, since older CLIs only understand timestamp entries. + +`SIGUSR1` reloads the saved projection synchronously and renegotiates changed +scopes. Moving back to legacy scope on an existing connection sends `mode: "all"` +and waits for its acknowledgement. Invalid projections withhold all new work. +An already executing task retains its original Hive lease and credentials. +Every new assignment, including a `fix-pr` interrupt, is checked against that +Hive's latest local selection before credentials, CLI execution or local quota +allowances can change. Scoped assignments need recognizable forge, host and +repository identity (explicit `forge`/`host` fields, or the existing GitHub task +URL); unknown or unselected identities are declined with +`reason: "out_of_contributor_scope"`. The scope-capable hub releases that lease +and rolls back its assignment allowance without recording a task failure. + +Empty or disabled selections everywhere log +`no repositories selected on any hive β€” idle`. No strategy falls back to the +default Hive or to all repositories. Contributor scope only narrows work; +operator admission, task credentials, trust rules and quotas still apply. + ## Multi-hub subscription A single relay can subscribe to multiple hives. Register with each hive first, then provide matching comma-separated lists: diff --git a/src/docs/dashboard-sections.md b/src/docs/dashboard-sections.md index 4cdfac6e48..ee94d5d8e1 100644 --- a/src/docs/dashboard-sections.md +++ b/src/docs/dashboard-sections.md @@ -612,7 +612,7 @@ More about the line: A progress strip per open issue and pull request showing which step of the pipeline it is on and who must act next. -Fed by `GET /api/stage-tracker`. Every issue and pull request follows the same pipeline, named once in the column header above the list: `Open β†’ Actionable (010) β†’ Claimed (020) β†’ PR in progress (030/040) β†’ CI / Review (050/060) β†’ Merge gate (080/100) β†’ βœ“ merged`. Each entry is a title line over a thin chevron strip. Only the current step is coloured: it is filled amber, carries the step name in words and pulses (static under `prefers-reduced-motion`); once the PR is merged the final βœ“ chevron is the lit one, in green. Every other step is a low-contrast shape with a small grey glyph (circle, bolt, lock, branch, eye, shield, check) β€” passed steps a subtle neutral fill, steps ahead a faint outline. Side stages are not extra steps: `070 Fixing` (the author's move; the `needs-rebase` chip next to it says what is being fixed β€” the stage labeller always parks a needs-rebase PR at 070/071/072, never at CI / Review), `071 Needs author` (human-authored), `072 Returned to contributor` (clanker-authored) and `074 Adopted by hive` light *CI / Review* with their own name (an adopted PR stays on 074 through the hive's pushes going pending, green or red again, and moves on only when *Merge gate* takes it); `085 Needs human decision` and `090 Ready for human merge` light *Merge gate* with their own name (the 085 tooltip says what must be decided). The lit chevron ends with an ink-coloured icon of whose move it is β€” person (human), robot (clanker) or hexagon (hive): the claim holder on Claimed, the PR author on PR in progress and Returned to contributor, hive on CI / Review, Adopted by hive and Ready for auto merge, a human on Needs human decision and Ready for human merge. `040 Covered by PR` shares the *PR in progress* column with 030; `060 Reviewed`, `080 Awaiting merge admission` and `100 Merge queue` all light *Merge gate* as "Ready for auto merge" (the chevron tooltip says "not yet admitted β€” " for 060/080 and "admitted, merging" for 100); the chevron's own tooltip names the exact stage. Covering pull requests nest under their issue. An issue with no PR yet draws the whole strip like any other row, lit at Open, Actionable or Claimed with the steps ahead faint. Once exactly one PR covers it, issue and PR are a single entry: the issue's `#N title` on the left, `[author icon] #PR title` under the *PR in progress* header on the same title line, and one continuous strip β€” Open, Actionable and Claimed passed, the PR's stage lighting the chevron from PR in progress through CI / Review, Merge gate and βœ“; the chip, tooltip and `since` are the PR's. With two or more covering PRs the issue's own strip stops at Claimed β€” all passed, nothing on the issue line lit. Directly beneath, tighter than the gap between entries, sits one indented sub-row per open covering PR, furthest along first: `[author icon] #N title` at the *PR in progress* column, then that PR's own strip from there on β€” `PR in progress β†’ CI / Review β†’ Merge gate β†’ βœ“` β€” with its own lit chevron, chip, tooltip and `since`. So an issue with two PRs, one at 030, shows "PR in progress" lit on that sub-row, not on the issue. A PR linked to an open issue in the list has no top-level row of its own; a PR with no linked issue keeps a full-width row and shows the first steps as passed. The *Pull requests* filter flattens the list β€” every PR as a top-level row; *Issues* and *All* show the nested view. Each row is named once on the left; a covering PR appears under the *PR in progress* header as `[author icon] #N title`. A pull request with no linked issue is named once: `[PR icon] [author icon] #N title` on the left over one continuous strip (first steps passed, its own step lit) and nothing in the *PR in progress* column, which is reserved for covering PRs β€” so a title never appears twice in a row. Pull requests are never issues here: when a number arrives through both the issues feed and the pulls feed the PR record wins and the issue copy is dropped on the server (pairing is keyed by repository, kind and number), and the *Issues* filter never lists a PR. The author icon is the one coloured legend: hive hexagon in blue, clanker robot in purple, human silhouette in teal (the `author/*` kind, inferred from the snapshot when the label is absent; hover for the login). The right-hand `since` column, on the title line, is how long the item has been on its step (red when `stalled`). At most one muted chip sits to its left on the title line: the most important health modifier (`stalled`, `regressed`, `hold`, `needs-human`, `needs-decision` β€” shown as "needs human decision" β€”, `needs-rebase`, `blocked`, `draft`) with a `+N` for the rest. Everything else β€” the reason sentence, who must act, nudges sent, the repository, all flags and the last stage hops β€” is in the row's hover tooltip. Title lines never wrap or push the strip out of alignment: the issue title is truncated with an ellipsis before the *PR in progress* column (a clear gap always separates it from the PR entry, which starts at the column edge) and the PR title before the chips and `since`; hovering a truncated title shows the full text. Rows are sorted furthest-along first by default β€” a covered issue ranks by its furthest-along PR β€” then longest on the step first; *Sort* also offers longest waiting, newest and number, and the arrow beside it reverses the order. Every open issue and pull request of the watched repositories is listed β€” actionable, held, claimed, in progress, covered by a PR, filtered by a `needs-*` label, reporter-triage, draft and exempt-labelled alike; an item without a stage label or journal entry sits at *Open*, and one in a scanner bucket with no label yet is placed from the bucket (claimed β†’ 020, in progress β†’ 030, covered β†’ 040). Filters work on the cached payload in the browser and persist across reloads (the page number does not): kind chips, *Repo*, a search box (`#number` / number / title substring, also matching a covering PR), *Since* (any, over 1 hour / 1 day / 1 week on the current step, or past the stage's configured dwell β€” stalled items always qualify), *Author* kind, *Needs attention* (anything with a health modifier or on 071 / 072 / 085 / 090), and label chips built from the labels present (`stage/*`, `needs-*`, `author/*`, `role/*` always; other labels when two or more items share them; chips of one family OR, families AND; counts on each chip). *Clear filters* shows while anything is narrowed. The list is paged β€” 25 / 50 / 100 rows, default 50 β€” with Prev / Next and "showing X–Y of N (M filtered out)" above and below; the header count "N items Β· M need attention" is for the filtered set, the sidebar badge for everything. "updated Ns ago Β· cache" says how old the server-built payload is: the spoke rebuilds it at most every `dashboard.stage_tracker.cache_ttl_s` (default `governor.eval_interval_s`) from its existing snapshot and serves it with an `ETag`, so the browser's 30-second poll is a 304 and no request ever reaches GitHub (`/api/gh-rate-limits` lists the caller `dashboard:stage-tracker` with nothing charged). See [Stage labels](labels-and-control-signals.md#stage-labels-stagennn--the-pipeline-state-machine). +Fed by `GET /api/stage-tracker`. Every issue and pull request follows the same pipeline, named once in the column header above the list: `Open β†’ Actionable (010) β†’ Claimed (020) β†’ PR in progress (030/040) β†’ CI / Review (050/060) β†’ Merge gate (080/100) β†’ βœ“ merged`. Each entry is a title line over a thin chevron strip. Only the current step is coloured: it is filled amber, carries the step name in words and pulses (static under `prefers-reduced-motion`); once the PR is merged the final βœ“ chevron is the lit one, in green. Every other step is a low-contrast shape with a small grey glyph (circle, bolt, lock, branch, eye, shield, check) β€” passed steps a subtle neutral fill, steps ahead a faint outline. Side stages are not extra steps: `070 Fixing` (the author's move; the `needs-rebase` chip next to it says what is being fixed β€” the stage labeller always parks a needs-rebase PR at 070/071/072, never at CI / Review), `071 Needs author` (human-authored), `072 Returned to contributor` (clanker-authored) and `074 Adopted by hive` light *CI / Review* with their own name (an adopted PR stays on 074 through the hive's pushes going pending, green or red again, and moves on only when *Merge gate* takes it); `085 Needs human decision` (PR-only) and `090 Ready for human merge` light *Merge gate* with their own name (the 085 tooltip says what must be decided); an issue parked on a person wears `045 Needs human decision` and lights *Actionable* (the issue side) with that name β€” a pre-045 issue still wearing 085 is drawn the same way until the reconciler re-stages it. The lit chevron ends with an ink-coloured icon of whose move it is β€” person (human), robot (clanker) or hexagon (hive): the claim holder on Claimed, the PR author on PR in progress and Returned to contributor, hive on CI / Review, Adopted by hive and Ready for auto merge, a human on Needs human decision and Ready for human merge. `040 Covered by PR` shares the *PR in progress* column with 030; `060 Reviewed`, `080 Awaiting merge admission` and `100 Merge queue` all light *Merge gate* as "Ready for auto merge" (the chevron tooltip says "not yet admitted β€” " for 060/080 and "admitted, merging" for 100); the chevron's own tooltip names the exact stage. Covering pull requests nest under their issue. An issue with no PR yet draws the whole strip like any other row, lit at Open, Actionable or Claimed with the steps ahead faint. Once exactly one PR covers it, issue and PR are a single entry: the issue's `#N title` on the left, `[author icon] #PR title` under the *PR in progress* header on the same title line, and one continuous strip β€” Open, Actionable and Claimed passed, the PR's stage lighting the chevron from PR in progress through CI / Review, Merge gate and βœ“; the chip, tooltip and `since` are the PR's. With two or more covering PRs the issue's own strip stops at Claimed β€” all passed, nothing on the issue line lit. Directly beneath, tighter than the gap between entries, sits one indented sub-row per open covering PR, furthest along first: `[author icon] #N title` at the *PR in progress* column, then that PR's own strip from there on β€” `PR in progress β†’ CI / Review β†’ Merge gate β†’ βœ“` β€” with its own lit chevron, chip, tooltip and `since`. So an issue with two PRs, one at 030, shows "PR in progress" lit on that sub-row, not on the issue. A PR linked to an open issue in the list has no top-level row of its own; a PR with no linked issue keeps a full-width row and shows the first steps as passed. The *Pull requests* filter flattens the list β€” every PR as a top-level row; *Issues* and *All* show the nested view. Each row is named once on the left; a covering PR appears under the *PR in progress* header as `[author icon] #N title`. A pull request with no linked issue is named once: `[PR icon] [author icon] #N title` on the left over one continuous strip (first steps passed, its own step lit) and nothing in the *PR in progress* column, which is reserved for covering PRs β€” so a title never appears twice in a row. Pull requests are never issues here: when a number arrives through both the issues feed and the pulls feed the PR record wins and the issue copy is dropped on the server (pairing is keyed by repository, kind and number), and the *Issues* filter never lists a PR. The author icon is the one coloured legend: hive hexagon in blue, clanker robot in purple, human silhouette in teal (the `author/*` kind, inferred from the snapshot when the label is absent; hover for the login). The right-hand `since` column, on the title line, is how long the item has been on its step (red when `stalled`). At most one muted chip sits to its left on the title line: the most important health modifier (`stalled`, `regressed`, `hold`, `needs-human`, `needs-decision` β€” shown as "needs human decision" β€”, `needs-rebase`, `blocked`, `draft`) with a `+N` for the rest. Everything else β€” the reason sentence, who must act, nudges sent, the repository, all flags and the last stage hops β€” is in the row's hover tooltip. Title lines never wrap or push the strip out of alignment: the issue title is truncated with an ellipsis before the *PR in progress* column (a clear gap always separates it from the PR entry, which starts at the column edge) and the PR title before the chips and `since`; hovering a truncated title shows the full text. Rows are sorted furthest-along first by default β€” a covered issue ranks by its furthest-along PR β€” then longest on the step first; *Sort* also offers longest waiting, newest and number, and the arrow beside it reverses the order. Every open issue and pull request of the watched repositories is listed β€” actionable, held, claimed, in progress, covered by a PR, filtered by a `needs-*` label, reporter-triage, draft and exempt-labelled alike; an item without a stage label or journal entry sits at *Open*, and one in a scanner bucket with no label yet is placed from the bucket (claimed β†’ 020, in progress β†’ 030, covered β†’ 040). Filters work on the cached payload in the browser and persist across reloads (the page number does not): kind chips, *Repo*, a search box (`#number` / number / title substring, also matching a covering PR), *Since* (any, over 1 hour / 1 day / 1 week on the current step, or past the stage's configured dwell β€” stalled items always qualify), *Author* kind, *Needs attention* (anything with a health modifier or on 071 / 072 / 085 / 090), and label chips built from the labels present (`stage/*`, `needs-*`, `author/*`, `role/*` always; other labels when two or more items share them; chips of one family OR, families AND; counts on each chip). *Clear filters* shows while anything is narrowed. The list is paged β€” 25 / 50 / 100 rows, default 50 β€” with Prev / Next and "showing X–Y of N (M filtered out)" above and below; the header count "N items Β· M need attention" is for the filtered set, the sidebar badge for everything. "updated Ns ago Β· cache" says how old the server-built payload is: the spoke rebuilds it at most every `dashboard.stage_tracker.cache_ttl_s` (default `governor.eval_interval_s`) from its existing snapshot and serves it with an `ETag`, so the browser's 30-second poll is a 304 and no request ever reaches GitHub (`/api/gh-rate-limits` lists the caller `dashboard:stage-tracker` with nothing charged). See [Stage labels](labels-and-control-signals.md#stage-labels-stagennn--the-pipeline-state-machine). ## Agent activity diff --git a/src/docs/dashboard.md b/src/docs/dashboard.md index f87b65dd3a..fe67ce45ba 100644 --- a/src/docs/dashboard.md +++ b/src/docs/dashboard.md @@ -274,7 +274,7 @@ someone else and Outside terms carry a β“˜ breakdown in `/api/status` as `actionableNow.equation.terms[].breakdown[]`; `actionableNow.outside.breakdown[]` has each Outside row naming the count, rule, current setting, and how to change it for this hive. The Outside rows cover hard-suppress labels -(`stage/085-needs-decision` with its retired `needs-decision` / `needs-direction` spellings, `needs-spec`, and `needs-human`) only for +(`stage/045-needs-decision` with its retired pre-045 `stage/085-needs-decision`, `needs-decision` / `needs-direction` spellings, `needs-spec`, and `needs-human`) only for items that are not your turn, exempt labels from `governor.labels.exempt` plus permanent exempt labels, reporter-trust triage from `project.issue_filter.reporter_trust`, require-label filtering from @@ -321,7 +321,7 @@ and exposes the editable rows. | Bucket | Definition | Rule/config source | How to change | | --- | --- | --- | --- | -| `needs-decision` (shown as "needs human decision") | Issue is parked for a maintainer ruling. | `stage/085-needs-decision` always, plus labels in `project.issue_filter.hard_suppress_labels.needs_decision` / `.needs_direction` and the retired `needs-decision` / `needs-direction` spellings until the reconciler migrates them ([#12683](https://github.com/hivecommons/hive/issues/12683)). | Settings β†’ Labels β†’ Triage buckets, or edit `hive.yaml`. | +| `needs-decision` (shown as "needs human decision") | Issue is parked for a maintainer ruling. | `stage/045-needs-decision` always (and the PR-only `stage/085-needs-decision`, which a pre-045 issue may still carry), plus labels in `project.issue_filter.hard_suppress_labels.needs_decision` / `.needs_direction` and the retired `needs-decision` / `needs-direction` spellings until the reconciler migrates them ([#12683](https://github.com/hivecommons/hive/issues/12683)). | Settings β†’ Labels β†’ Triage buckets, or edit `hive.yaml`. | | `needs-spec` | Issue needs specification or acceptance criteria. | Labels in `project.issue_filter.hard_suppress_labels.needs_spec`; default `needs-spec`. | Settings β†’ Labels β†’ Triage buckets, or edit `hive.yaml`. | | Exempt labels | Issue or PR carries a deny-list label and is never agent work. | `governor.labels.exempt` plus permanent labels such as `do-not-merge`. | Settings β†’ Labels β†’ Exempt labels, or edit `hive.yaml`. | | Reporter triage | Reporter trust is enabled and the issue author is not trusted yet. | `project.issue_filter.reporter_trust` trusted associations/logins and untrusted required labels. | Settings β†’ Labels β†’ Reporter trust. | @@ -402,9 +402,9 @@ in exactly one band, while non-winning states remain as badges on the pill: close it. Once acknowledged the issue falls through to Unclaimed/Claimed with the role badge still on the pill. 4. **Parked** (`parked`) β€” the parking labels and `blocked` by default: - `needs-human`, `stage/085-needs-decision` (plus its retired - `needs-direction` / `needs-decision` spellings until the issue stage - reconciler migrates them), `needs-spec` and `blocked`. `blocked` is a gate only a person lifts, so it is parked + `needs-human`, `stage/045-needs-decision` (plus its retired pre-045 + `stage/085-needs-decision`, `needs-direction` / `needs-decision` spellings + until the issue stage reconciler migrates them), `needs-spec` and `blocked`. `blocked` is a gate only a person lifts, so it is parked whatever it waits on; the Your turn decision still tells a block on another item (waiting elsewhere) from one nothing explains (`blocked_unexplained`) ([#12376](https://github.com/hivecommons/hive/issues/12376)). @@ -446,7 +446,7 @@ Operators can tune only the display taxonomy under `dashboard.issue_bands`: ```yaml dashboard: issue_bands: - waiting_labels: [needs-human, stage/085-needs-decision, needs-direction, needs-decision, needs-spec, blocked] + waiting_labels: [needs-human, stage/045-needs-decision, stage/085-needs-decision, needs-direction, needs-decision, needs-spec, blocked] done_labels: [hive/already-done, hive/covered-by-pr, hive/likely-done] stale_days: 14 ``` diff --git a/src/docs/design/agent-turn-model.md b/src/docs/design/agent-turn-model.md index 0597958dfb..431c8673e2 100644 --- a/src/docs/design/agent-turn-model.md +++ b/src/docs/design/agent-turn-model.md @@ -48,35 +48,35 @@ plainly β€” that is the main thing a spike is for. The backend CLI is launched **once per tmux session**, as an interactive foreground process typed into a shell pane, and it stays there across many turns. `launchInTmux` creates a detached shell session -(`src/pkg/agent/manager_tmux.go:149`, `newSessionCommands`, which issues a plain -`tmux new-session -d` at `src/pkg/agent/manager_tmux.go:165`) and then *types the CLI +(`src/pkg/agent/manager_tmux.go:172`, `newSessionCommands`, which issues a plain +`tmux new-session -d` at `src/pkg/agent/manager_tmux.go:201`) and then *types the CLI invocation into that shell*: ```go -m.tmuxSendLiteralForAgent(agent, fullCmd) // src/pkg/agent/manager_tmux.go:499 +m.tmuxSendLiteralForAgent(agent, fullCmd) // src/pkg/agent/manager_tmux.go:553 time.Sleep(textToEnterDelay) -m.tmuxSendEntersForAgent(agent) // src/pkg/agent/manager_tmux.go:504 +m.tmuxSendEntersForAgent(agent) // src/pkg/agent/manager_tmux.go:558 ``` The composed command line is per-backend and is built as a string β€” `claude --model … --dangerously-skip-permissions` -(`src/pkg/agent/manager_launch.go:861`), `copilot … --allow-all` -(`src/pkg/agent/manager_launch.go:888`), and so on. +(`src/pkg/agent/manager_launch.go:849`), `copilot … --allow-all` +(`src/pkg/agent/manager_launch.go:859`), and so on. Nothing in the kick path ever spawns a process. `deliverKickLocked` -(`src/pkg/agent/manager_kick.go:252`) only manipulates the existing pane. +(`src/pkg/agent/manager_kick.go:244`) only manipulates the existing pane. ### 1.2 A turn starts with simulated keystrokes -`SendKick` (`src/pkg/agent/manager_kick.go:110`) is the entry point; it delegates to -`deliverKickLocked` (`src/pkg/agent/manager_kick.go:252`), which: +`SendKick` (`src/pkg/agent/manager_kick.go:102`) is the entry point; it delegates to +`deliverKickLocked` (`src/pkg/agent/manager_kick.go:244`), which: - clears any stale input with `C-c` then `C-u` - (`src/pkg/agent/manager_kick.go:280-283`), + (`src/pkg/agent/manager_kick.go:290-293`), - types the prompt literally, chunked, via - `tmuxSendLiteralForAgent` (`src/pkg/agent/manager_kick.go:317`, implementation at - `src/pkg/agent/manager_tmux.go:499` β†’ `tmux send-keys -l`), -- submits with `tmuxSendEntersForAgent` (`src/pkg/agent/manager_kick.go:344`). + `tmuxSendLiteralForAgent` (`src/pkg/agent/manager_kick.go:327`, implementation at + `src/pkg/agent/manager_tmux.go:553` β†’ `tmux send-keys -l`), +- submits with `tmuxSendEntersForAgent` (`src/pkg/agent/manager_kick.go:354`). The prompt reaches the model the same way a human's keyboard would. There is no API call, no stdin pipe, and no structured request that hive could inspect, @@ -89,10 +89,10 @@ turns, so there is no exit to wait for. Turn completion is **inferred from pane text**, by two independent mechanisms: **The readiness gate before the next kick.** -`waitForInputPromptForAgent` (`src/pkg/agent/manager_tmux.go:452`) polls +`waitForInputPromptForAgent` (`src/pkg/agent/manager_tmux.go:496`) polls `captureTmuxPaneForAgent` and returns once `paneShowsInputPrompt` -(`src/pkg/agent/manager_pane_signals.go:343`) matches a per-backend marker string. `SendKick` -calls it at `src/pkg/agent/manager_kick.go:110`. This gate is the only thing that +(`src/pkg/agent/manager_pane_signals.go:413`) matches a per-backend marker string. `SendKick` +calls it at `src/pkg/agent/manager_kick.go:178`. This gate is the only thing that stops hive typing a new prompt on top of an in-flight response. The gate is bounded by `inputPromptTimeout` (120s), which is why operator kicks @@ -105,16 +105,16 @@ a retried click cannot type the prompt twice. The governor's tick still calls `SendKick` synchronously β€” it has no proxy in front of it. **The pane poller.** `pollTmuxOutputForAgent` -(`src/pkg/agent/manager_poll.go:16`) runs a `3 * time.Second` ticker -(`src/pkg/agent/manager_poll.go:17`) for the agent's whole lifetime, diffing +(`src/pkg/agent/manager_poll.go:13`) runs a `3 * time.Second` ticker +(`src/pkg/agent/manager_poll.go:15`) for the agent's whole lifetime, diffing captured pane content to maintain `agent.LastPaneChange` -(`src/pkg/agent/manager_poll.go:93`) β€” hive's only evidence that a running, +(`src/pkg/agent/manager_poll.go:102`) β€” hive's only evidence that a running, authenticated CLI is actually doing something. Stall recovery is likewise textual: `nudgeIfKickStalled` -(`src/pkg/agent/manager_kick.go:805`) compares `paneContentHash(pane)` against -`agent.lastInferKickPane` (`src/pkg/agent/manager_kick.go:833`), and -`paneShowsActiveWork` (`src/pkg/agent/manager_kick.go:729`) matches a spinner or a +(`src/pkg/agent/manager_kick.go:878`) compares `paneContentHash(pane)` against +`agent.lastInferKickPane` (`src/pkg/agent/manager_kick.go:906`), and +`paneShowsActiveWork` (`src/pkg/agent/manager_kick.go:794`) matches a spinner or a token-counter marker. ### 1.4 Scheduling: one shared ticker, not a per-turn continuation @@ -122,17 +122,17 @@ token-counter marker. **Correction to the task framing: `src/pkg/agent/scheduler.go` does not exist.** Kick *timing* lives in `pkg/governor`; kick *text* is built in `pkg/scheduler`. -- `Governor.agentsDueForKick` (`src/pkg/governor/governor.go:787`) compares +- `Governor.agentsDueForKick` (`src/pkg/governor/governor.go:887`) compares `now - LastKick` against each agent's configured cadence (`AgentCadence`, `src/pkg/governor/governor.go:25`). -- `Governor.Evaluate` (`src/pkg/governor/governor.go:323`) calls it at - `src/pkg/governor/governor.go:420` and returns the due list. +- `Governor.Evaluate` (`src/pkg/governor/governor.go:377`) calls it at + `src/pkg/governor/governor.go:485` and returns the due list. - The driving loop is a single ticker in `main`: - `time.NewTicker(… EvalIntervalS …)` at `src/cmd/hive/main.go:5143`, loop at - `src/cmd/hive/main.go:5164`, evaluation at `src/cmd/hive/main.go:5798`, - message assembly via `sched.BuildKickMessages` at `src/cmd/hive/main.go:6076` - (`src/pkg/scheduler/kickmessage.go:130`), and delivery via - `agentMgr.SendKick` at `src/cmd/hive/main.go:6153`. + `deps.newTicker(initialEvalInterval)` (from `EvalIntervalS`) at `src/cmd/hive/main.go:5883`, loop at + `src/cmd/hive/main.go:6120`, evaluation at `src/cmd/hive/main.go:6972`, + message assembly via `sched.BuildKickMessages` at `src/cmd/hive/main.go:7136` + (`src/pkg/scheduler/kickmessage.go:254`), and delivery via + `agentMgr.SendKick` at `src/cmd/hive/main.go:7224`. This matters for the RFC: the scheduler is already **stateless with respect to turns**. It does not hold a continuation, does not await turn *N* before @@ -159,7 +159,7 @@ CLI subprocess. | Token-usage summary | `/data/token-summary.json` | `src/pkg/tokens/collector.go:194`, `:112-116` | | Structured audit trail | `/data/audit.jsonl`, reloaded into a ring at boot | `src/pkg/dashboard/audit.go:22`, `loadFromDisk` `:94` | | Agent-name β†’ UID allocation | durable source `/data/.hive/uid-map.json`, runtime copy `/var/run/hive/uid-map.json` | `PersistedUIDMapPath` / `UIDMapPath` `src/pkg/agent/uidmap.go`; load `src/pkg/agent/manager.go` | -| Backend CLI's own session/credential files | the CLI's own `HOME` / `CODEX_HOME`, rooted at `/data/home` | per-agent `CODEX_HOME` `src/pkg/agent/manager_env.go:400`, helper `src/pkg/agent/manager_homes.go:56`; per-agent HOME `src/pkg/agent/interactive_home.go:59`; shared `.claude` bridged by symlink `interactive_home.go:74` | +| Backend CLI's own session/credential files | the CLI's own `HOME` / `CODEX_HOME`, rooted at `/data/home` | per-agent `CODEX_HOME` `src/pkg/agent/manager_env.go:510`, helper `src/pkg/agent/manager_homes.go:56`; per-agent HOME `src/pkg/agent/interactive_home.go:29`; shared `.claude` bridged by symlink `interactive_home.go:67` | Two caveats on that table: @@ -175,33 +175,33 @@ Two caveats on that table: ### 2.2 In-process β€” lost on restart All of the following are fields of `AgentProcess` -(`src/pkg/agent/manager.go:203`) or goroutines owned by the manager. None is +(`src/pkg/agent/manager.go:208`) or goroutines owned by the manager. None is written anywhere. | State | Field / mechanism | Citation | |---|---|---| -| Live output ring buffer | `OutputBuffer *RingBuffer` β€” pure in-memory circular slice, no persistence path | `src/pkg/agent/manager.go:239`; `src/pkg/agent/ringbuffer.go` | -| Last captured pane content | `lastPaneCapture []string`, guarded by `paneMu` | `src/pkg/agent/manager.go:232-233` | -| Activity clock | `LastPaneChange` (guarded by `paneMu`) | `src/pkg/agent/manager.go:304` | -| Most recent kick text | `LastKickMessage` β€” replaced on every kick; the durable copy is truncated (`KickRecord.Snippet`) or lives in prompt-history | `src/pkg/agent/manager.go:235`; explicitly called out at `src/pkg/dashboard/prompt_history.go:26-31` | -| tmux session/socket identity | `tmuxSession`, `tmuxSocket` | `src/pkg/agent/manager.go:240-241` | -| Per-launch cancel func and launch generation | `cancel context.CancelFunc`, `launchGen int` | `src/pkg/agent/manager.go:242`, `:320` | -| Launch-serialization flag | `launching bool` (guarded by `m.mu`) | `src/pkg/agent/manager.go:252` | -| One-shot bootstrap override | `BootstrapOverride` | `src/pkg/agent/manager.go:253` | -| Token-restart backoff ladder | `lastTokenRestart`, `tokenRestartAttempts`, `tokenRestartGaveUp` | `src/pkg/agent/manager.go:287`, `:284`, `:287` | -| Login / quota observations | `NeedsLogin`, `QuotaExhausted` | `src/pkg/agent/manager.go:288-289` | -| Consent-screen watcher timers | `consentSeenAt`, `lastConsentDismiss` | `src/pkg/agent/manager.go:305-306` | -| Stall-watchdog per-kick state | `lastInferKickAt`, `lastInferKickPane`, `stallNudgeSent`, `lastInferKickMarks`, `actionNudgeSent` | `src/pkg/agent/manager.go:341`, `:321`, `:341` | -| Transient-API-error nudge cooldown | `lastTransientNudge`, `transientNudgesThisKick` | `src/pkg/agent/manager.go:317-318` | -| Un-archived-scrollback flag | `kickLogPending` (guarded by `m.mu`) | `src/pkg/agent/manager.go:334` | -| Sandbox / bob-key latches, last launch banner | `sandboxResumeAfterCancel`, `awaitingBobKey`, `lastLaunchFailureBanner` | `src/pkg/agent/manager.go:388`, `:361`, `:388` | -| Poller goroutines themselves | `go m.pollTmuxOutputForAgent(agent, agentCtx)` and siblings, tied to a per-launch context | `src/pkg/agent/manager_launch.go:276`, `:376`, `:279` | -| Blocked-action thrash windows | `Manager.thrash map[string]*thrashState` under its own `thrashMu` β€” deliberately *not* `m.mu`, to avoid re-entrancy from the output-capture goroutines | `src/pkg/agent/manager.go:440-444`; `thrashState` `src/pkg/agent/manager_thrash.go:35`; trip logic `recordBlockedAndCheck` `src/pkg/agent/manager_thrash.go:81` | +| Live output ring buffer | `OutputBuffer *RingBuffer` β€” pure in-memory circular slice, no persistence path | `src/pkg/agent/manager.go:252`; `src/pkg/agent/ringbuffer.go` | +| Last captured pane content | `lastPaneCapture []string`, guarded by `paneMu` | `src/pkg/agent/manager.go:253` | +| Activity clock | `LastPaneChange` (guarded by `paneMu`) | `src/pkg/agent/manager.go:366` | +| Most recent kick text | `LastKickMessage` β€” replaced on every kick; the durable copy is truncated (`KickRecord.Snippet`) or lives in prompt-history | `src/pkg/agent/manager.go:259`; the prompt-history store is contrasted with the 120-character snippet at `src/pkg/dashboard/prompt_history.go:186-188` | +| tmux session/socket identity | `tmuxSession`, `tmuxSocket` | `src/pkg/agent/manager.go:266-267` | +| Per-launch cancel func and launch generation | `cancel context.CancelFunc`, `launchGen int` | `src/pkg/agent/manager.go:268`, `:404` | +| Launch-serialization flag | `launching bool` (guarded by `m.mu`) | `src/pkg/agent/manager.go:278` | +| One-shot bootstrap override | `BootstrapOverride` | `src/pkg/agent/manager.go:283` | +| Token-restart backoff ladder | `lastTokenRestart`, `tokenRestartAttempts`, `tokenRestartGaveUp` | `src/pkg/agent/manager.go:303`, `:322`, `:325` | +| Login / quota observations | `NeedsLogin`, `QuotaExhausted` | `src/pkg/agent/manager.go:344`, `:351` | +| Consent-screen watcher timers | `consentSeenAt`, `lastConsentDismiss` | `src/pkg/agent/manager.go:367-368` | +| Stall-watchdog per-kick state | `lastInferKickAt`, `lastInferKickPane`, `stallNudgeSent`, `lastInferKickMarks`, `actionNudgeSent` | `src/pkg/agent/manager.go:369-372`, `:405`, `:424` | +| Transient-API-error nudge cooldown | `lastTransientNudge`, `transientNudgesThisKick` | `src/pkg/agent/manager.go:388-389` | +| Un-archived-scrollback flag | `kickLogPending` (guarded by `m.mu`) | `src/pkg/agent/manager.go:417` | +| Sandbox / bob-key latches, last launch banner | `sandboxResumeAfterCancel`, `awaitingBobKey`, `lastLaunchFailureBanner` | `src/pkg/agent/manager.go:435`, `:444`, `:471` | +| Poller goroutines themselves | `go m.pollTmuxOutputForAgent(agent, agentCtx)` and siblings, tied to a per-launch context | `src/pkg/agent/manager_launch.go:246`, `:249`, `:256` | +| Blocked-action thrash windows | `Manager.thrash map[string]*thrashState` under its own `thrashMu` β€” deliberately *not* `m.mu`, to avoid re-entrancy from the output-capture goroutines | `src/pkg/agent/manager.go:567-571`; `thrashState` `src/pkg/agent/manager_thrash.go:65`; trip logic `recordBlockedAndCheck` `src/pkg/agent/manager_thrash.go:104` | Note the asymmetry: several counters that exist precisely to *stop a runaway loop* (`tokenRestartAttempts`, `transientNudgesThisKick`, `stallNudgeSent`) are in-process only. A restart resets each of them to zero. The watchdog's own -backoff ladder is persisted (`src/pkg/watchdog/reconciler.go:397`) specifically +backoff ladder is persisted (`src/pkg/watchdog/reconciler.go:374`) specifically so that it doesn't have this property; the manager-side nudge counters were not given the same treatment. This is an observation about current behaviour, not a bug report β€” see Open questions. @@ -209,7 +209,7 @@ bug report β€” see Open questions. ### 2.3 tmux state, and the one accidental survival path tmux is a separate process tree with its own server per agent socket -(`src/pkg/agent/manager_tmux.go:94` builds `tmux -L `). What tmux holds β€” +(`src/pkg/agent/manager_tmux.go:118` builds `tmux -L `). What tmux holds β€” the running CLI process and its scrollback β€” is therefore outside hive's memory and *can* outlive a hive process restart. @@ -224,31 +224,31 @@ if !agent.forceRelaunch && m.tmuxPaneHasCLIForAgent(agent) { return nil } ``` -(`src/pkg/agent/manager_launch.go:259-289`) +(`src/pkg/agent/manager_launch.go:235-281`) Three things about this are worth stating precisely: - **It is one of two reattach points, and both are early returns rather than a recovery routine.** The other is `ensureTmuxSession` - (`src/pkg/agent/manager_tmux.go:249`), whose first act is + (`src/pkg/agent/manager_tmux.go:285`), whose first act is `if m.tmuxSessionExistsForAgent(agent) { return nil }` - (`src/pkg/agent/manager_tmux.go:250-252`) β€” a surviving session is reused, not + (`src/pkg/agent/manager_tmux.go:286-288`) β€” a surviving session is reused, not recreated. Boot reaches both: `main` unconditionally calls `agentMgr.Start(ctx, name)` for every enabled agent (`src/cmd/hive/main.go:3674`) and the reuse-vs-relaunch decision is taken inside. There is no `Adopt`, `Reattach`, or `RecoverAgents` function; searching for one finds only `RestoreBreaker` - (`src/pkg/agent/manager_pause.go:384`), which restores control metadata and is + (`src/pkg/agent/manager_pause.go:382`), which restores control metadata and is explicitly documented as *not* touching agent state: "a boot restore must never change agent state, only reattach the breaker" - (`src/pkg/agent/manager_pause.go:381-383`). + (`src/pkg/agent/manager_pause.go:379-380`). Because reattachment is emergent from two independent early returns rather than an explicit path, nothing in the codebase names it, tests it end to end, or reports whether it happened. The only trace is a log line - (`src/pkg/agent/manager_launch.go:260`). + (`src/pkg/agent/manager_launch.go:236`). - **The adoption test is a screen-scrape.** `tmuxPaneHasCLIForAgent` - (`src/pkg/agent/manager_tmux.go:397`) is one line: `paneHasCLIMarker( + (`src/pkg/agent/manager_tmux.go:441`) is one line: `paneHasCLIMarker( m.captureVisiblePaneForAgent(agent))`. Hive decides whether an agent's conversation is still alive by pattern-matching rendered terminal output. - **It does not survive the deployment's actual restart mode.** In hosted @@ -256,7 +256,7 @@ Three things about this are worth stating precisely: destroys every tmux server in the container. The `kick_logs.go` header states this outright: graceful shutdown must archive scrollback because "a pod roll or hive image upgrade … destroys every tmux server in the container" - (`src/pkg/agent/kick_logs.go:17-19`, `:246`). + (`src/pkg/agent/kick_logs.go:19-21`, `:334-336`). So the reattach path helps only when the hive **process** restarts inside a surviving **container**. For the case the RFC names β€” spoke rolls β€” it does not @@ -266,33 +266,33 @@ apply. ## 3. What a restart actually does to a turn -`Restart` (`src/pkg/agent/manager_restart.go:668`) does not resume anything. In order: +`Restart` (`src/pkg/agent/manager_restart.go:722`) does not resume anything. In order: 1. `C-c` into the pane and cancel the launch context - (`src/pkg/agent/manager_restart.go:722-726`). + (`src/pkg/agent/manager_restart.go:838-843`). 2. Archive the scrollback *before* it is destroyed β€” `tearDownTurnLocked(agent, "restart")` - (`src/pkg/agent/manager_restart.go:394`), with the comment that `kill-session` + (`src/pkg/agent/manager_restart.go:849`), with the comment that `kill-session` destroys the only record of the previous run - (`src/pkg/agent/manager_restart.go:728-731`). + (`src/pkg/agent/manager_restart.go:845-848`). 3. Reap the CLI process and any UID-owned helpers - (`src/pkg/agent/manager_restart.go:749-753`). -4. `tmux kill-session` (`src/pkg/agent/manager_restart.go:760`). + (`src/pkg/agent/manager_restart.go:866-876`). +4. `tmux kill-session` (`src/pkg/agent/manager_restart.go:878`). The replacement CLI starts with **no prompt at all** in the default case. -`buildBootstrapPrompt` (`src/pkg/agent/manager_env.go:17`) unconditionally returns `""` -(`src/pkg/agent/manager_env.go:25`), with the reasoning recorded inline: the +`buildBootstrapPrompt` (`src/pkg/agent/manager_env.go:19`) unconditionally returns `""` +(`src/pkg/agent/manager_env.go:27`), with the reasoning recorded inline: the governor's first eval cycle kicks all due agents with fully substituted templates, whereas sending a boot prompt here leaked unsubstituted `${ISSUE_LIST}` -placeholders to the agent (`src/pkg/agent/manager_env.go:18-23`). +placeholders to the agent (`src/pkg/agent/manager_env.go:20-25`). So a restarted agent sits idle at its input prompt until the shared governor ticker (Β§1.4) next finds it due. Only the explicit override paths supply text -directly: `RestartWithBootstrap` (`src/pkg/agent/manager_restart.go:371`) sets +directly: `RestartWithBootstrap` (`src/pkg/agent/manager_restart.go:419`) sets `BootstrapOverride`, and `RestartThenSendKick` -(`src/pkg/agent/manager_restart.go:451`) is documented as restarting "with a clean +(`src/pkg/agent/manager_restart.go:505`) is documented as restarting "with a clean slate". Whichever path runs, delivery is gated on the new pane reaching its -input marker (`deliverStartupKick`, `src/pkg/agent/manager_kick.go:410`). +input marker (`deliverStartupKick`, `src/pkg/agent/manager_kick.go:453`). **Restart is a reset, not a resume.** Whatever the agent had established in its conversation β€” plan, working context, partial tool results β€” is gone, and the @@ -308,7 +308,7 @@ that a restart happened or that earlier work exists. Its input is an `Observation` (`src/pkg/watchdog/classify.go:47`) whose substantive fields are `Pane string` β€” the visible pane, deliberately excluding scrollback so "stale markers in scroll history must not vouch for a dead CLI" -(`src/pkg/watchdog/classify.go:51-52`) β€” plus boolean verdicts derived from that +(`src/pkg/watchdog/classify.go:52-53`) β€” plus boolean verdicts derived from that pane and a `LastChange` timestamp from the poller. It classifies each pane into one `PaneClass` @@ -322,15 +322,15 @@ literal CLI UI chrome β€” `"Login expired"`, `"Please run /login"` (`src/pkg/watchdog/classify.go:99-104`), `"[Next]"`, `"Choose an accent"` (`src/pkg/watchdog/classify.go:110-119`). -Its action is `r.fleet.Restart(ctx, name)` (`src/pkg/watchdog/reconciler.go:650`, -via `planRestartLocked` at `:565` and `restartDetached` at `:646`) β€” i.e. +Its action is `r.fleet.Restart(ctx, name)` (`src/pkg/watchdog/reconciler.go:661`, +via `planRestartLocked` at `:576` and `restartDetached` at `:657`) β€” i.e. exactly the destructive path in Β§3. **What the watchdog does about lost in-process state: nothing, and by design it cannot.** It restores its *own* ladder across restarts -(`Snapshot`/`Restore`, `src/pkg/watchdog/reconciler.go:397` and `:397`), so it +(`Snapshot`/`Restore`, `src/pkg/watchdog/reconciler.go:374` and `:408`), so it "neither forgets a crash-loop nor re-runs a backoff ladder from the top" -(`src/pkg/watchdog/reconciler.go:389-390`). It has no mechanism to restore the +(`src/pkg/watchdog/reconciler.go:401-402`). It has no mechanism to restore the agent's conversation, because none exists to call. From the watchdog's point of view a restarted agent is a fresh agent that happens to keep its name and its failure count. @@ -348,13 +348,13 @@ RFC. ### 5.1 Hive does not own the conversation, and cannot read it Every supported backend is an opaque interactive subprocess. Hive composes a -command line as a string (`src/pkg/agent/manager_launch.go:861`, -`src/pkg/agent/manager_launch.go:865`, `:888`), +command line as a string (`src/pkg/agent/manager_launch.go:849`, +`src/pkg/agent/manager_launch.go:859`, `:888`), types it into a shell, and from then on interacts only through keystrokes in and rendered characters out. Hive sets `HOME` and a per-agent `CODEX_HOME` -(`src/pkg/agent/manager_env.go:400`, helper at `src/pkg/agent/manager_homes.go:56`) so +(`src/pkg/agent/manager_env.go:510`, helper at `src/pkg/agent/manager_homes.go:56`) so that each agent's CLI writes its session files somewhere hive controls the *location* of. That is location control, not format control: nothing in `src/pkg/agent/` parses, writes, or migrates a backend session file. @@ -380,15 +380,14 @@ It is easy to mistake either for one; neither is. - `/data/prompt-history.jsonl` holds the **input half only**. It is the fully-expanded prompt text of every kick - (`src/pkg/dashboard/prompt_history.go:45`, `:365`). The file's own header - explains it exists because the alternatives β€” in-memory `LastKickMessage` and + (`src/pkg/dashboard/prompt_history.go:24`, `:361`). The alternatives β€” in-memory `LastKickMessage` and the 120-character `KickRecord.Snippet` β€” could not answer "what was my agent - asked to do?" (`src/pkg/dashboard/prompt_history.go:22-35`). No model + asked to do?"; the store's own comment still contrasts itself with the 120-character snippet (`src/pkg/dashboard/prompt_history.go:186-188`). No model response, tool call, or tool result is recorded. - `/data/logs/kicks/…` holds **rendered terminal text**, captured with `tmux capture-pane -p -J` (`src/pkg/agent/terminal.go:107-109`). It is a human debugging artifact, retained to 10 files / 64 MiB per agent - (`src/pkg/agent/kick_logs.go:51`, `:60`). It contains ANSI-rendered, + (`src/pkg/agent/kick_logs.go:28`, `:39`). It contains ANSI-rendered, width-wrapped, spinner-animated output with no message boundaries and no role structure. It is not machine-replayable into any backend. @@ -399,14 +398,14 @@ screen looked like*, and still cannot reconstruct *what the model's state was*. Whatever shape a handoff envelope takes, hive has a working pattern for it: `snapshot.PersistedState` (`src/pkg/snapshot/state.go:16`), saved atomically via -a `.tmp` + rename (`src/pkg/snapshot/state.go:172`), aged out at seven days +a `.tmp` + rename (`src/pkg/snapshot/state.go:179-190`), aged out at seven days (`src/pkg/snapshot/state.go:14`), restored at boot by `restoreAgentRuntimeState` (`src/cmd/hive/state_restore.go`). The watchdog's -`PersistedAgent` (`src/pkg/watchdog/reconciler.go:205`) shows how a subsystem +`PersistedAgent` (`src/pkg/watchdog/reconciler.go:216`) shows how a subsystem contributes a durable subset of its in-memory record to that envelope without persisting the whole thing, including the judgement call about *which* fields are safe to restore under which mode -(`src/pkg/watchdog/reconciler.go:394-400`). +(`src/pkg/watchdog/reconciler.go:401-407`). If step 2 externalizes manager-side state, this is the mechanism to extend rather than a new one to invent. @@ -415,10 +414,10 @@ rather than a new one to invent. This is the load-bearing constraint behind all of the above and is worth isolating. Turn completion (Β§1.3), liveness classification (Β§4), stall -detection (`src/pkg/agent/manager_kick.go:805`), auth failure +detection (`src/pkg/agent/manager_kick.go:878`), auth failure (`src/pkg/watchdog/classify.go:99-104`), quota exhaustion -(`src/pkg/agent/manager.go:295`), and even whether a surviving CLI can be -adopted (`src/pkg/agent/manager_tmux.go:397`) are all decided by matching substrings +(`src/pkg/agent/manager.go:351`), and even whether a surviving CLI can be +adopted (`src/pkg/agent/manager_tmux.go:441`) are all decided by matching substrings against rendered terminal output. A structured turn return value β€” the RFC's testability and subagent-sync @@ -539,11 +538,11 @@ Things this spike did not establish, and what would settle each. Settling it requires per-backend version-pinned testing against the actual binaries, since an undocumented on-disk format is not a contract. 2. **Should the manager-side nudge and restart counters join the persisted - envelope?** `tokenRestartAttempts` (`src/pkg/agent/manager.go:284`), - `transientNudgesThisKick` (`src/pkg/agent/manager.go:318`), and - `stallNudgeSent` (`src/pkg/agent/manager.go:310`) are loop-breakers that + envelope?** `tokenRestartAttempts` (`src/pkg/agent/manager.go:322`), + `transientNudgesThisKick` (`src/pkg/agent/manager.go:389`), and + `stallNudgeSent` (`src/pkg/agent/manager.go:372`) are loop-breakers that reset to zero on restart, while the watchdog's equivalent ladder is - deliberately persisted (`src/pkg/watchdog/reconciler.go:389-390`). Whether the + deliberately persisted (`src/pkg/watchdog/reconciler.go:401-402`). Whether the difference is intentional (these counters are per-launch by design, and a fresh launch legitimately deserves a fresh budget) or an oversight is not determinable from the code alone and was not raised as a defect by this @@ -558,11 +557,11 @@ Things this spike did not establish, and what would settle each. *Since answered: dead code.* The function used to assemble a candidate policy-file list and then discard it by returning `""`; the path construction has since been deleted, and the function is now an explained - `return ""` stub (`src/pkg/agent/manager_env.go:17-25`, `return ""` at - `src/pkg/agent/manager_env.go:25`), with the removal recorded in its comment. + `return ""` stub (`src/pkg/agent/manager_env.go:19-27`, `return ""` at + `src/pkg/agent/manager_env.go:27`), with the removal recorded in its comment. 5. **Are there non-tmux agent execution paths with different properties?** `src/pkg/agent/sandbox_executor.go` runs a different shape of execution - (`src/pkg/agent/sandbox_executor.go:353` composes its own command line) and + (`src/pkg/agent/sandbox_executor.go:405` composes its own command line) and inference backends are handled separately (`IsInferenceBackend`, `src/pkg/agent/manager_routing.go:23`). This spike scoped itself to the tmux CLI path, which is the fleet's normal mode; the sandbox diff --git a/src/docs/env-vars.md b/src/docs/env-vars.md index 1582095ccc..14378ecbc5 100644 --- a/src/docs/env-vars.md +++ b/src/docs/env-vars.md @@ -422,6 +422,8 @@ With two or more providers configured, `/login` renders a provider picker; with |---|---:|---|---| | `HIVE_HUB` | Required after registration for contributor relay; `just` can discover/set it | `wss://hive.hivecommons.dev/contribute` (the `Justfile` default; the legacy `wss://hive.kubestellar.io/contribute` value is treated as unset and triggers the hive lookup) | Contributor WebSocket hub URL. Comma-separated values are supported with matching `HIVE_REGISTRATION_TOKEN` entries. | | `HIVE_REGISTRATION_TOKEN` | Yes for contributor relay | none | Contributor registration token. Comma-separated values match `HIVE_HUB` by position. | +| `HIVE_REPO_SCOPE` | No | none (written by `hivectl`) | Per-hub repository scope projected into `contributor.env` by `hivectl` profiles, aligned with `HIVE_HUB` by position and separated by `,`; each entry is `*` (legacy profile, unrestricted), empty (no repositories), or that hub's canonical repository IDs joined by `;`. The value is single-quoted in the file; the relay's env-file parser strips the quotes. Split on `,` first, then `;`. See [hivectl](hivectl.md#hives--named-profiles-for-the-hives-you-contribute-to). | +| `HIVE_REPO_SCOPE_VERSION` | No | none (written by `hivectl`) | Monotonic counter incremented on every profile-store commit; lets the relay detect that `HIVE_REPO_SCOPE` changed. | | `HIVE_COMMONS_STRATEGY` | No | `ranked` | Multi-hive routing strategy for The Commons. `ranked` keeps strict rank order and falls through only when a hive has no work; `spread` does rank-weighted rotation with periodic mixing; `neediest` scores subscribed hubs by `actionable_items` plus idle contributor capacity from `/api/contribute/status`. Read between tasks only. | | `HIVE_COMMONS_SPREAD_MIX_EVERY` | No | `7` | In `spread`, choose randomly from the weighted rank cycle every N completed/failed tasks. `0` disables random mixing. | | `HIVE_COMMONS_NEEDIEST_REFRESH_MS` | No | `60000` | In `neediest`, milliseconds between `/api/contribute/status` refreshes per subscribed hub. `0` disables the interval. | diff --git a/src/docs/fleet-report.md b/src/docs/fleet-report.md index 470e7b2b1c..6ad3db5485 100644 --- a/src/docs/fleet-report.md +++ b/src/docs/fleet-report.md @@ -83,9 +83,9 @@ title is `[selfreport] : `. | Class | Fires when | Severity | |---|---|---| | `restart_storm` | the restart-storm breaker pauses an agent | high | -| `kick_failed_repeatedly` | β‰₯3 consecutive undeliverable kicks for one agent | medium | +| `kick_failed_repeatedly` | β‰₯3 consecutive undeliverable kicks for one agent *after* the busy-no-activity stall restart has been attempted (or that restart itself errored) β€” i.e. the recovery did not bring the session back; kicks that fail before the restart are a dashboard/log warning only | medium | | `panic_recovered` | a dashboard handler recovered from a panic | high | -| `model_discovery_degraded` | model discovery has been failing for >1h | medium | +| `model_discovery_degraded` | model discovery has been failing for >1h (excluded, audit-log only: copilot serving the HTTP catalog because the SDK helper is absent (#11868), and copilot `Not authenticated` because the operator has not completed the dashboard Copilot Login (#12947)) | medium | | `inference_auth_failed` | 401/403 from an inference backend (LiteLLM, Copilot) | high | | `advisory_post_failed` | the advisory digest could not be posted (rate-limit skips excluded) | medium | diff --git a/src/docs/github-write-surface.md b/src/docs/github-write-surface.md index 29bcdbac34..11513c14b4 100644 --- a/src/docs/github-write-surface.md +++ b/src/docs/github-write-surface.md @@ -84,7 +84,8 @@ as fact (`issue_request_label.go`): - the labels that record a person's decision or an escalation: `approved-direction`, `design-approved`, `needs-human`, `blocked`, the retired `needs-decision` / `needs-direction`, and `stage/*` (an agent may - only *add* `stage/085-needs-decision`, the park-for-a-ruling signal). + only *add* the park-for-a-ruling signals: `stage/045-needs-decision` on an + issue, `stage/085-needs-decision` on a PR). An agent parks an issue for the owner with the `--needs-decision` flag instead: on a create, or on `comment` for an existing issue whose remaining diff --git a/src/docs/good-first-issue.md b/src/docs/good-first-issue.md index eff15e41b2..d7f1b356ac 100644 --- a/src/docs/good-first-issue.md +++ b/src/docs/good-first-issue.md @@ -77,7 +77,7 @@ first failure is the `gfi skip reason=…` logged: | Pull request wearing the issue shape | `pull-request` | | Live claimant (`hive:claim`, assignee) | `claimed`, `assigned` | | An open PR already references the issue | `linked-pr` | -| Hard-suppress label (`needs-human`, `stage/085-needs-decision`, `needs-spec`, or the retired `needs-direction` / `needs-decision`) β€” always excluded, whatever `exclude_labels` says; `needs-human` next to `approved-direction` still excludes ([#12680](https://github.com/hivecommons/hive/issues/12680)) | `hard-suppress: