Dispatch can run an optional hosted issue groomer that calls an OpenAI-compatible LLM endpoint and updates one GitHub issue per invocation.
The hosted groomer is intentionally narrow:
- It enriches issue labels, lane, grooming metadata, and optionally one GitHub comment.
- It runs at most one issue per request.
- It does not edit code, open PRs, merge PRs, or run shell commands. It closes an issue only when its plan's verdict is
already_doneat high confidence with current-revision evidence that proves that issue's own acceptance (see Close policy). - Before writing anything it re-checks that the issue and default branch still match the evidence the plan was built on, and it applies each plan at most once (see Applying a plan).
- External
next-task?mode=groomtask dispatch is retired (#1200). Hosted grooming remains available throughPOST /api/groomer/run.
The feature is disabled by default.
| Variable | Default | Description |
|---|---|---|
DISPATCH_HOSTED_GROOMER_ENABLED |
false |
Enables POST /api/groomer/run when set to true or 1. |
DISPATCH_LLM_BASE_URL |
required when enabled | OpenAI-compatible base URL, without /chat/completions. |
DISPATCH_LLM_API_KEY |
required when enabled | LLM provider API key. |
DISPATCH_GROOMER_MODEL |
required when enabled | Model sent to the chat completions API. Must be set explicitly when the hosted groomer is enabled. |
DISPATCH_GROOMER_TIMEOUT_MS |
Scaled | LLM request timeout. Defaults to 60s + 5s/KB of maxContextBytes, clamped to 60s–300s. |
DISPATCH_GROOMER_MAX_CONTEXT_BYTES |
8192 |
Budget for issue context sent to the model. |
DISPATCH_GROOMER_DRY_RUN |
true |
Keeps rollout safe by returning a mutation plan without writes. Legacy boolean view of DISPATCH_GROOMER_MODE. |
DISPATCH_GROOMER_MODE |
derived | Off/observe/auto rollout mode (#1542): observe runs the full assessment with zero external writes and records a would-disposition receipt; auto writes; off disables. Unset maps from DISPATCH_GROOMER_DRY_RUN (true → observe, false → auto); an explicit mode wins over the dry-run boolean; an unrecognized value warns once and falls back to the dry-run mapping. |
DISPATCH_LLM_RESPONSE_FORMAT |
true |
Send response_format (json_schema, then json_object). Set false for backends that implement neither. |
DISPATCH_GROOMER_REPO_CONTEXT_ENABLED |
false |
Enables bounded GitHub API repository context. When true, the groomer gathers repository metadata, code-search snippets, and file text through GitHub REST APIs only — it never clones repositories or runs shell commands. |
DISPATCH_GROOMER_MAX_CONTEXT_FILES |
5 |
Maximum number of files included in repository context. |
DISPATCH_GROOMER_MAX_SEARCHES |
3 |
Maximum GitHub code searches per grooming run. |
DISPATCH_GROOMER_MAX_FILE_BYTES |
4096 |
Maximum bytes per fetched file snippet. |
DISPATCH_GROOMER_COMMENT_COOLDOWN_HOURS |
24 |
Suppresses repeated hosted-groomer comments on the same issue. A comment is skipped (and recorded on the run) when a run that recorded a comment was last updated within this window, or a hosted-groomer comment (found by its hidden marker) was posted on the issue within it, unless force is true. |
DISPATCH_GROOMER_TOOL_LOOP_ENABLED |
true |
Lets the groomer drive its own repository exploration with tools (search_code, read_file, list_directory, submit_findings) instead of one pre-computed context block. |
DISPATCH_GROOMER_MAX_ROUNDS |
12 |
Model round-trips the exploration loop may make. One round can carry several tool calls, so this is not a cap on calls. DISPATCH_GROOMER_MAX_TOOL_CALLS is accepted as a deprecated alias. |
DISPATCH_GROOMER_MAX_SEARCH_RESULTS |
10 |
Maximum code-search results returned to the model per search_code call. |
DISPATCH_GROOMER_MAX_DIR_ENTRIES |
60 |
Maximum directory entries returned to the model per list_directory call. |
DISPATCH_GROOMER_CONTEXT_MODE |
medium |
Exploration budget preset: small, medium, large. See below. |
DISPATCH_GROOMER_MODEL_CONTEXT_TOKENS |
unset | The model's real context window in tokens. When set, the exploration budget is derived from it and DISPATCH_GROOMER_CONTEXT_MODE is ignored. The derived budget has no fixed byte cap, and it never exceeds the share of the window reserved for exploration. |
DISPATCH_GROOMER_EXPLORE_MAX_BYTES |
from mode | Overrides the exploration byte budget. |
DISPATCH_GROOMER_EXPLORE_MAX_FILE_BYTES |
from mode | Overrides bytes per file returned to the model. Never exceeds the total budget. |
DISPATCH_GROOMER_EXPLORE_TIMEOUT_MS |
from mode | Overrides the wall-clock cap on the exploration loop. |
DISPATCH_GROOMER_TOKEN |
unset | Optional bearer token for scheduled or admin groomer invocations. When set, POST /api/groomer/run accepts this token in addition to DISPATCH_AGENT_TOKEN. |
DISPATCH_GROOMER_TRUSTED_LOGINS |
empty | Comma- or newline-separated GitHub logins treated as trusted participants for the external-participant reply gate. |
DISPATCH_GROOMER_EXTERNAL_REPLIES |
pending |
pending queues externally-engaged comments for operator approval; off suppresses them without queueing. |
DISPATCH_GROOMER_INTERVAL_MS |
600000 | Interval for the in-process scheduler's groomer job. Dispatch still processes at most one issue per run. |
The hosted groomer's public-comment side effect is gated independently from its ordinary grooming writes. Issue labels, status, and other validated grooming changes can still apply, but an externally-engaged issue never receives an autonomous public groomer comment; the proposed reply is held for an operator to review and approve.
Trust is resolved per issue author and commenter. Dispatch's internal automation authors are trusted, as are logins explicitly listed in DISPATCH_GROOMER_TRUSTED_LOGINS. author_association values OWNER, MEMBER, and COLLABORATOR are only trusted after a live repository collaborator-permission lookup confirms admin, maintain, or write. Every other association and permission is untrusted. Missing logins, unknown associations, lookup errors, and any other failure fail closed as untrusted. The issue is externally engaged when any participant is untrusted.
DISPATCH_GROOMER_EXTERNAL_REPLIES controls the held-comment behavior: pending (the default) creates a pending approval item, while off suppresses the comment without queueing it. DISPATCH_GROOMER_TRUSTED_LOGINS accepts comma- or newline-separated GitHub logins; configure it only for accounts that operators intend to trust.
Operators can review pending replies in the Hosted Groomer page or use GET /api/groomer/pending-replies, then call POST /api/groomer/pending-replies/{id}/approve or POST /api/groomer/pending-replies/{id}/dismiss. Approval posts the saved reply with its idempotency marker; dismissal records the operator decision without posting. Both mutation endpoints require operator authentication: an OIDC session, basic auth, or auth-disabled mode. Any bearer token is rejected, including DISPATCH_GROOMER_TOKEN (401) and maintainer-tier DISPATCH_AGENT_TOKEN (403); bearer tokens cannot approve or dismiss a held reply. Audit actions include groomer_reply_held, groomer_reply_suppressed, groomer_reply_approved, and groomer_reply_dismissed.
External grooming retired (#1200): GET /api/agents/<name>/next-task?mode=groom returns HTTP 410 Gone and never hands out a task. The Hosted Groomer page calls POST /api/groomer/run directly. Disable legacy external pollers and revoke their unnecessary GitHub write credentials; removing task discovery cannot revoke independently held credentials.
Repository exploration is a multi-turn tool loop, so it needs a budget of its own rather than the single-call one. Three ways to size it, most specific first:
- the individual
DISPATCH_GROOMER_EXPLORE_*overrides, DISPATCH_GROOMER_MODEL_CONTEXT_TOKENS, which derives the budget from the model's real context window and reserves the rest for the system prompt, the issue context and the model's own output,DISPATCH_GROOMER_CONTEXT_MODE.
| Mode | Total bytes | Per file | Timeout |
|---|---|---|---|
small |
8 KB | 4 KB | 90s |
medium (default) |
24 KB | 8 KB | 150s |
large |
96 KB | 24 KB | 300s |
With two rounds left the loop tells the model to submit what it has, so a run that explores well but never volunteers findings is not discarded empty. The byte budget carries the same nudge when it runs out.
The default suits a modest self-hosted model. If your model's window is much
larger, set DISPATCH_GROOMER_MODEL_CONTEXT_TOKENS. The derived budget takes
35% of the declared window and converts it to bytes at 3.5 bytes per token.
The budget never exceeds that 35% share, so a small window gets a small
budget instead of an allowance the window cannot hold. There is no universal
byte ceiling on the derived budget. A 131,072-token window derives about
160 KB, and a 245,760-token window derives about 300 KB. The per-file cap is
a quarter of the total budget, so a large source file is not truncated while
context capacity remains. The cap never exceeds the total budget. A window
whose share is under 2 KB gives one file the whole allowance.
The model context window is the capacity boundary. Time and rounds are the
runaway-work boundaries, and they stay bounded. The derived timeout is 60s
plus 5s per KB, clamped to 300s, and DISPATCH_GROOMER_MAX_ROUNDS bounds the
loop. Set DISPATCH_GROOMER_EXPLORE_TIMEOUT_MS when your model needs more
wall-clock time. A starved loop stops mid-investigation and reports fewer
files, which shows up as repository exploration hit its byte budget in a
run's contextWarnings. The resolved budget and which path produced it are
recorded on every run under contextSummary.exploration.budget.
POST /api/groomer/run
Authorization: Bearer <DISPATCH_AGENT_TOKEN>
Content-Type: application/jsonOptional body:
{
"dryRun": true,
"repoFullName": "org/repo",
"issueNumber": 123,
"force": false
}dryRun overrides the environment default for a single request. mode (observe or auto) overrides the configured rollout mode for a single request. repoFullName and issueNumber target a specific synced issue. force lets the hosted groomer proceed when another active issue lease exists.
observe executes the same read/evidence/freshness path as write-mode planning — the same selector, evidence snapshot, model call, plan validation and read-only apply preconditions — but performs no GitHub or queue mutation. That includes the per-issue lease: observe reads active leases and declines (would_refuse, run recorded dry_run_completed) when another actor owns the issue, but never creates, renews or releases a lease — lease state stays untouched, unlike an auto run, which acquires and releases the fenced hosted-groomer lease. A positive decision is durably recorded on the run as observeDisposition (not merely a generic dry run):
would_act— the plan carried at least one mutation auto would have written (labels, comment, close, children, title/body).would_noop— the assessment decided but nothing external would have changed, including a plan whose application key was already applied (auto would have replayed, writing nothing new).would_refuse— the pipeline would have declined to act: in-flight status, stale or unverifiable evidence, a held reply with no other mutation, or another actor holding the issue lease.assessment_unavailable— no decision exists: the evidence snapshot could not capture the live issue, or the model stage failed.
The comment decision in a disposition is the same one the write path makes (resolveCommentGate): a comment for this application already on GitHub replays to would_noop, a held reply — from the reply gate or a prior pending reply — is would_refuse, and a groomer comment inside DISPATCH_GROOMER_COMMENT_COOLDOWN_HOURS skips the comment, so a comment-only plan inside the cooldown is would_noop, not would_act. force bypasses the cooldown in both modes.
The run record keeps the proposed mutations (mutationPlan, validatedOutput) and the evidence revision/freshness they were based on (evidenceDigest, preconditions), so an observe receipt can later be compared with what an auto run actually did. Runs carry mode (observe/auto; null on rows recorded before this contract), and GET /api/groomer/runs?mode=observe filters them. GET /api/groomer/runs/summary?windowHours=24 answers "how often would this automation act?" over a recent window (default 24h, clamped 1h–720h). The /groomer history page shows an observe badge with the disposition, a mode filter, and the 24h summary strip. Ad-hoc dry-run requests (dryRun: true, or DISPATCH_GROOMER_DRY_RUN=true) map cleanly onto observe: they record mode: "observe" and a disposition too. Switching from observe to auto is an env or per-request change only — no prompt or rule-definition change.
- Configure the LLM endpoint and keep
DISPATCH_GROOMER_DRY_RUN=true(equivalentlyDISPATCH_GROOMER_MODE=observe). - Invoke
POST /api/groomer/runand inspect the returnedplannedLabelsand model output; watch theobserveDispositionreceipts and the summary to measure precision. - Once plans look safe, set
mode=autofor a targeted request or changeDISPATCH_GROOMER_MODE=auto(or the legacyDISPATCH_GROOMER_DRY_RUN=false).
Write mode first re-validates the plan against live GitHub state, then applies only what differs from the live issue: labels, at most one comment when the model returned githubComment, a bad-title rewrite or managed body section, and the already_done close. It then updates Dispatch grooming fields and lane history, records the result's freshness baseline (see Grooming Freshness), and records AgentRun/AuditLog rows. A plan whose preconditions changed applies nothing (see Applying a plan).
The model returns a versioned GroomingPlan (src/lib/groomer/plan.ts, schema version 1), not free-form label advice. The plan keeps analysis separate from mutation intent:
| Section | Holds |
|---|---|
verdict |
actionability (ready, needs_info, blocked, backlog, already_done), workType (implementation or design), confidence, lane, summary, rationale, evidenceRefs, and uncertainties (each with a kind and whether it is material). |
implementationBrief |
The bounded worker brief, or null: problem, verified current behavior with its evidence, relevant paths (cited as evidence), files to create, invariants, in/out of scope, dependencies, acceptance criteria with how each is verified, and tests. |
mutations |
Priority/type labels to add or remove, proposed title/body, one comment, and an optional close recommendation (already_done, duplicate, superseded) with its evidence. |
decomposition |
Whether the issue must be split, and bounded child briefs. |
relatedWork |
Related issues/PRs/commits cited as duplicate_of, superseded_by, or related. |
The response schema sent as json_schema enum-constrains lanes (from the lane config), labels (priority/type allowlist), and every evidence id (from the run's catalog), and bounds every string and array. It names no provider or model.
Each run builds an evidence catalog from its revision-pinned snapshot and appends it to the prompt. The plan may cite only these ids:
issue: the groomed issue as captured;comment:<id>: a comment, marked human or automation;repo:<path>: a repository path from this run. It is pinned only when the file was actually read at the snapshot head SHA (repository context fetches andread_file). Code-search hits come from the default-branch index and paths the model names in its findings were never read, so both are recorded asvia: "surfaced"withref: nulland never satisfy a pinned-evidence rule;github:<kind>:<ref>: related GitHub issue/PR/commit state.
An id outside the catalog fails validation. Automation-authored comments may be cited as context but never satisfy an evidence requirement.
Dispatch derives readiness; it does not trust the model's claim. A ready verdict validates only when:
- the snapshot was captured and pinned to a default-branch head SHA;
verdict.evidenceRefscites repository evidence read at that SHA, and confidence is notlow;- no material uncertainty remains;
- for
implementationwork: the brief is present, its verified current behavior cites pinned repository evidence, it names at least one path or file to create, every relevant path it marksmodifywas read at the pinned SHA (a path only surfaced by search may have moved;referencepaths may stay surfaced, since they orient the worker rather than tell it what to change),inScopeis not empty, every acceptance criterion is verified by an automated test, a command, or code inspection (notsubjective), and no decomposition is required; - for
designwork: the lane is the one with roleescalation, and onlydesign_choiceuncertainties remain. Design work never validates into the default lane, and without an escalation lane it cannot be ready; - the lane is claimable and no close is recommended.
A ready verdict that breaks any rule is a validation error: the run fails as retryable and applies no mutation. The persisted plan records the result as readiness (ready, admission of implementation or escalation, lane, evidenceDigest), which an admission gate can check against the current evidence digest. evaluateReadiness re-runs the rules against a fresh catalog.
Other rules the validator enforces:
- Status is derived from actionability (
ready→status/ready,blocked→status/blocked,already_done→status/done, otherwisestatus/backlog); the plan cannot setstatus/*oragent/*labels. After an applied groom the derived status is the onlystatus/*label: every other one is removed, including statuses the groomer does not own (such asstatus/needs-review), as the external groom route already does. - An issue carrying
status/in-progressorstatus/in-review(claimed, or with an open PR) is never moved, including on targeted runs. Its plan is recorded on the run, withmutationPlan.skippedReason: "in_flight_status", but no label, lane, title/body, comment or close mutation is applied. OnlygroomedAtis stamped, so the 24h re-groom cooldown still applies. - A non-ready verdict is placed in the non-claimable lane, so a lane alone never promotes an issue. A ready verdict placed there is moved to the default lane (implementation) or escalation lane (design). Both moves are recorded in
contextWarnings. already_doneis the only close the runner applies; see Close policy.duplicateandsupersededcloses are recorded recommendations only; each must cite a matchingrelatedWorkentry.- A dependency state that contradicts the cited GitHub state is rejected. Dependencies are descriptive: the
depends on #Nclaim gate stays authoritative. - Output in the legacy
GroomerOutputshape is rejected with a clear error rather than migrated.
A needs_info verdict carries verdict.infoGap: a kind (product_decision, external_fact, runtime_reproduction, or investigation_incomplete), the one concrete question, and whyToolsCannotAnswer — why the repository and related-work evidence this run had cannot answer it. The run resolves the declaration against its own exploration facts and persists the class on readiness.gapClass (human_question or investigation_incomplete): failed tool calls, truncated reads, or an exhausted round/byte budget override a claimed human question into investigation_incomplete, because an investigation that never finished has not earned the right to ask. Both classes land non-claimable on status/backlog and re-groom on the existing bounded cooldown/freshness paths — no new retry channel, no schema change. The two classes part ways at the persisted disposition (dispatch#1557): a human_question maps to mark_needs_info and stores its machine-prefixed reason (human_question(<kind>):) on Issue.needsInfoReason; an investigation_incomplete maps to mark_not_ready and stores its machine-prefixed reason (investigation_incomplete:) on Issue.notReadyReason, so an incomplete run is never presented as Needs Info — what it waits on is more machine investigation, not information a human owes. The same class decides what the run shows and posts: the legacy output and the history verdict badge present an incomplete investigation as the not-ready backlog disposition (never Needs Info), and the run posts no comment for it, because a comment would ask a human to do the investigation Dispatch owes itself — only a human_question reaches a maintainer. The deferral TTL (DISPATCH_DEFERRAL_TTL_DAYS) exists to keep a model-authored deferral sentence from being irreversible; the machine-authored investigation_incomplete: prefix is carved out of that clause in the grooming-state exclusion, so the class re-grooms on the ordinary 24h cooldown rather than the 14-day deferral window. Each verdict supersedes the other's field, so a flip from a human question to an incomplete investigation (or a later successful re-groom) leaves no stale needsInfoReason behind. Pre-fix rows that stored an incomplete investigation under mark_needs_info/needsInfoReason remain readable — the stored row is untouched, and the history reads their readiness.gapClass — with no destructive migration; the next bounded re-groom rewrites the row to the not-ready disposition. An operator's manual mark_needs_info via POST /api/issues/groom stores the operator's text as given, with no prefix enforced there.
The approved scope is limited to the existing deterministic already_done policy below. Duplicate and superseded outcomes remain recommendations for human review; see Groomer Close Policy.
An already_done plan closes the issue, the highest-impact write the groomer makes. It validates only when all of these hold:
- the close has reason
already_done, verdict confidence ishigh, and no material uncertainty remains; - at least one close citation (
mutations.close.evidenceRefs) is repository content read at the pinned head SHA. The issue itself, automation comments, a merged PR, a commit or a human comment may corroborate but never meet this alone; - the evidence proves this issue's acceptance, not a sibling's, parent's or dependent's (dispatch#1099):
mutations.close.criteriamaps every acceptance criterion to{ criterion, evidenceRef, excerpt }, arepo:file read at the pinned head and an excerpt of it.- Dispatch checks each excerpt against the file's content as fetched this run: both sides have every run of whitespace collapsed to one space and are trimmed, then the excerpt must be an exact, case-sensitive substring. Re-wrapped lines pass; a paraphrase does not.
- An excerpt must be at least 24 characters after that normalisation (300 at most), and must contain at least one word that is not a common keyword or literal (
import,return,nil,if,err,trueand the like). An excerpt of only keywords, punctuation, brackets and numbers could match nearly any file, so it grounds nothing. Whether the excerpt is relevant to its criterion is still the model's judgement; this only makes trivial matches impossible. - When the issue lists acceptance criteria (list items under an
Acceptance criteriaheading or label), every one of them must appear incriteria, compared ignoring case, backticks/emphasis, spacing and a trailing stop; with no enumerable criteria, at least one grounded criterion is required. When the issue names expected files, at least one grounded criterion must cite one of them.
- evidence about other issues, and related work in general, corroborates but never satisfies the close:
- a merged pull request into the default branch whose GitHub closing reference is this exact issue is recorded as corroboration only. Such a PR closes the issue when it merges, so an issue still open afterwards was usually reopened, which is evidence it is not done;
- GitHub reopen history (
state_reason: "reopened"), an explicit authoritative human report that the issue still reproduces, or unavailable reopen history vetoes the close; a withheld close lands as backlog for human review, even when repository excerpts still match; - a changelog or release-notes file (
CHANGELOG,CHANGES,HISTORY,NEWS,RELEASE_NOTES,RELEASES, any extension) cannot ground a criterion unless the issue lists it as an expected file; - an excerpt that mentions another issue or PR (
#N,owner/repo#N, or a GitHub issue/PR URL) cannot ground a criterion.
Expected files come from the issue body as captured: an Expected files (or Affected files, Target files) section, as a heading or a label line such as Expected files: / **Expected files:**, contributes its backticked paths or each list item's leading path. Without such a section, the explicit backticked repository paths anywhere in the body are the expected files. A token counts only when it looks like a file: a path with a directory and an extension (or a well-known name such as Dockerfile), a bare file name with a known source/config extension, or a dotfile. Identifiers, config keys, owner/repo refs, URLs and directories are ignored.
Closing references come from read_related_pr (and read_related_issue when the number is a PR): for a merged PR it makes one bounded, read-only GraphQL query for closingIssuesReferences (at most 10), which covers closing keywords and manual Development links. The references and the PR's base branch are recorded on the related-work evidence and shown in the catalog, so a closing PR is visible as corroboration. If the lookup fails, the references stay unknown. Search hits never carry them.
The content excerpts are checked against is what the run's repository-context fetches and read_file calls returned at the pinned head (what the model was shown, after truncation), held in memory for the run only, capped at 1 MB, and never persisted. The catalog rendered into the prompt lists the issue's expected files and acceptance criteria so the model knows what it must ground.
A close that fails any rule is a validation error and applies nothing. The applier re-checks the whole policy, grounding included, against the run's catalog before closing (see What is applied, and in which order); a close that fails there is withheld and the plan lands as backlog.
A plan may also split the issue into bounded children instead of (or alongside) promoting it. This is an independent decision from the close and ready promotions: a plan that decomposes is withheld only when its own decomposition policy fails, never because the close or ready policy did. The policy requires, all at once:
- no close in the same plan — a decomposed parent is not closed;
- verdict confidence of at least
medium(a low-confidence split is too speculative to fan out); - no material uncertainty remaining (the same
verdict.uncertainties[].materialflag the close policy keys on); - every child brief is a complete bounded implementation brief: its
problem,designDecision, andverifiedCurrentBehaviorare all non-blank, and itsrelevantPaths,inScope,outOfScope,acceptanceCriteria, andtestseach name at least one entry (dependenciesmay be empty). A child brief that leaves any of those short — a design choice left open, no verified current behavior, no relevant paths, no out-of-scope, no acceptance criteria, no tests — is not bounded, so the whole split is withheld with the gap named inwithheld.decomposition(child brief[i] is not a complete bounded implementation brief (missing: …)). This is an apply-time gate: the validator does not police child-brief completeness (a child brief is not anImplementationBrief), so the gate lives in the decomposition policy the applier evaluates.
When it holds, each bounded child brief in decomposition.childBriefs becomes its own GitHub issue, created with the child labels (status/backlog). Child creation is idempotent: every child is keyed by a stable childBriefKey (repository, parent issue number, and the child brief) recorded in the GroomingChildClaim table, so a retried attempt reuses a child an earlier attempt already created and opens only the ones still missing. Once every child exists or is reused, the children step records the parent's decomposition state — decomposed, decomposedAt, decomposedBy: "hosted-groomer", the decomposition reason as the note, and the created child URLs as its followUpUrls — through the same setDecompositionState helper the operator POST /api/issues/actions/decompose route uses, so both paths write the state and its audit entry identically, and only then adds the umbrella label, as the step's final write (an additive addLabel, not part of the labels write). The ordering matters: the umbrella label removes the issue from groomer selection on every path (the selector excludes umbrella-labeled issues on every path, including a targeted re-groom), so it must be the step's last write; any earlier failure (a child create, the state write) keeps the parent re-selectable, so a partially applied decomposition converges on retry. Two convergence behaviors of the child-claim hold are accepted: a fresh null claim under a different application key is held as another in-flight attempt's claim, so if an LLM re-plan produces a new key shortly after a failed attempt, that child's convergence waits out the active-claim window (about 10 minutes) rather than failing forever; and if the final umbrella label add fails after the decomposition state was recorded, a later retry replays the created children but re-records the decomposition state, which appends another issue_decomposed audit entry — accepted as audit noise on an already-rare failure path.
Alongside creating the children, the step writes a managed decomposition section into the parent's body between the dispatch-groomer:decomposition:start and dispatch-groomer:decomposition:end markers — a short heading, one line that says what the section is, and one - #<n>: <url> line per created-or-reused child, in brief order. The section renders into the body as it stands after the content step, so it coexists with the managed enrichment section that step writes — each is parsed and rendered independently. The write only happens when the section content actually changed (no digests, no timestamps), so re-rendering the same children is a byte-for-byte no-op and a well-groomed parent is never churned; the step records the outcome in its detail (written, unchanged, or refused). A body whose markers a human edit broke (unpaired, repeated, or out of order) is refused rather than guessed at, and so is a rendered body that would exceed GitHub's body cap (a write that large would fail on every retry and the umbrella would never land) — in either case the step records the refusal reason in its detail, but the children, the state write, and the umbrella still land, so the decomposition converges on retry.
Three consequences of the managed section are accepted. Re-planning is last-write-wins: a fresh plan with different child briefs replaces the section and the recorded followUpUrls with the new child set, and children a previous plan already created stay open as status/backlog issues, still traceable to the parent by the Parent: backlink in their body rather than by any link on the parent. A single well-formed marker pair in the body is treated as Dispatch-owned and replaced in place — only a broken or duplicated pair (unpaired, repeated, or out of order) is refused. And because the section's fixed prose makes the parent body permanently non-sparse, a decomposed parent is never enriched afterwards: the content step's sparsity gate sees the section's text and skips the write.
A decomposition that fails any rule is withheld: no child is created and the parent is not decorated, and withheld.decomposition records why.
GroomingRun.validatedOutput stores the full plan. mutationPlan keeps its existing fields and adds planSchemaVersion, evidenceDigest, readiness, closeRecommendation, applicationKey, preconditions and willCreateChildren (true when the plan's decomposition children will be created; forced false for in-flight plans, including their dry-run previews, which apply nothing) and, when a policy withheld something, withheld. The run path applies mutations through toGroomerOutput, the legacy view that POST /api/groomer/run still returns as output (with the plan alongside as plan). A rejected plan's raw output and validationErrors are kept on the run. Runs recorded before the plan contract still render on /groomer (also reachable via /automation/groomer, which redirects there), marked legacy, with no readiness claim.
A validated plan is not written straight to GitHub. The applier (src/lib/groomer/mutation-validator.ts, src/lib/groomer/mutation-applier.ts) sits between the plan and every write.
Immediately before the first write, the run re-reads live state with the same capture path as its evidence snapshot and checks:
| Precondition | Fails when |
|---|---|
issue |
The live title, body, labels (any label, including agent/* claims) or state differ from the snapshot, the issue is no longer open, or it cannot be re-read. A snapshot that never captured the issue can never be applied. |
comments |
A human comment (non-automation author) was posted after the run's evidence window opened, or the recent comments cannot be read. |
head |
The default branch was renamed, its head cannot be resolved, or the head moved in a way that touches what the plan relies on: a commit changing a relied-on path, any commit when the plan relies on repo-wide evidence, or a comparison that cannot be trusted (diverged history, truncated file list, compare failure). A head that moved without touching the plan's evidence passes, with the same rules the freshness pass uses, and the result is recorded as verified at the new head. A snapshot with no pinned head skips this check; such a plan cannot be ready or close anyway. |
If any precondition changed or cannot be verified, the run applies zero grooming mutations: no label, comment, title/body, close, lane, grooming field or freshness baseline is written. Every check is kept in preconditions, with one line per failed check in preconditionFailures (a run stopped before planning, below, records only its one failure) (for example issue: issue changed since the evidence snapshot: body). Stale evidence is never patched through. What happens next depends on why:
- Stale (live state
changed): theGroomingRunends withstatus: "stale",stage: "validated",retryable: true,applyOutcome: "stale". Nothing on the issue moves, so it stays exactly as eligible as when this run selected it and is re-groomed promptly on the fresh evidence. - Unverifiable (a live read failed or could not be completed; this wins when both occur): the same zero-mutation record, but with
status: "unverifiable", plusIssue.groomingRetryAfterset one hour ahead. The selector skips the issue until then on every path, including the stale one, so a persistently unreadable issue (deleted upstream, or GitHub failing for it) cannot win selection every tick. Targeted runs ignore the backoff. One hour rather than the 24h cooldown, because most read failures are transient. An applied groom clears the backoff.
When the evidence snapshot fails to capture the live issue in the first place, the run stops right there, before any repository read or model call: a plan built on it could never be ready, close, or pass these preconditions. It records status: "unverifiable" and backs off the same way (a dry run records the outcome and writes nothing).
Issues that are status/in-progress or status/in-review in either Dispatch's cache or the live snapshot are still skipped before any of this (no preconditions, no application).
The diff is computed against the live issue, never Dispatch's cache, and only what differs is written, so re-applying a plan to an issue that already matches it writes nothing. Steps run from lowest to highest impact, and the first failure stops every later step:
- labels: priority/type changes and the derived status. For an
already_doneplan the status stays as it was here. - comment: at most one, with
@mentions neutralized and a hidden<!-- dispatch-groomer:apply=<key> -->marker at its end. Any marker the model wrote into its own text is stripped, and a marker only counts on a comment by an automation author, so nobody else can forge one to suppress or impersonate a groomer comment. - title/body: one write. A title is rewritten only when the current one is bad (the existing guard). The body is never replaced: enrichment goes into one Dispatch-managed section between
<!-- dispatch-groomer:managed:start -->and<!-- dispatch-groomer:managed:end -->markers, appended after the human text on first write and replaced in place afterwards. Text outside the section is kept byte for byte. Enrichment still applies only when the human-authored text is sparse, and a body whose markers are unpaired or repeated is left alone. - children, only for a plan that decomposes and still satisfies the decomposition policy (no close in the same plan, at least medium confidence, no material uncertainty, and every child brief a complete bounded implementation brief). Each bounded child brief becomes its own issue (created with the child labels), an already-created child is reused rather than re-opened, and once every child exists or is reused the parent is recorded as decomposed with the child URLs as its follow-ups; the step then writes a managed decomposition section into the parent body (one
- #<n>: <url>line per child) and, only then, adds theumbrellalabel — the step's final write (see Decomposition). - close, only for an
already_doneplan that still satisfies the close policy. - status/done, only once the close has landed, so a failed close leaves the issue open in its previous (groomable) status rather than open with
status/done, which the selector would skip forever.
Each step is recorded as applied, replayed, noop, skipped (comment cooldown), failed or not_attempted in appliedMutations.steps, alongside the existing labelsUpdated, titleUpdated, bodyUpdated, commentUrl, commentSkippedReason, commentError, issueClosed and issueClosedError fields. A run where a later step failed after earlier ones landed ends with status: "partial", retryable: true and an errorMessage naming the failed step; the grooming fields and freshness baseline record only what actually landed. A run whose first needed write failed (nothing landed) fails as before.
The ready and close policies are checked again here against the run's catalog. They cannot normally disagree with plan validation, but if they do, the ready promotion or close is withheld: the plan lands as status/backlog in the non-claimable lane with only its priority/type labels, and withheld records why.
Every application has a key: a SHA-256 over the repository, issue number, the plan's evidence digest, the plan schema version and the normalized mutation intent (final label set, lane, comment text, title, body, close, the persisted verdict reason text, the verdict action, the persisted verdict summary text, the release signal, and the decomposition children). The persisted reason (#1461) makes a re-groom whose verdict reason changed apply — so the issue row carries the new text — rather than replay with the stale one; the verdict action decides which field that reason persists to, so a verdict flip with an identical reason text is a new application too. The verdict summary (#1470) rides the same intent: it is written to Issue.groomingSummary the same way the reason is (and the board displays it), so a re-groom whose summary text changed applies instead of replaying with the stale one. The release signal (#1481) rides the same intent, so a re-groom whose release semantics changed applies (the projection is never replayed with an obsolete signal) while an identical re-groom replays. The key is claimed in the GroomingApplication table (unique on the key) before the first write, and each step's result is recorded as it lands.
Every run captures its own snapshot, so the key only recurs when the issue, its comments and the head are exactly as they were for the earlier attempt:
- A run whose key was already fully applied is a replay: it writes nothing to GitHub and no lane history, stamps only
groomedAt(so the same plan is not re-billed every tick), and recordsapplyOutcome: "replayed"with the claiming run. The only local columns it may still write are the bounded heals of fields persisted after the application finalized: the release-signal projection (#1487) and the verdict summary into an emptyIssue.groomingSummarycolumn (#1470) — each recovers the write a failed fresh-path issue update lost, and neither overwrites text a newer or different write already owns. - A run whose key was claimed but not finished resumes it: steps recorded as landed are replayed, not repeated, and only the rest are attempted. Because a landed label, title/body or close write changes the issue (and so the next snapshot and key), in practice what a resume skips is a comment that landed before the attempt failed or crashed. An unfinished claim updated in the last 10 minutes may belong to an attempt that is still running, so a run that meets one writes nothing (
applyOutcome: "busy", retryable, no cooldown stamp); an older one is treated as abandoned and taken over with a compare-and-swap, so of two runs resuming the same abandoned claim only one proceeds. - A comment that landed without being recorded is found on GitHub by its marker instead of being posted again, including when the comment write itself reported a failure after GitHub accepted it.
- A retry after earlier writes landed sees them in its own snapshot, so its preconditions pass and it has a new key. The diff from live state makes the already-applied parts no-ops, and the comment cooldown (recorded runs, or groomer markers on the issue) stops a second comment within the window.
Deploy note for application-key version 2 (dispatch#1461): the key now covers the persisted verdict reason and the verdict action, so keys recorded under version 1 can no longer match. Rows are not migrated — the reason text is not stored on a GroomingApplication row, so a v1 key cannot be recomputed — and the deploy behaviour is one re-apply: the first post-deploy groom of an already-groomed issue applies again under its fresh key (its comment re-posts, except where the comment cooldown still covers the previous one) before the new key is claimed and idempotency resumes. Decomposed parents re-record their decomposition state (one extra issue_decomposed audit entry); the children themselves are reused by their own childBriefKey, which does not include the application key.
Dry runs use the same preconditions, diff and policies without writing: mutationPlan.applyOutcome is dry_run, stale or unverifiable (with preconditionFailures), or would_replay when the key was already applied. A dry run never claims a key, so it never reports busy, and it never writes a backoff.
Deploy note for application-key version 3 (dispatch#1481): the key now also covers the release signal, so keys recorded under version 2 (and version 1) can no longer match. Rows are not migrated and the deploy behaviour is the same one re-apply as described above.
Deploy note for application-key version 4 (dispatch#1470): the key now also covers the persisted verdict summary text, so keys recorded under version 3 (and earlier) can no longer match. Rows are not migrated — the summary text is not stored on a GroomingApplication row, so an older key cannot be recomputed — and the deploy behaviour is the same one re-apply as described above.
Deploy note for application-key version 2 (dispatch#1461): the key now covers the persisted verdict reason and the verdict action, so keys recorded under version 1 can no longer match. Rows are not migrated — the reason text is not stored on a GroomingApplication row, so a v1 key cannot be recomputed — and the deploy behaviour is one re-apply: the first post-deploy groom of an already-groomed issue applies again under its fresh key (its comment re-posts, except where the comment cooldown still covers the previous one) before the new key is claimed and idempotency resumes. Decomposed parents re-record their decomposition state (one extra issue_decomposed audit entry); the children themselves are reused by their own childBriefKey, which does not include the application key.
Worker admission gating on these results (#1065) is shipped and documented under Worker admission; the operator view of run history evidence, freshness and mutation validation (#1067) is described under History and Audit.
A plan may carry an optional releaseSignal: semantic-only metadata describing what the issue represents if/when it is delivered — { kind: feature|fix|architecture|internal, headline, significance: routine|notable|major, evidenceRefs }. The authority boundary is absolute: it never means the work shipped or is done. Delivery stays authoritative to GitHub state and lifecycle evidence, and an already_done verdict that carries a signal acquires no delivery semantics from it.
The signal affects nothing operational: no lane, priority, readiness, admission, pickup or freshness change follows from it or from its evidence refs — signal refs are validated against the run's catalog but are deliberately excluded from plan.citations, so they never feed evidence-reliance derivation or the freshness baseline.
On a fresh applied groom, the signal is persisted wholesale on Issue.releaseSignal (latest applied groom owns the column; a groom with no signal clears it with a provenance-carrying tombstone, dispatch#1487):
set: { kind, headline, significance, evidenceRefs,
provenance: { groomingRunId, applicationKey, appliedAt } }
cleared: { tombstone: true,
provenance: { groomingRunId, applicationKey, appliedAt } }
provenance ties the projection to the grooming result that produced it. Because the signal is part of the application intent, a re-groom whose signal changed is applied and overwrites the projection; an identical re-groom replays and rewrites nothing. Consumers reading the signal must normalize it with normalizeStoredReleaseSignal (src/lib/groomer/plan.ts), so a tombstone reads as "no signal"; surfaces that serialize raw Issue rows normalize the column in place with withNormalizedReleaseSignal (GET /api/issues, GET /api/audit, the board and projects pages). appliedAt is the owning application's ownership time — its recorded finalize time on a replay heal, the projection write's timestamp on the fresh path; groomer runs are serialized under the groomer lock, so an earlier run's write precedes a later application's recorded finalize time — the same clock assumption the set-projection ordering already makes.
The projection write happens after the application finalizes, so a failed issue update there would otherwise leave the signal missing or stale with no later run to repair it — every same-key retry replays and skips the write. A replay therefore re-asserts the projection as a bounded heal: the column is written only when the stored provenance does not already speak for this application — an empty column, or one holding a projection this application supersedes (an older appliedAt) — and what it writes is the shape this application owns: the set projection, or the tombstone for a clear. A replay whose key already matches the stored provenance, or one whose projection a newer application already owns, writes nothing. The tombstone is written on an empty column too: an empty column is exactly what a lost clear leaves behind, and without the tombstone that lost clear carried no ownership evidence, so a later replay of an older signal-bearing key could not tell it apart from an unwritten column and re-asserted the stale signal.
The heal orders stored writes by their provenance, and a tombstone carries provenance too: an application that carried no signal clears the column with ownership evidence, so a later replay of an older signal-bearing key sees the newer application's tombstone and skips — it no longer mistakes the deliberate clear for its own lost write (the gap closed here, dispatch#1487). A stored projection whose provenance is unusable (missing or malformed appliedAt, on a tombstone or on a set projection) reads as no ownership and the heal still repairs — fail-open toward repairing lost writes, the heal's standing bias; both writers always fill provenance, so only hand-written data reaches that state. The heal stamps the application's recorded applied-at, never wall-clock time: ownership time is what orders two applications against each other, and without a recorded applied-at there is no ordering key, so nothing is written at all rather than a stamp that would outrank every recorded applied-at and block a newer application's own repair. The read-compare-write is not atomic, and it is safe because the hosted groomer is the only writer of this column and its runs are serialized under the groomer lock. One legacy window remains: a column cleared to a bare null by a pre-#1487 deployment carries no evidence, so a replay of an older signal-bearing key may still re-assert the stale signal; the next fresh groom — or a replay of the clear that owns the column — re-owns it and closes the window.
Every hosted grooming run is recorded in a dedicated GroomingRun table. Operators can inspect recent runs at /groomer (also reachable via /automation/groomer, which redirects there), including dry-run plans, write-mode applied mutations, context warnings, the LLM output summary, labels before/after, lane before/after, and the failure stage when a run fails.
Each run's row also carries an operator view (#1067) that answers the debugging questions without reading Postgres JSON by hand:
- Evidence and provenance — the exact default-branch head SHA the run analyzed (or an explicit "unpinned" marker when none could be captured), the evidence digest and issue fingerprint the plan is bound to, counts of repository paths read at the pinned revision versus merely surfaced, and related GitHub issue/PR/commit refs. Each evidence source is also listed with its identity and attribution: repository sources show the path and whether it was read at the pinned SHA or merely surfaced, and related-work sources show their stable key, live state, URL, and whether they were read directly or via a (possibly lagging) search-index hit. Comment provenance distinguishes human (authoritative) from automation comments, which are visibly marked non-authoritative.
- Pipeline and freshness — the run's status and stage (evidence collection, plan validation, precondition validation, mutation application are all distinguishable via the
stageprogression), plus the apply-time precondition result: each check (issue, comments, head) with pass/changed/unverifiable status and what changed, so a stale-aborted run shows exactly why it was refused. - Idempotent application — the
applicationKeyandapplyOutcome, with a replay visibly marked and described as "nothing was written", distinct from a fresh application, along with how many applications were recorded for the key and the latest application timestamp (populated on both the list and detail views). - High-impact decisions — the close recommendation's reason, rationale and cited evidence refs; a ready promotion with its admission (implementation or escalation) and the evidence refs that authorized it; and a decomposition with its reason, cited evidence refs and links to the child issues created or reused. Each decision also carries the plan verdict's confidence (
high/medium/low, orunknownwhen a legacy run's output predates the verdict contract), since the close, promotion and decomposition policies are confidence-gated.
The primary operator view never carries comment bodies, proposed bodies, raw model output or file contents. Those raw debugging fields — including the full validatedOutput plan (proposed bodies, comment drafts, briefs), the mutationPlan and the applied mutations — remain available on the detail API (GET /api/groomer/runs/[id]), which returns the full row; the list API strips them and attaches the operator view, so the verdict cell renders from the view's plan summary alone.
AgentRun and AuditLog rows are still written for compatibility with existing activity and audit views. GroomingRun is the detailed drilldown for hosted grooming and is the source of truth for /groomer (also reachable via /automation/groomer, which redirects there).
Two history API endpoints back the UI and integrations:
GET /api/groomer/runslists recent runs with filters for repo, issue number, status, dry-run/write mode, and model.GET /api/groomer/runs/[id]returns one run with its full plan, applied result, context summary, and error details.
A grooming result is only valid for the evidence it was checked against. After every applied (non-dry-run) groom, Dispatch records that evidence on the issue, and later syncs mark the result stale when the evidence changes. The groomer then picks the issue up again, even when it is fully classified status/ready, status/backlog or a parked status/blocked.
What is recorded (the groomed* columns on Issue):
- the default-branch head SHA and branch the run was pinned to;
- a fingerprint of the issue as the groom left it: title, body, state and labels, excluding
agent/*claim labels. It is computed from what the groomer itself wrote, so its own label/title/body/close writes never read back as an external change; - the comment count and the start of the run's evidence window;
- the evidence digest from the evidence snapshot, and the
GroomingRunthat produced the result; - the repository paths the result relied on, plus an evidence scope:
paths(a bounded path set),global(a code search found nothing, the result cites a path that was only surfaced by search or findings and never read, or the run consulted the repository without reading a path), ornone(the run used no repository evidence). Only paths fetched at the pinned SHA (via: "read") can bound a result. When the plan cites repository evidence, the cited read paths are what it relied on; when it cites none, every read path counts; - the
depends on #Nkeys in the body (parsed by the same code as dependency gating) and which of them were open; - the related issues and PRs the plan cites, with the state it saw (or, when it cites none, those the run read directly).
What makes a result stale (groomingStaleAt, groomingStaleReasons, groomingStaleDetail):
| Reason | Trigger |
|---|---|
issue_changed |
Title, body, state or a non-agent/* label differs from what the groom left. |
human_comment |
A comment by a non-automation author after the run started. Automation authors (the same list the groomer uses to tag comments) never stale a result, including the groomer's own comment. |
dependency_changed |
A declared blocker closed or reopened. |
related_work_changed |
A related issue or PR the run read changed state (e.g. a PR merged). |
evidence_path_changed |
A default-branch commit touched a path the result relied on (exact file, or a file under an evidenced directory). |
global_evidence_commit |
Any new default-branch commit, when the result relied on global evidence. |
compare_unreliable |
The commit comparison cannot be trusted: the base SHA is gone, the histories diverged, or GitHub truncated the changed-file list. |
evidence_incomplete |
(#1459) A parked explained block with no recorded dependency anchor was revisited after the reconsideration window (see below). |
A commit that only touches unrelated paths does not stale a paths result; the issue's groomingVerifiedSha advances to the new head instead, so each later check compares a small range. A transient compare failure leaves the result fresh but unverified (groomingVerifiedSha behind the head) and is retried on the next sync. A run whose head SHA could not be pinned stays unverified rather than stale, so a repository whose head cannot be resolved does not re-groom in a loop.
How it runs:
- The scheduled and manual issue syncs run a freshness pass after syncing. It never calls the model. Database checks (issue fingerprint, dependencies, tracked related issues) cost nothing extra; GitHub reads are capped per pass: one head lookup per repository branch, at most 10 commit comparisons (shared by every issue verified at the same SHA), 10 comment reads (only when the comment count grew) and 10 related-work reads. Anything the budget does not reach is checked on a later pass. Issues a worker owns (
status/in-progress,status/in-review) are not evaluated. POST /api/issues/webhookalso acceptsissue_commentcreatedevents and stales a result immediately for a new human comment. Without webhooks, the sync pass catches it.- Writes are guarded on the recorded
GroomingRunid and on the result still being fresh, so repeated passes are idempotent and a pass racing a new groom cannot stale the newer result. Each invalidation writes agrooming_staleAuditLogrow. - The selector offers stale issues ahead of routine backlog re-grooming but behind anything missing classification. A stale issue skips the 24h cooldown and the blocked/not-ready parking (the evidence that parked it changed), but is not re-groomed within 30 minutes of its last groom. The re-groom's
GroomingRunrecordscandidateSource: "stale"and thestaleReasonsthat triggered it. - Targeted runs (
issueNumber) still force a re-evaluation regardless of freshness. - A run skipped because the issue is
status/in-progressorstatus/in-reviewrecords no baseline and leaves any earlier one as it was.
Freshness unknown: issues groomed before this existed, or through POST /api/issues/groom (which carries no evidence and resets any recorded baseline), have no fingerprint. The hosted groomer backfills them at the lowest priority, only when nothing else wants grooming and outside the usual 24h cooldown; deliberately blocked issues are not backfilled. External groomers polling next-task?mode=groom are not offered backfill work. Worker admission decides how unknown and unverified results are treated.
An issue parked with an explained block (blockedReason, status/blocked) can stay parked after its named prerequisites close: a legacy park has no freshness baseline the freshness pass can evaluate, and a baselined park whose dependency evidence is incomplete (nothing saved for the body or the verdict) is skipped by the freshness pass's dependency check. A bounded reconciliation pass — read-only over the cache, run after each sync next to the freshness pass — revisits such parked blocks. Only groomer-owned parks are considered, and a park is a card on the blocked column that still carries its block reason: a park carrying the groomer's own verdict marker (nextGroomingAction is mark_blocked), or a legacy park that carries no marker because the verdict that wrote it omitted the action — there the last verdict must be the hosted groomer's (groomedBy is hosted-groomer), the row must have been actually judged (groomedAt is set), the card must still sit on the blocked column, and the row must carry no operator evidence. Operator evidence is a deliberate human hold the pass never revisits, and the status route is its only source: it is the only writer of an operator's block reason, and it stamps the row with the actor that decided (groomedBy), so a hold written now is excluded by that stamp alone — atomically with the hold, which no audit row can promise, since the audit write is best-effort and happens after the commit. Two history filters cover the holds written before that stamp existed: a status_changed lifecycle event from the status route, and a successful set_status audit row; both are checked because the event is only written when the status label actually moves, so a re-block of an already-blocked issue leaves only the audit row, and a failed write proves nothing. A board move and a manual groom are not holds here: neither writes a block reason, and the card's column is what they change — a card a human moved off the blocked column is not a park at all — while manual POST /api/issues/groom parks (which write a prose marker) stay outside the pass's reach by construction, and a park the pass cannot see remains reachable through a targeted re-groom or the operator's recovery audit. Reconsideration emits one stale reason, evidence_incomplete: a park with no typed dependency anchor is revisited on a bounded 24h window. An anchorless park is revisited even when a candidate dependency is still open: with no anchor the pass cannot tell which dependency the verdict relied on, so an open prerequisite is not proof that the park still holds, and the window-bounded re-groom re-reads the dependency state and re-decides (it never unblocks the issue on its own — the stale mark only schedules the re-groom — and the observed open set rides along in the audit detail). The cost stays bounded: at most one re-groom per anchorless park per window.
The anchor — the persisted dependency set: the issue body's depends on #N refs unioned with the verdict's typed, evidence-grounded dependencies (the plan brief's open/unknown dependencies backed by a cited GitHub issue) — decides ownership. A park WITH an anchor is the freshness pass's territory: it compares the recorded open blockers against the current open set and marks dependency_changed only when they really change. The anchor, not the recorded open-blocker set, decides that ownership: a dependency that was unknown or already closed at verdict time leaves the open-blocker set empty while the anchor stands, and such a row is not revisited on the window either — nothing about its dependencies changed, so re-grooming it would only churn the model. After a reconsideration the park usually carries an anchor (the body's refs are persisted with the new baseline), so it moves to the freshness pass's regime: from then on it is re-groomed when its evidence changes — an issue edit, a new human comment, a changed dependency set, changed related work — instead of on a window.
A park whose typed dependency Dispatch cannot observe (an untracked or disabled repository) therefore gets no automatic reconsideration at all: its closure is invisible to the cache, and it stays reachable through a targeted re-groom or the operator's recovery audit. The pass itself never auto-promotes to ready and never closes: the stale mark is the selector's reconsideration path, and the fresh groom it schedules is what re-decides the issue's status. Operator admission overrides, worker-owned statuses, retry backoff and the write race guards are preserved.
The scan is capped per repository, so the cap rotates: every row the scan reaches is stamped with the time it was last considered (Issue.blockedReconciliationAt, audit-only — no freshness, admission or block predicate reads it) and the scan orders least-recently-considered first (NULLS FIRST, so never-considered and never-groomed legacy rows lead). A row the scan cannot act on — one it skips in memory (worker-owned status, renovate issue), one it defers (its reconsideration window has not elapsed), or one whose mark lost the race — would otherwise hold the capped slice on every pass and starve every later parked issue out of reconsideration. Rows the scan never reached (cut off by the cap) stay unstamped and lead the next pass.
Worker admission (#1065) decides whether a status/ready issue may be handed to an autonomous implementation worker, from persisted state only: no model call, no GitHub call, and nothing is written by next-task or /queue. It is opt-in via DISPATCH_QUEUE_ADMISSION_MODE:
| Mode | Behaviour |
|---|---|
off (default) |
Queue behaviour is exactly what it was: same query, same items, no admission field. Unset or unrecognised values mean off (an unrecognised value is logged once), so a typo can never starve the fleet. |
audit |
Nothing is filtered. Every issue item in /queue and get_queue carries an admission decision, board cards show "Would be withheld from workers (audit): ...", and next-task logs a [queue-admission] audit: warning when it hands out an item enforce mode would withhold. |
enforce |
Withheld issues are removed from implementation pickup. next-task never returns an implement task for them and goes idle with No work available (N ready issues withheld by grooming admission) when nothing else is left. GET /api/agents/{name}/queue?includeWithheld=true (MCP get_queue includeWithheld) appends them with claimable: false for diagnostics; board cards show "Withheld from workers: ...". |
Roll out with audit first, check which ready issues would be withheld and why, then switch to enforce.
Only status/ready issues are gated. A worker's status/in-progress work is never withheld (the freshness pass does not track worker-owned statuses, so gating them would only strand claimed work). A ready issue is admitted only when all of these hold:
- No open
depends on #Nblocker (#1038). The dependency gate still runs first and stays authoritative; admission never re-admits a blocked issue. - The issue has a freshness baseline (not unknown) that is not marked stale.
- The cached issue still matches the baseline's fingerprint, so an edit the freshness pass has not recorded yet still withholds it. The groomer does not write its own title/body changes into Dispatch's cache, so right after a groom that rewrote them the issue reads as
grooming_issue_changeduntil the next sync. - A baseline whose evidence scope depends on the repository (
paths,global) was pinned to a verified SHA (groomingVerifiedSha). Ongoing head verification is the freshness pass's job: it marks the result stale on a relevant commit. - Either the baseline is a current operator override (below), or the grooming run it points at (
groomedRunId) was fully applied (stage: "applied",status: "completed", not a dry run;partialis withheld until the retry lands) and its plan's readiness (#1062) isready, bound to the same evidence digest the baseline records, with anescalationadmission sitting on the escalation lane.
Readiness is read only from the run the baseline points at. Skipped (in-flight), stale, unverifiable, failed and dry runs never write a baseline, so their stored readiness is never consulted.
The decision on a queue item:
"admission": {
"mode": "enforce",
"admitted": false,
"basis": "grooming",
"reasons": [{ "code": "grooming_stale", "message": "Grooming decision is stale (human_comment); awaiting re-grooming" }],
"summary": "Grooming decision is stale (human_comment); awaiting re-grooming",
"groomedRunId": "clx..."
}basis is grooming, override, or not_gated (not a ready issue). Reason codes: dependency_blocked, grooming_unknown, grooming_stale, grooming_issue_changed, grooming_unverified, grooming_run_missing, grooming_not_applied, grooming_partial, grooming_readiness_missing, grooming_not_ready, grooming_evidence_mismatch, escalation_lane_mismatch.
Admission gates what the queue hands out (next-task, /queue, get_queue); it does not gate the claim APIs for an issue a caller names explicitly, the same as the dependency gate.
PR-fix queue items are never subject to admission, and linked-PR follow-up routing in next-task scans the queue before admission, so a withheld issue's PR can still be followed up.
Cost: the queue loads a few extra Issue columns and does one primary-key GroomingRun lookup for the ready issues' baselines, only when admission is on.
Re-grooming is the only way withheld work gets admitted, so with admission on the hosted groomer keeps it eligible: stale results take the existing stale path; a ready issue with no baseline is backfilled ahead of routine backlog re-grooming instead of last; and a fresh result the gate withholds (partial, unpinned, not ready, missing readiness, lane mismatch) is re-selected after the normal 24h cooldown with candidateSource: "admission_withheld". External groomers are not offered this work, for the same reason as backfill.
A bare status/ready label is never an override. An operator records one explicitly:
POST /api/issues/{issueId}/admission-overridewith an optional body{ "reason": "...", "headSha": "<40-hex>" }. The issue must be open and alreadystatus/readyin Dispatch's cache. WithoutheadSha, the live default-branch head is resolved from GitHub.DELETE /api/issues/{issueId}/admission-overrideclears it; if it was still the baseline, freshness returns to unknown.
The override records the actor, time, reason and head SHA on the issue (admissionOverride* columns) and an admission_override AuditLog row. It is written as the freshness baseline itself, with groomedRunId set to the override id and evidence scope global (the override vouches for the whole repository at that SHA), so it goes stale under exactly the checks a grooming result does: an issue edit, a new human comment, a dependency change, or any default-branch commit. A later applied groom replaces the baseline and supersedes the override. It never bypasses a depends on #N blocker. It also sets groomedAt, so the periodic groomer does not replace it on its next tick. Only operator auth can record or clear an override: an OIDC session, basic auth, or DISPATCH_AUTH_MODE=disabled. Agent bearer tokens (DISPATCH_AGENT_TOKEN, which every worker holds) get 403 on both POST and DELETE, so a worker cannot override the gate that exists to stop it. In legacy auth mode (no DISPATCH_AUTH_MODE) only bearer auth exists, so overrides are unavailable there. The actor is the authenticated operator, and the audit row records it with the auth type.
When DISPATCH_GROOMER_REPO_CONTEXT_ENABLED=true, the groomer gathers bounded repository context through GitHub REST APIs only — it does not clone repositories, execute shell commands, or run GitHub Actions. The number of searches, number of files, bytes per file, and total prompt context are all capped by configuration.
Repository context is best-effort: fetch warnings are recorded on the GroomingRun record and the groomer proceeds with issue-only context when the targeted GitHub issue can still be loaded from Dispatch's cache.
Dispatch exposes a one-run endpoint (POST /api/groomer/run), driven by the in-process scheduler in src/lib/scheduler.ts on the DISPATCH_GROOMER_INTERVAL_MS interval (default 10 minutes). The endpoint remains callable directly — by an external scheduler, or by hand with DISPATCH_AGENT_TOKEN or DISPATCH_GROOMER_TOKEN — and processes at most one issue per run either way.