Skip to content

Commit ced820b

Browse files
fix: prevent auto-close of stuck convoys with tracked but unready issues
FeedStranded() was treating ReadyCount==0 as 'empty convoy' and auto-closing. But ReadyCount==0 just means no issues pass the ready filter — the convoy can still have open tracked issues (stuck, not empty). Now checks TrackedCount before closing: only close if total tracked == 0. Changes: - Add TrackedCount field to StrandedConvoy/strandedConvoyInfo structs - FeedStranded: distinguish stuck (tracked>0, ready==0) from empty (tracked==0) - findStrandedConvoys: emit stuck convoys in stranded list with TrackedCount - convoy stranded UI: show stuck convoy state separately from empty/feedable - Add TestFindStrandedConvoys_StuckConvoy test Closes: gt-2qoj Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0845edd commit ced820b

3 files changed

Lines changed: 151 additions & 24 deletions

File tree

internal/cmd/convoy.go

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,14 @@ Examples:
284284

285285
var convoyStrandedCmd = &cobra.Command{
286286
Use: "stranded",
287-
Short: "Find stranded convoys (ready work or empty) needing attention",
287+
Short: "Find stranded convoys (ready work, stuck, or empty) needing attention",
288288
Long: `Find convoys that have ready issues but no workers processing them,
289-
or empty convoys (0 tracked issues) that need cleanup.
289+
stuck convoys (tracked issues but none ready), or empty convoys that need cleanup.
290290
291291
A convoy is "stranded" when:
292292
- Convoy is open AND either:
293293
- Has tracked issues that are ready but unassigned, OR
294+
- Has tracked issues but none are ready (stuck — waiting on dependencies/workers), OR
294295
- Has 0 tracked issues (empty — needs auto-close via convoy check)
295296
296297
Use this to detect convoys that need feeding or cleanup. The Deacon patrol
@@ -1161,10 +1162,11 @@ func removePolecatWorktree(wt convoyWorktreeInfo) error {
11611162

11621163
// strandedConvoyInfo holds info about a stranded convoy.
11631164
type strandedConvoyInfo struct {
1164-
ID string `json:"id"`
1165-
Title string `json:"title"`
1166-
ReadyCount int `json:"ready_count"`
1167-
ReadyIssues []string `json:"ready_issues"`
1165+
ID string `json:"id"`
1166+
Title string `json:"title"`
1167+
TrackedCount int `json:"tracked_count"`
1168+
ReadyCount int `json:"ready_count"`
1169+
ReadyIssues []string `json:"ready_issues"`
11681170
}
11691171

11701172
// readyIssueInfo holds info about a ready (stranded) issue.
@@ -1199,22 +1201,26 @@ func runConvoyStranded(cmd *cobra.Command, args []string) error {
11991201
fmt.Printf("%s Found %d stranded convoy(s):\n\n", style.Warning.Render("⚠"), len(stranded))
12001202
for _, s := range stranded {
12011203
fmt.Printf(" 🚚 %s: %s\n", s.ID, s.Title)
1202-
if s.ReadyCount == 0 {
1204+
if s.ReadyCount == 0 && s.TrackedCount == 0 {
12031205
fmt.Printf(" Empty convoy (0 tracked issues) — needs cleanup\n")
1206+
} else if s.ReadyCount == 0 && s.TrackedCount > 0 {
1207+
fmt.Printf(" Stuck convoy (%d tracked issues, 0 ready)\n", s.TrackedCount)
12041208
} else {
1205-
fmt.Printf(" Ready issues: %d\n", s.ReadyCount)
1209+
fmt.Printf(" Ready issues: %d (of %d tracked)\n", s.ReadyCount, s.TrackedCount)
12061210
for _, issueID := range s.ReadyIssues {
12071211
fmt.Printf(" • %s\n", issueID)
12081212
}
12091213
}
12101214
fmt.Println()
12111215
}
12121216

1213-
// Separate feed advice (convoys with ready work) from cleanup advice (empty convoys).
1214-
var feedable, empty []strandedConvoyInfo
1217+
// Separate feed advice, stuck convoys, and cleanup advice.
1218+
var feedable, stuck, empty []strandedConvoyInfo
12151219
for _, s := range stranded {
12161220
if s.ReadyCount > 0 {
12171221
feedable = append(feedable, s)
1222+
} else if s.TrackedCount > 0 {
1223+
stuck = append(stuck, s)
12181224
} else {
12191225
empty = append(empty, s)
12201226
}
@@ -1226,10 +1232,19 @@ func runConvoyStranded(cmd *cobra.Command, args []string) error {
12261232
fmt.Printf(" gt sling mol-convoy-feed deacon/dogs --var convoy=%s\n", s.ID)
12271233
}
12281234
}
1229-
if len(empty) > 0 {
1235+
if len(stuck) > 0 {
12301236
if len(feedable) > 0 {
12311237
fmt.Println()
12321238
}
1239+
fmt.Println("Stuck convoys (tracked issues exist but none are ready):")
1240+
for _, s := range stuck {
1241+
fmt.Printf(" 🚚 %s (%d tracked)\n", s.ID, s.TrackedCount)
1242+
}
1243+
}
1244+
if len(empty) > 0 {
1245+
if len(feedable) > 0 || len(stuck) > 0 {
1246+
fmt.Println()
1247+
}
12331248
fmt.Println("To close empty convoys, run:")
12341249
for _, s := range empty {
12351250
fmt.Printf(" gt convoy check %s\n", s.ID)
@@ -1276,10 +1291,11 @@ func findStrandedConvoys(townBeads string) ([]strandedConvoyInfo, error) {
12761291
// attention (auto-close via convoy check or manual cleanup).
12771292
if len(tracked) == 0 {
12781293
stranded = append(stranded, strandedConvoyInfo{
1279-
ID: convoy.ID,
1280-
Title: convoy.Title,
1281-
ReadyCount: 0,
1282-
ReadyIssues: []string{},
1294+
ID: convoy.ID,
1295+
Title: convoy.Title,
1296+
TrackedCount: 0,
1297+
ReadyCount: 0,
1298+
ReadyIssues: []string{},
12831299
})
12841300
continue
12851301
}
@@ -1312,10 +1328,22 @@ func findStrandedConvoys(townBeads string) ([]strandedConvoyInfo, error) {
13121328

13131329
if len(readyIssues) > 0 {
13141330
stranded = append(stranded, strandedConvoyInfo{
1315-
ID: convoy.ID,
1316-
Title: convoy.Title,
1317-
ReadyCount: len(readyIssues),
1318-
ReadyIssues: readyIssues,
1331+
ID: convoy.ID,
1332+
Title: convoy.Title,
1333+
TrackedCount: len(tracked),
1334+
ReadyCount: len(readyIssues),
1335+
ReadyIssues: readyIssues,
1336+
})
1337+
} else {
1338+
// Stuck convoy: has tracked issues but none are ready.
1339+
// Include in stranded list so callers (e.g., FeedStranded)
1340+
// can distinguish stuck from truly empty.
1341+
stranded = append(stranded, strandedConvoyInfo{
1342+
ID: convoy.ID,
1343+
Title: convoy.Title,
1344+
TrackedCount: len(tracked),
1345+
ReadyCount: 0,
1346+
ReadyIssues: []string{},
13191347
})
13201348
}
13211349
}

internal/cmd/convoy_empty_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ esac
233233
if empty.ReadyCount != 0 {
234234
t.Errorf("empty convoy ReadyCount = %d, want 0", empty.ReadyCount)
235235
}
236+
if empty.TrackedCount != 0 {
237+
t.Errorf("empty convoy TrackedCount = %d, want 0", empty.TrackedCount)
238+
}
236239
if len(empty.ReadyIssues) != 0 {
237240
t.Errorf("empty convoy ReadyIssues = %v, want empty", empty.ReadyIssues)
238241
}
@@ -245,6 +248,9 @@ esac
245248
if feedable.ReadyCount != 1 {
246249
t.Errorf("feedable convoy ReadyCount = %d, want 1", feedable.ReadyCount)
247250
}
251+
if feedable.TrackedCount != 1 {
252+
t.Errorf("feedable convoy TrackedCount = %d, want 1", feedable.TrackedCount)
253+
}
248254
if len(feedable.ReadyIssues) != 1 || feedable.ReadyIssues[0] != "gt-ready1" {
249255
t.Errorf("feedable convoy ReadyIssues = %v, want [gt-ready1]", feedable.ReadyIssues)
250256
}
@@ -258,4 +264,84 @@ esac
258264
if strings.Contains(jsonStr, `"ready_issues":null`) {
259265
t.Error("JSON output contains ready_issues:null — should be [] for empty convoys")
260266
}
267+
// Verify tracked_count appears in JSON
268+
if !strings.Contains(jsonStr, `"tracked_count"`) {
269+
t.Error("JSON output missing tracked_count field")
270+
}
271+
}
272+
273+
// TestFindStrandedConvoys_StuckConvoy verifies that a convoy with tracked
274+
// issues but none ready (stuck) is included in the stranded list with
275+
// TrackedCount > 0 and ReadyCount == 0, preventing accidental auto-close.
276+
func TestFindStrandedConvoys_StuckConvoy(t *testing.T) {
277+
if runtime.GOOS == "windows" {
278+
t.Skip("skipping convoy test on Windows")
279+
}
280+
281+
binDir := t.TempDir()
282+
townRoot := t.TempDir()
283+
townBeads := filepath.Join(townRoot, ".beads")
284+
if err := os.MkdirAll(townBeads, 0755); err != nil {
285+
t.Fatalf("mkdir townBeads: %v", err)
286+
}
287+
if err := os.WriteFile(filepath.Join(townBeads, "routes.jsonl"), []byte(`{"prefix":"gt-","path":"gastown/mayor/rig"}`+"\n"), 0644); err != nil {
288+
t.Fatalf("write routes: %v", err)
289+
}
290+
291+
bdPath := filepath.Join(binDir, "bd")
292+
293+
// Mock bd: convoy has tracked issues but all are blocked — none are ready.
294+
script := `#!/bin/sh
295+
i=0
296+
for arg in "$@"; do
297+
case "$arg" in
298+
--*) ;;
299+
*) eval "pos$i=\"$arg\""; i=$((i+1)) ;;
300+
esac
301+
done
302+
303+
case "$pos0" in
304+
list)
305+
echo '[{"id":"hq-stuck1","title":"Stuck convoy"}]'
306+
exit 0
307+
;;
308+
dep)
309+
# All tracked issues are open but blocked — none are ready
310+
echo '[{"id":"gt-busy1","title":"Blocked issue 1","status":"open","issue_type":"task","assignee":"","dependency_type":"tracks"},{"id":"gt-busy2","title":"Blocked issue 2","status":"open","issue_type":"task","assignee":"","dependency_type":"tracks"}]'
311+
exit 0
312+
;;
313+
show)
314+
# Both issues have blockers so isReadyIssue returns false
315+
echo '[{"id":"gt-busy1","title":"Blocked issue 1","status":"open","issue_type":"task","assignee":"","blocked_by":["gt-blocker1"],"blocked_by_count":1,"dependencies":[]},{"id":"gt-busy2","title":"Blocked issue 2","status":"open","issue_type":"task","assignee":"","blocked_by":["gt-blocker1"],"blocked_by_count":1,"dependencies":[]}]'
316+
exit 0
317+
;;
318+
*)
319+
exit 0
320+
;;
321+
esac
322+
`
323+
if err := os.WriteFile(bdPath, []byte(script), 0755); err != nil {
324+
t.Fatalf("write mock bd: %v", err)
325+
}
326+
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
327+
328+
stranded, err := findStrandedConvoys(townBeads)
329+
if err != nil {
330+
t.Fatalf("findStrandedConvoys() error: %v", err)
331+
}
332+
333+
if len(stranded) != 1 {
334+
t.Fatalf("expected 1 stranded convoy (stuck), got %d", len(stranded))
335+
}
336+
337+
s := stranded[0]
338+
if s.ID != "hq-stuck1" {
339+
t.Errorf("stranded convoy ID = %q, want %q", s.ID, "hq-stuck1")
340+
}
341+
if s.TrackedCount != 2 {
342+
t.Errorf("stuck convoy TrackedCount = %d, want 2", s.TrackedCount)
343+
}
344+
if s.ReadyCount != 0 {
345+
t.Errorf("stuck convoy ReadyCount = %d, want 0", s.ReadyCount)
346+
}
261347
}

internal/deacon/feed_stranded.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ type ConvoyFeedState struct {
4444

4545
// StrandedConvoy holds info about a stranded convoy from `gt convoy stranded --json`.
4646
type StrandedConvoy struct {
47-
ID string `json:"id"`
48-
Title string `json:"title"`
49-
ReadyCount int `json:"ready_count"`
50-
ReadyIssues []string `json:"ready_issues"`
47+
ID string `json:"id"`
48+
Title string `json:"title"`
49+
TrackedCount int `json:"tracked_count"`
50+
ReadyCount int `json:"ready_count"`
51+
ReadyIssues []string `json:"ready_issues"`
5152
}
5253

5354
// FeedResult describes the outcome of a feed-stranded invocation.
@@ -226,8 +227,20 @@ func FeedStranded(townRoot string, maxPerCycle int, cooldown time.Duration) *Fee
226227
fedCount := 0
227228

228229
for _, convoy := range stranded {
229-
// Handle empty convoys (auto-close) — no rate limit needed
230+
// Handle convoys with no ready issues.
230231
if convoy.ReadyCount == 0 {
232+
// Stuck convoy: has tracked issues but none are ready.
233+
// Don't close — the convoy is waiting, not empty.
234+
if convoy.TrackedCount > 0 {
235+
result.Details = append(result.Details, FeedConvoyResult{
236+
ConvoyID: convoy.ID,
237+
Action: "stuck",
238+
Message: fmt.Sprintf("stuck convoy (%d tracked issues, 0 ready) — skipping", convoy.TrackedCount),
239+
})
240+
continue
241+
}
242+
243+
// Truly empty convoy (0 tracked issues) — auto-close
231244
if err := closeEmptyConvoy(townRoot, convoy.ID); err != nil {
232245
result.Errors++
233246
result.Details = append(result.Details, FeedConvoyResult{

0 commit comments

Comments
 (0)