Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions docs/hosted-groomer.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,26 @@ The content excerpts are checked against is what the run's repository-context fe

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](#what-is-applied-and-in-which-order)); a close that fails there is withheld and the plan lands as backlog.

### Decomposition

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[].material` flag the close policy keys on);
- every child brief is a **complete bounded implementation brief**: its `problem`, `designDecision`, and `verifiedCurrentBehavior` are all non-blank, and its `relevantPaths`, `inScope`, `outOfScope`, `acceptanceCriteria`, and `tests` each name at least one entry (`dependencies` may 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 in `withheld.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 an `ImplementationBrief`), 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.

### Compatibility

`GroomingRun.validatedOutput` stores the full plan. `mutationPlan` keeps its existing fields and adds `planSchemaVersion`, `evidenceDigest`, `readiness`, `closeRecommendation`, `applicationKey`, `preconditions` 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 `/automation/groomer`, marked `legacy`, with no readiness claim.
`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 `/automation/groomer`, marked `legacy`, with no readiness claim.

## Applying a plan

Expand Down Expand Up @@ -207,8 +224,9 @@ The diff is computed against the live issue, never Dispatch's cache, and only wh
1. **labels**: priority/type changes and the derived status. For an `already_done` plan the status stays as it was here.
2. **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.
3. **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.
4. **close**, only for an `already_done` plan that still satisfies the close policy.
5. **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.
4. **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 the `umbrella` label — the step's final write (see [Decomposition](#decomposition)).
5. **close**, only for an `already_done` plan that still satisfies the close policy.
6. **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.

Expand All @@ -227,7 +245,7 @@ Every run captures its own snapshot, so the key only recurs when the issue, its

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.

Out of scope here, and still to come: worker admission gating on these results (#1065), child issue creation (#1066), and UI exposure of the new history fields (#1067).
Out of scope here, and still to come: worker admission gating on these results (#1065), and UI exposure of the new history fields (#1067).

## History and Audit

Expand Down
Loading
Loading