Skip to content

Commit bd5e27b

Browse files
steveyeggeclaude
andcommitted
feat: skip witness/refinery registration for owned+direct convoys (gt-myofa.5)
Polecats working on owned convoys with direct merge strategy now bypass the standard witness/refinery merge pipeline. The polecat already pushed to main, so no MR is needed. Changes: - gt done: skip MR creation for owned+direct convoys, close issue directly - gt done: include convoy info (ID, owned, strategy) in POLECAT_DONE body - witness: HandlePolecatDone skips merge flow for owned+direct, runs cleanup - refinery: belt-and-suspenders checks in handler and engineer to skip strays - protocol: add PolecatDonePayload with SkipMergeFlow() and parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ae16ec commit bd5e27b

File tree

7 files changed

+210
-22
lines changed

7 files changed

+210
-22
lines changed

internal/cmd/done.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ func runDone(cmd *cobra.Command, args []string) (retErr error) {
380380
var mrID string
381381
var pushFailed bool
382382
var doneErrors []string
383-
var mergeStrategy string // convoy merge strategy: "direct", "mr", "local", or "" (default mr)
383+
var convoyInfo *ConvoyInfo // Populated if issue is tracked by a convoy
384384
if exitType == ExitCompleted {
385385
if branch == defaultBranch || branch == "master" {
386386
return fmt.Errorf("cannot submit %s/master branch to merge queue", defaultBranch)
@@ -477,10 +477,10 @@ func runDone(cmd *cobra.Command, args []string) (retErr error) {
477477
// direct: push commits straight to target branch, bypass refinery
478478
// mr: default — create merge-request bead, refinery merges
479479
// local: keep on feature branch, no push, no MR (for human review/upstream PRs)
480-
mergeStrategy = getConvoyMergeStrategyForIssue(issueID)
480+
convoyInfo = getConvoyInfoForIssue(issueID)
481481

482482
// Handle "local" strategy: skip push and MR entirely
483-
if mergeStrategy == "local" {
483+
if convoyInfo != nil && convoyInfo.MergeStrategy == "local" {
484484
fmt.Printf("%s Local merge strategy: skipping push and merge queue\n", style.Bold.Render("→"))
485485
fmt.Printf(" Branch: %s\n", branch)
486486
if issueID != "" {
@@ -492,7 +492,7 @@ func runDone(cmd *cobra.Command, args []string) (retErr error) {
492492
}
493493

494494
// Handle "direct" strategy: push to target branch, skip MR
495-
if mergeStrategy == "direct" {
495+
if convoyInfo != nil && convoyInfo.MergeStrategy == "direct" {
496496
fmt.Printf("%s Direct merge strategy: pushing to %s\n", style.Bold.Render("→"), defaultBranch)
497497
directRefspec := branch + ":" + defaultBranch
498498
directPushErr := g.Push("origin", directRefspec, false)
@@ -664,6 +664,39 @@ func runDone(cmd *cobra.Command, args []string) (retErr error) {
664664
}
665665
}
666666

667+
// Check if issue belongs to an owned+direct convoy.
668+
// Owned convoys with direct merge strategy bypass the refinery pipeline —
669+
// the polecat already pushed to main. Skip MR creation and close directly.
670+
convoyInfo = getConvoyInfoForIssue(issueID)
671+
if convoyInfo.IsOwnedDirect() {
672+
fmt.Printf("%s Owned convoy (direct merge): skipping merge queue\n", style.Bold.Render("→"))
673+
fmt.Printf(" Convoy: %s\n", convoyInfo.ID)
674+
fmt.Printf(" Branch: %s\n", branch)
675+
fmt.Printf(" Issue: %s\n", issueID)
676+
fmt.Println()
677+
fmt.Printf("%s\n", style.Dim.Render("Polecat already pushed to main. No MR needed."))
678+
679+
// Close the issue directly — refinery won't process it.
680+
// Retry with backoff handles transient dolt lock contention.
681+
var closeErr error
682+
for attempt := 1; attempt <= 3; attempt++ {
683+
closeErr = bd.ForceCloseWithReason("Completed via owned+direct convoy (no MR needed)", issueID)
684+
if closeErr == nil {
685+
fmt.Printf("%s Issue %s closed (owned+direct)\n", style.Bold.Render("✓"), issueID)
686+
break
687+
}
688+
if attempt < 3 {
689+
style.PrintWarning("close attempt %d/3 failed: %v (retrying in %ds)", attempt, closeErr, attempt*2)
690+
time.Sleep(time.Duration(attempt*2) * time.Second)
691+
}
692+
}
693+
if closeErr != nil {
694+
style.PrintWarning("could not close issue %s after 3 attempts: %v", issueID, closeErr)
695+
}
696+
697+
goto notifyWitness
698+
}
699+
667700
// Determine target branch (auto-detect integration branch if applicable)
668701
// Only if refinery integration branch auto-targeting is enabled
669702
target := defaultBranch
@@ -857,8 +890,15 @@ afterDoltMerge:
857890
bodyLines = append(bodyLines, fmt.Sprintf("Gate: %s", doneGate))
858891
}
859892
bodyLines = append(bodyLines, fmt.Sprintf("Branch: %s", branch))
860-
if mergeStrategy != "" && mergeStrategy != "mr" {
861-
bodyLines = append(bodyLines, fmt.Sprintf("MergeStrategy: %s", mergeStrategy))
893+
// Include convoy ownership info so witness can skip merge flow registration
894+
if convoyInfo != nil {
895+
bodyLines = append(bodyLines, fmt.Sprintf("ConvoyID: %s", convoyInfo.ID))
896+
if convoyInfo.Owned {
897+
bodyLines = append(bodyLines, "ConvoyOwned: true")
898+
}
899+
if convoyInfo.MergeStrategy != "" {
900+
bodyLines = append(bodyLines, fmt.Sprintf("MergeStrategy: %s", convoyInfo.MergeStrategy))
901+
}
862902
}
863903
if len(doneErrors) > 0 {
864904
bodyLines = append(bodyLines, fmt.Sprintf("Errors: %s", strings.Join(doneErrors, "; ")))

internal/cmd/sling_convoy.go

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"crypto/rand"
56
"encoding/base32"
67
"encoding/json"
@@ -136,40 +137,65 @@ func convoyTracksBead(beadsDir, convoyID, beadID string) bool {
136137
return false
137138
}
138139

139-
// getConvoyMergeStrategyForIssue finds the convoy tracking the given issue
140-
// and returns its merge strategy ("direct", "mr", "local", or "" for default/mr).
141-
// Returns "" on any error (convoy not found, lookup fails, no strategy set).
142-
func getConvoyMergeStrategyForIssue(issueID string) string {
143-
if issueID == "" {
144-
return ""
145-
}
140+
// ConvoyInfo holds convoy details for an issue's tracking convoy.
141+
type ConvoyInfo struct {
142+
ID string // Convoy bead ID (e.g., "hq-cv-abc")
143+
Owned bool // true if convoy has gt:owned label
144+
MergeStrategy string // "direct", "mr", "local", or "" (default = mr)
145+
}
146+
147+
// IsOwnedDirect returns true if the convoy is owned with direct merge strategy.
148+
// This is the key check for skipping witness/refinery merge pipeline.
149+
func (c *ConvoyInfo) IsOwnedDirect() bool {
150+
return c != nil && c.Owned && c.MergeStrategy == "direct"
151+
}
152+
153+
// getConvoyInfoForIssue checks if an issue is tracked by a convoy and returns its info.
154+
// Returns nil if not tracked by any convoy.
155+
func getConvoyInfoForIssue(issueID string) *ConvoyInfo {
146156
convoyID := isTrackedByConvoy(issueID)
147157
if convoyID == "" {
148-
return ""
158+
return nil
149159
}
150160

151161
townRoot, err := workspace.FindFromCwd()
152162
if err != nil {
153-
return ""
163+
return nil
154164
}
155165
townBeads := filepath.Join(townRoot, ".beads")
156166

167+
// Get convoy details (labels + description) for ownership and merge strategy
157168
showCmd := exec.Command("bd", "show", convoyID, "--json")
158169
showCmd.Dir = townBeads
170+
var stdout bytes.Buffer
171+
showCmd.Stdout = &stdout
159172

160-
out, err := showCmd.Output()
161-
if err != nil {
162-
return ""
173+
if err := showCmd.Run(); err != nil {
174+
return &ConvoyInfo{ID: convoyID} // Return basic info even if details fail
163175
}
164176

165177
var convoys []struct {
166-
Description string `json:"description"`
178+
Labels []string `json:"labels"`
179+
Description string `json:"description"`
167180
}
168-
if err := json.Unmarshal(out, &convoys); err != nil || len(convoys) == 0 {
169-
return ""
181+
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil || len(convoys) == 0 {
182+
return &ConvoyInfo{ID: convoyID}
183+
}
184+
185+
info := &ConvoyInfo{ID: convoyID}
186+
187+
// Check for gt:owned label
188+
for _, label := range convoys[0].Labels {
189+
if label == "gt:owned" {
190+
info.Owned = true
191+
break
192+
}
170193
}
171194

172-
return parseConvoyMergeStrategy(convoys[0].Description)
195+
// Parse merge strategy from description
196+
info.MergeStrategy = parseConvoyMergeStrategy(convoys[0].Description)
197+
198+
return info
173199
}
174200

175201
// createAutoConvoy creates an auto-convoy for a single issue and tracks it.

internal/protocol/messages.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,28 @@ func ParseReworkRequestPayload(body string) (*ReworkRequestPayload, error) {
333333
return payload, nil
334334
}
335335

336+
// ParsePolecatDonePayload parses a POLECAT_DONE notification body.
337+
// Unlike formal protocol messages, POLECAT_DONE is a mail convention — no
338+
// required fields are enforced. Returns a best-effort parse of available fields.
339+
func ParsePolecatDonePayload(polecatName, body string) *PolecatDonePayload {
340+
payload := &PolecatDonePayload{
341+
Polecat: polecatName,
342+
ExitType: parseField(body, "Exit"),
343+
Issue: parseField(body, "Issue"),
344+
Branch: parseField(body, "Branch"),
345+
MR: parseField(body, "MR"),
346+
ConvoyID: parseField(body, "ConvoyID"),
347+
MergeStrategy: parseField(body, "MergeStrategy"),
348+
Errors: parseField(body, "Errors"),
349+
}
350+
351+
if parseField(body, "ConvoyOwned") == "true" {
352+
payload.ConvoyOwned = true
353+
}
354+
355+
return payload
356+
}
357+
336358
// parseField extracts a field value from a key-value body format.
337359
// Format: "Key: value"
338360
func parseField(body, key string) string {

internal/protocol/refinery_handlers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ func (h *DefaultRefineryHandler) SetOutput(w io.Writer) {
4343
// HandleMergeReady handles a MERGE_READY message from Witness.
4444
// When a polecat's work is verified and ready, the Refinery acknowledges receipt.
4545
//
46+
// Belt-and-suspenders: if the message indicates this is from an owned+direct
47+
// convoy, the Refinery skips processing. These MRs shouldn't exist (gt done
48+
// skips MR creation for owned+direct), but this guards against edge cases.
49+
//
4650
// NOTE: The merge-request bead is created by `gt done`, so we no longer need
4751
// to add to the mrqueue here. The Refinery queries beads directly for ready MRs.
4852
func (h *DefaultRefineryHandler) HandleMergeReady(payload *MergeReadyPayload) error {
@@ -51,6 +55,13 @@ func (h *DefaultRefineryHandler) HandleMergeReady(payload *MergeReadyPayload) er
5155
_, _ = fmt.Fprintf(h.Output, " Issue: %s\n", payload.Issue)
5256
_, _ = fmt.Fprintf(h.Output, " Verified: %s\n", payload.Verified)
5357

58+
// Belt-and-suspenders: check if this is from an owned+direct convoy.
59+
// The Verified field may contain "owned+direct" marker from witness.
60+
if payload.Verified == "owned+direct: skip merge" {
61+
_, _ = fmt.Fprintf(h.Output, "[Refinery] ⚠ Owned+direct convoy — skipping merge (belt-and-suspenders)\n")
62+
return nil
63+
}
64+
5465
// The merge-request bead is created by `gt done` with gt:merge-request label.
5566
// The Refinery queries beads directly via ReadyWithType("merge-request").
5667
// No need to add to mrqueue - that was a duplicate tracking file.

internal/protocol/types.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,44 @@ type ReworkRequestPayload struct {
166166
Instructions string `json:"instructions,omitempty"`
167167
}
168168

169+
// PolecatDonePayload contains the data from a POLECAT_DONE notification.
170+
// This is not a formal protocol message (it's a mail convention), but the
171+
// payload is structured for programmatic parsing by witness handlers.
172+
type PolecatDonePayload struct {
173+
// Polecat is the worker name.
174+
Polecat string `json:"polecat"`
175+
176+
// ExitType is the exit status (COMPLETED, ESCALATED, DEFERRED, PHASE_COMPLETE).
177+
ExitType string `json:"exit_type"`
178+
179+
// Issue is the beads issue ID the polecat worked on.
180+
Issue string `json:"issue,omitempty"`
181+
182+
// Branch is the polecat's work branch.
183+
Branch string `json:"branch"`
184+
185+
// MR is the merge-request bead ID (empty for owned+direct convoys).
186+
MR string `json:"mr,omitempty"`
187+
188+
// ConvoyID is the tracking convoy ID (if any).
189+
ConvoyID string `json:"convoy_id,omitempty"`
190+
191+
// ConvoyOwned indicates the convoy has caller-managed lifecycle.
192+
ConvoyOwned bool `json:"convoy_owned,omitempty"`
193+
194+
// MergeStrategy is the convoy's merge strategy (direct, mr, local).
195+
MergeStrategy string `json:"merge_strategy,omitempty"`
196+
197+
// Errors contains any non-fatal errors encountered during gt done.
198+
Errors string `json:"errors,omitempty"`
199+
}
200+
201+
// SkipMergeFlow returns true if this polecat's work should bypass the
202+
// standard witness/refinery merge pipeline (owned convoy + direct merge).
203+
func (p *PolecatDonePayload) SkipMergeFlow() bool {
204+
return p.ConvoyOwned && p.MergeStrategy == "direct"
205+
}
206+
169207
// IsProtocolMessage returns true if the subject matches a known protocol type.
170208
func IsProtocolMessage(subject string) bool {
171209
return ParseMessageType(subject) != ""

internal/protocol/witness_handlers.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,49 @@ func (h *DefaultWitnessHandler) HandleMergeFailed(payload *MergeFailedPayload) e
100100
return nil
101101
}
102102

103+
// HandlePolecatDone handles a POLECAT_DONE notification.
104+
// When a polecat signals completion, the Witness decides whether to register
105+
// the work for merge processing or skip it (for owned+direct convoys).
106+
//
107+
// For standard convoys: the merge pipeline proceeds normally (MR bead exists,
108+
// refinery will process it).
109+
//
110+
// For owned+direct convoys: the polecat already pushed to main and closed its
111+
// issue. The witness skips merge flow registration — only cleanup remains.
112+
func (h *DefaultWitnessHandler) HandlePolecatDone(payload *PolecatDonePayload) error {
113+
_, _ = fmt.Fprintf(h.Output, "[Witness] POLECAT_DONE received for polecat %s\n", payload.Polecat)
114+
_, _ = fmt.Fprintf(h.Output, " Exit: %s\n", payload.ExitType)
115+
if payload.Issue != "" {
116+
_, _ = fmt.Fprintf(h.Output, " Issue: %s\n", payload.Issue)
117+
}
118+
_, _ = fmt.Fprintf(h.Output, " Branch: %s\n", payload.Branch)
119+
120+
if payload.SkipMergeFlow() {
121+
_, _ = fmt.Fprintf(h.Output, "[Witness] ✓ Owned+direct convoy %s — merge flow skipped\n", payload.ConvoyID)
122+
_, _ = fmt.Fprintf(h.Output, " Polecat already pushed to main. Proceeding with cleanup only.\n")
123+
124+
// Initiate polecat cleanup (same as HandleMerged)
125+
nukeResult := witness.AutoNukeIfClean(h.WorkDir, h.Rig, payload.Polecat)
126+
if nukeResult.Nuked {
127+
fmt.Fprintf(h.Output, "[Witness] ✓ Auto-nuked polecat %s: %s\n", payload.Polecat, nukeResult.Reason)
128+
} else if nukeResult.Skipped {
129+
fmt.Fprintf(h.Output, "[Witness] ⚠ Cleanup skipped for %s: %s\n", payload.Polecat, nukeResult.Reason)
130+
} else if nukeResult.Error != nil {
131+
fmt.Fprintf(h.Output, "[Witness] ✗ Cleanup failed for %s: %v\n", payload.Polecat, nukeResult.Error)
132+
}
133+
134+
return nil
135+
}
136+
137+
// Standard flow: log receipt, merge pipeline will handle the rest
138+
if payload.MR != "" {
139+
_, _ = fmt.Fprintf(h.Output, " MR: %s\n", payload.MR)
140+
}
141+
_, _ = fmt.Fprintf(h.Output, "[Witness] ✓ Standard flow — Refinery will process MR\n")
142+
143+
return nil
144+
}
145+
103146
// HandleReworkRequest handles a REWORK_REQUEST message from Refinery.
104147
// When a branch has conflicts requiring rebase, the Witness:
105148
// 1. Logs the conflict

internal/refinery/engineer.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,14 @@ func (e *Engineer) ListReadyMRs() ([]*MRInfo, error) {
10081008
continue
10091009
}
10101010

1011+
// Belt-and-suspenders: skip MRs labeled gt:owned-direct.
1012+
// These MRs shouldn't exist (gt done skips MR creation for owned+direct
1013+
// convoys), but if one slips through, the refinery should not process it.
1014+
if beads.HasLabel(issue, "gt:owned-direct") {
1015+
_, _ = fmt.Fprintf(e.output, "[Engineer] Skipping MR %s: owned+direct convoy (belt-and-suspenders)\n", issue.ID)
1016+
continue
1017+
}
1018+
10111019
fields := beads.ParseMRFields(issue)
10121020
if fields == nil {
10131021
continue // Skip issues without MR fields

0 commit comments

Comments
 (0)