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
8 changes: 6 additions & 2 deletions docs/review-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@ new cohort, and it starts new provider conversations only through the same
missing-conversation fallback.

Reuse fails with `--fresh-session` guidance when a cohort member is missing or
runtime-incompatible, `--max-agents` is smaller than the saved cohort, or the
cohort cannot cover every changed file.
runtime-incompatible, `--max-agents` is smaller than the saved cohort, or a
changed file is coverable by a current catalog agent (broad with no file globs
or matching a file glob) but no saved cohort member can take it. If no current
catalog agent can cover a changed file, reuse leaves it unassigned and the
downstream coverage gate reports `incomplete_unassigned` without forcing a
fresh session.

## `--fresh-session`

Expand Down
22 changes: 8 additions & 14 deletions internal/dossier/dossier.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,23 +263,17 @@ const (
const SummaryTaskID = dossierSummaryTaskID

var forbiddenDiscussionSummaryPatterns = []*regexp.Regexp{
regexp.MustCompile(`\bprovider[_ ]session[_ ]id\b`),
regexp.MustCompile(`\bsession[_ ]row[_ ]id\b`),
regexp.MustCompile(`\bsession[_ ]id\b`),
regexp.MustCompile(`\brun[_ ]id\b`),
regexp.MustCompile(`\bretry[_ ]state\b`),
regexp.MustCompile(`\bcache[_ ]state\b`),
regexp.MustCompile(`\bcache hit\b`),
regexp.MustCompile(`\bledger\b`),
regexp.MustCompile(`\bmergeab(?:le|ility)\b`),
regexp.MustCompile(`\bdraft\b`),
regexp.MustCompile(`\bapprovals?\b`),
regexp.MustCompile(`\bapproved\b`),
regexp.MustCompile(`\b(?:provider[_ ]session[_ ]id|session[_ ]row[_ ]id|session[_ ]id|run[_ ]id|retry[_ ]state|cache[_ ]state|cache hit)(?:\s*[:=]\s*|\s+(?:is|was|has been|had been)\s+)\S+`),
regexp.MustCompile(`\b(?:pr|pull request)\b(?:'s)?\s+(?:(?:is|was|has been)\s+)?(?:approved(?:\s+by\s+\S+)?|a\s+draft|draft)\b`),
regexp.MustCompile(`\b(?:approved|draft)\s+(?:pr|pull request)\b`),
regexp.MustCompile(`\b(?:pr|pull request)\b(?:'s)?\s+(?:(?:is|was)\s+)?mergeab(?:le|ility)(?:\s+status)?\b`),
regexp.MustCompile(`\b(?:pr|pull request)\b(?:'s)?\s+approvals?\s+(?:state|status)\b`),
regexp.MustCompile(`\brequested reviewers?\b`),
regexp.MustCompile(`\brequested review\b`),
regexp.MustCompile(`\bci status\b`),
regexp.MustCompile(`\bbuild failed\b`),
regexp.MustCompile(`\bcheck(s)? failed\b`),
regexp.MustCompile(`\b(?:ci|build|check)\s+(?:failed|(?:has|had) failed|(?:is|was|has been) failing)\b`),
regexp.MustCompile(`\b(?:builds|checks)\s+(?:failed|(?:have|had) failed|(?:are|were|have been) failing)\b`),
regexp.MustCompile(`\bfailed (?:build|builds|check|checks)\b`),
}

// WriteRaw writes the source dossier artifacts used by Prepare.
Expand Down
125 changes: 124 additions & 1 deletion internal/dossier/dossier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ func TestDecodeDossierDiscussionSummaryRejectsProcessState(t *testing.T) {
if err != nil {
t.Fatalf("dossierDiscussionPromptInputFromDiscussion: %v", err)
}
for _, text := range []string{"CI status is red", "Build failed in CI", "Approved by alice", "run_id=1234"} {
for _, text := range []string{"CI status is red", "Build failed in CI", "The pull request was approved by alice", "run_id=1234"} {
_, err := decodeDossierDiscussionSummary([]byte(fmt.Sprintf(`{
"schema_version": 1,
"top_level_comments": [{"summary": %q}]
Expand All @@ -1123,6 +1123,129 @@ func TestDecodeDossierDiscussionSummaryRejectsProcessState(t *testing.T) {
}
}

func TestDossierDiscussionSummaryProcessVocabularyMatrix(t *testing.T) {
accepted := []string{
"Existing matching approval records remain retrievable after a run reaches a terminal state.",
"The approval request was approved by an administrator.",
"Draft invoices are stored in the ledger.",
"The session_id and run_id columns are indexed.",
"Cache hits update retry state transitions.",
}
rejected := []string{
"The PR is approved.",
"The PR is approved by alice.",
"The PR was approved by alice.",
"The pull request is approved by alice.",
"The pull request was approved by alice.",
"The pull request has been approved.",
"The pull request is a draft.",
"PR mergeability status is clean.",
"The PR is mergeable.",
"The PR was mergeable.",
"The pull request is mergeable.",
"The pull request was mergeable.",
"Requested reviewers are listed.",
"Requested review is pending.",
"CI is failing.",
"CI was failing.",
"CI has been failing.",
"Checks are failing.",
"Checks were failing.",
"Checks have been failing.",
"Build is failing.",
"Build was failing.",
"Build has been failing.",
"Builds are failing.",
"Builds were failing.",
"Builds have been failing.",
"CI failed.",
"CI has failed.",
"CI had failed.",
"Build has failed.",
"Checks have failed.",
"Builds had failed.",
"CI status is red.",
"Build failed in CI.",
"Checks failed in CI.",
"session_id=019fe123.",
"The session ID is 019fe123.",
"The run_id was abc.",
"retry_state: exhausted.",
"cache_state=hit.",
}
for i, text := range accepted {
t.Run(fmt.Sprintf("accept_%d", i), func(t *testing.T) {
promptData, err := dossierDiscussionPromptInputFromDiscussion(dossierDiscussionArtifact{
TopLevelComments: []dossierTopLevelCommentArtifact{{Body: text}},
})
if err != nil {
t.Fatalf("dossierDiscussionPromptInputFromDiscussion: %v", err)
}
if len(promptData.Input.TopLevelComments) != 1 {
t.Fatalf("prompt comments = %#v, want accepted vocabulary", promptData.Input.TopLevelComments)
}
_, err = decodeDossierDiscussionSummary([]byte(fmt.Sprintf(`{
"schema_version": 1,
"top_level_comments": [{"summary": %q}]
}`, text)), promptData)
if err != nil {
t.Fatalf("decodeDossierDiscussionSummary(%q): %v, want accepted vocabulary", text, err)
}
})
}
for i, text := range rejected {
t.Run(fmt.Sprintf("reject_%d", i), func(t *testing.T) {
promptData, err := dossierDiscussionPromptInputFromDiscussion(dossierDiscussionArtifact{
TopLevelComments: []dossierTopLevelCommentArtifact{{Body: text}},
})
if err != nil {
t.Fatalf("dossierDiscussionPromptInputFromDiscussion: %v", err)
}
if len(promptData.Input.TopLevelComments) != 0 {
t.Fatalf("prompt comments = %#v, want process state omitted", promptData.Input.TopLevelComments)
}
_, err = decodeDossierDiscussionSummary([]byte(fmt.Sprintf(`{
"schema_version": 1,
"top_level_comments": [{"summary": %q}]
}`, text)), promptData)
if err == nil || !strings.Contains(err.Error(), "excluded reviewer-facing process state") {
t.Fatalf("decodeDossierDiscussionSummary(%q) error = %v, want excluded process state", text, err)
}
})
}
}

func TestDossierDiscussionSummaryFiltersProcessStateFromInlineContent(t *testing.T) {
promptData, err := dossierDiscussionPromptInputFromDiscussion(dossierDiscussionArtifact{
InlineThreads: []dossierInlineThreadArtifact{
{
ID: "inline-comment", Path: "main.go", Side: "RIGHT", Line: 2, AnchorKind: "line",
Comments: []dossierThreadCommentArtifact{{Body: "Checks are failing."}},
},
{
ID: "cached-summary", Path: "other.go", Side: "RIGHT", Line: 4, AnchorKind: "line",
CachedSummary: &dossierCachedThreadSummaryArtifact{
ThreadID: "cached-summary", Body: "The pull request has been approved.",
},
},
},
})
if err != nil {
t.Fatalf("dossierDiscussionPromptInputFromDiscussion: %v", err)
}
if len(promptData.CachedInlineSummaries) != 0 {
t.Fatalf("cached inline summaries = %#v, want process state excluded", promptData.CachedInlineSummaries)
}
if len(promptData.Input.InlineThreads) != 2 {
t.Fatalf("inline threads = %#v, want both threads without excluded text", promptData.Input.InlineThreads)
}
for _, thread := range promptData.Input.InlineThreads {
if len(thread.Comments) != 0 {
t.Fatalf("inline thread %q comments = %#v, want process state excluded", thread.ThreadID, thread.Comments)
}
}
}

func TestDecodeDossierDiscussionSummaryRejectsUnknownAnchor(t *testing.T) {
promptData, err := dossierDiscussionPromptInputFromDiscussion(dossierDiscussionArtifact{
InlineThreads: []dossierInlineThreadArtifact{{
Expand Down
8 changes: 6 additions & 2 deletions internal/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -1655,7 +1655,11 @@ func rebaseReviewerCohort(req Request, catalog agents.Catalog, cohort ledger.Rev
break
}
if !assigned {
return freshError("saved reviewer cohort leaves changed file %q uncovered", file)
for _, current := range catalog.Agents {
if len(current.FileGlobs) == 0 || globsMatchFile(current.FileGlobs, file) {
return freshError("saved reviewer cohort leaves changed file %q uncovered", file)
}
}
}
}

Expand Down Expand Up @@ -2434,7 +2438,7 @@ func reviewerToolEvidenceByAgent(sessions []sessionDraft) map[string]*llm.Review
}

func buildReviewerCoverage(selected []llm.SelectedAgent, results []llm.Findings, failures []ReviewerFailure, changedFiles []string, toolEvidence ...map[string]*llm.ReviewerToolEvidence) []reviewplan.ReviewerCoverageSummary {
if len(selected) == 0 {
if len(selected) == 0 && len(changedFiles) == 0 {
return nil
}
resultByAgent := make(map[string]llm.Findings, len(results))
Expand Down
Loading
Loading