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
1 change: 1 addition & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type LiveSession struct {
type RetryEntry struct {
IssueID string
Identifier string
IssueTitle string
Attempt int // 1-based
DueAtMS int64
Error string
Expand Down
13 changes: 9 additions & 4 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ func (o *Orchestrator) onWorkerExit(issueID string, entry *model.RunningEntry, e
nextAttempt := entry.RetryAttempt + 1
delay := o.calculateBackoff(nextAttempt)

o.scheduleRetry(issueID, entry.Identifier, nextAttempt, err.Error(), delay)
o.scheduleRetry(issueID, entry.Identifier, entry.Issue.Title, nextAttempt, err.Error(), delay)

o.logger.Warn("worker failed",
"issue_id", issueID,
Expand All @@ -665,14 +665,15 @@ func (o *Orchestrator) calculateBackoff(attempt int) int64 {
return delay
}

func (o *Orchestrator) scheduleRetry(issueID, identifier string, attempt int, errMsg string, delayMS int64) {
func (o *Orchestrator) scheduleRetry(issueID, identifier, issueTitle string, attempt int, errMsg string, delayMS int64) {
// Cancel existing retry timer
delete(o.retryAttempts, issueID)

dueAt := time.Now().UnixMilli() + delayMS
entry := &model.RetryEntry{
IssueID: issueID,
Identifier: identifier,
IssueTitle: issueTitle,
Attempt: attempt,
DueAtMS: dueAt,
Error: errMsg,
Expand Down Expand Up @@ -701,7 +702,7 @@ func (o *Orchestrator) onRetryTimer(issueID string) {
issues, err := o.linear.FetchCandidateIssues(ctx, o.cfg.TrackerActiveStates)
if err != nil {
o.mu.Lock()
o.scheduleRetry(issueID, entry.Identifier, entry.Attempt+1, "retry poll failed", o.calculateBackoff(entry.Attempt+1))
o.scheduleRetry(issueID, entry.Identifier, entry.IssueTitle, entry.Attempt+1, "retry poll failed", o.calculateBackoff(entry.Attempt+1))
o.mu.Unlock()
return
}
Expand Down Expand Up @@ -738,7 +739,7 @@ func (o *Orchestrator) onRetryTimer(issueID string) {

if slots <= 0 {
o.mu.Lock()
o.scheduleRetry(issueID, entry.Identifier, entry.Attempt+1, "no available orchestrator slots", o.calculateBackoff(entry.Attempt+1))
o.scheduleRetry(issueID, entry.Identifier, entry.IssueTitle, entry.Attempt+1, "no available orchestrator slots", o.calculateBackoff(entry.Attempt+1))
o.mu.Unlock()
return
}
Expand Down Expand Up @@ -885,6 +886,7 @@ func (o *Orchestrator) Snapshot() StateSnapshot {
snap.Running = append(snap.Running, RunningSnapshot{
IssueID: entry.IssueID,
IssueIdentifier: entry.Identifier,
IssueTitle: entry.Issue.Title,
State: entry.Issue.State,
SessionID: entry.Session.SessionID,
TurnCount: entry.Session.TurnCount,
Expand All @@ -904,6 +906,7 @@ func (o *Orchestrator) Snapshot() StateSnapshot {
snap.Retrying = append(snap.Retrying, RetrySnapshot{
IssueID: entry.IssueID,
IssueIdentifier: entry.Identifier,
IssueTitle: entry.IssueTitle,
Attempt: entry.Attempt,
DueAt: time.UnixMilli(entry.DueAtMS).UTC(),
Error: entry.Error,
Expand All @@ -925,6 +928,7 @@ type StateSnapshot struct {
type RunningSnapshot struct {
IssueID string `json:"issue_id"`
IssueIdentifier string `json:"issue_identifier"`
IssueTitle string `json:"issue_title"`
State string `json:"state"`
SessionID string `json:"session_id"`
TurnCount int `json:"turn_count"`
Expand All @@ -944,6 +948,7 @@ type TokenSnapshot struct {
type RetrySnapshot struct {
IssueID string `json:"issue_id"`
IssueIdentifier string `json:"issue_identifier"`
IssueTitle string `json:"issue_title"`
Attempt int `json:"attempt"`
DueAt time.Time `json:"due_at"`
Error string `json:"error"`
Expand Down
50 changes: 50 additions & 0 deletions internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,56 @@ func TestSortForDispatch_SamePrioritySameTime(t *testing.T) {
}
}

func TestSnapshotIncludesIssueTitle(t *testing.T) {
o := &Orchestrator{
running: make(map[string]*model.RunningEntry),
retryAttempts: make(map[string]*model.RetryEntry),
}

o.running["issue-1"] = &model.RunningEntry{
IssueID: "issue-1",
Identifier: "ABC-1",
Issue: model.Issue{
ID: "issue-1",
Identifier: "ABC-1",
Title: "Fix the login bug",
State: "In Progress",
},
StartedAt: time.Now(),
}

o.retryAttempts["issue-2"] = &model.RetryEntry{
IssueID: "issue-2",
Identifier: "ABC-2",
IssueTitle: "Add dark mode",
Attempt: 1,
DueAtMS: time.Now().Add(10 * time.Second).UnixMilli(),
Error: "timeout",
}

snap := o.Snapshot()

if len(snap.Running) != 1 {
t.Fatalf("expected 1 running entry, got %d", len(snap.Running))
}
if snap.Running[0].IssueTitle != "Fix the login bug" {
t.Errorf("running snapshot: expected title %q, got %q", "Fix the login bug", snap.Running[0].IssueTitle)
}
if snap.Running[0].IssueIdentifier != "ABC-1" {
t.Errorf("running snapshot: expected identifier %q, got %q", "ABC-1", snap.Running[0].IssueIdentifier)
}

if len(snap.Retrying) != 1 {
t.Fatalf("expected 1 retrying entry, got %d", len(snap.Retrying))
}
if snap.Retrying[0].IssueTitle != "Add dark mode" {
t.Errorf("retry snapshot: expected title %q, got %q", "Add dark mode", snap.Retrying[0].IssueTitle)
}
if snap.Retrying[0].IssueIdentifier != "ABC-2" {
t.Errorf("retry snapshot: expected identifier %q, got %q", "ABC-2", snap.Retrying[0].IssueIdentifier)
}
}

func TestSortForDispatch_NilPriorityLast(t *testing.T) {
now := time.Now()
issues := []model.Issue{
Expand Down
20 changes: 13 additions & 7 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ import (
"github.com/jordan/go-symphony/internal/orchestrator"
)

// orchClient is the subset of orchestrator.Orchestrator used by the server.
type orchClient interface {
Snapshot() orchestrator.StateSnapshot
TriggerPoll(ctx context.Context)
}

// Server is the optional HTTP observability server.
type Server struct {
orch *orchestrator.Orchestrator
orch orchClient
logger *slog.Logger
srv *http.Server
addr string
Expand Down Expand Up @@ -109,11 +115,11 @@ Runtime: %.1fs
if len(snap.Running) > 0 {
fmt.Fprintf(w, `<h2 class="running">Running (%d)</h2>
<table>
<tr><th>Issue</th><th>State</th><th>Session</th><th>Turns</th><th>Last Event</th><th>Started</th><th>Tokens</th></tr>
<tr><th>Issue</th><th>Title</th><th>State</th><th>Session</th><th>Turns</th><th>Last Event</th><th>Started</th><th>Tokens</th></tr>
`, len(snap.Running))
for _, r := range snap.Running {
fmt.Fprintf(w, "<tr><td>%s</td><td>%s</td><td>%s</td><td>%d</td><td>%s</td><td>%s</td><td>%d/%d/%d</td></tr>\n",
r.IssueIdentifier, r.State, r.SessionID, r.TurnCount, r.LastEvent,
fmt.Fprintf(w, "<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%d</td><td>%s</td><td>%s</td><td>%d/%d/%d</td></tr>\n",
r.IssueIdentifier, r.IssueTitle, r.State, r.SessionID, r.TurnCount, r.LastEvent,
r.StartedAt.Format("15:04:05"),
r.Tokens.InputTokens, r.Tokens.OutputTokens, r.Tokens.TotalTokens,
)
Expand All @@ -124,11 +130,11 @@ Runtime: %.1fs
if len(snap.Retrying) > 0 {
fmt.Fprintf(w, `<h2 class="retrying">Retrying (%d)</h2>
<table>
<tr><th>Issue</th><th>Attempt</th><th>Due At</th><th>Error</th></tr>
<tr><th>Issue</th><th>Title</th><th>Attempt</th><th>Due At</th><th>Error</th></tr>
`, len(snap.Retrying))
for _, r := range snap.Retrying {
fmt.Fprintf(w, "<tr><td>%s</td><td>%d</td><td>%s</td><td>%s</td></tr>\n",
r.IssueIdentifier, r.Attempt, r.DueAt.Format("15:04:05"), r.Error,
fmt.Fprintf(w, "<tr><td>%s</td><td>%s</td><td>%d</td><td>%s</td><td>%s</td></tr>\n",
r.IssueIdentifier, r.IssueTitle, r.Attempt, r.DueAt.Format("15:04:05"), r.Error,
)
}
fmt.Fprintf(w, "</table>\n")
Expand Down
74 changes: 74 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package server

import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/jordan/go-symphony/internal/orchestrator"
)

type fakeOrch struct {
snap orchestrator.StateSnapshot
}

func (f *fakeOrch) Snapshot() orchestrator.StateSnapshot { return f.snap }
func (f *fakeOrch) TriggerPoll(_ context.Context) {}

func newTestServer(snap orchestrator.StateSnapshot) *httptest.Server {
s := &Server{orch: &fakeOrch{snap: snap}}
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.handleDashboard)
return httptest.NewServer(mux)
}

func TestDashboardShowsIssueTitle(t *testing.T) {
now := time.Now()
snap := orchestrator.StateSnapshot{
GeneratedAt: now,
Running: []orchestrator.RunningSnapshot{
{
IssueID: "id-1",
IssueIdentifier: "ZYX-99",
IssueTitle: "Fix the login bug",
State: "In Progress",
StartedAt: now,
},
},
Retrying: []orchestrator.RetrySnapshot{
{
IssueID: "id-2",
IssueIdentifier: "ZYX-100",
IssueTitle: "Add dark mode",
Attempt: 1,
DueAt: now.Add(10 * time.Second),
Error: "timeout",
},
},
}

ts := newTestServer(snap)
defer ts.Close()

resp, err := http.Get(ts.URL + "/") //nolint:noctx // test-only HTTP call
if err != nil {
t.Fatalf("GET /: %v", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
html := string(body)

for _, want := range []string{"Fix the login bug", "ZYX-99", "Add dark mode", "ZYX-100"} {
if !strings.Contains(html, want) {
t.Errorf("dashboard missing %q", want)
}
}
}
Loading