diff --git a/internal/model/types.go b/internal/model/types.go
index 9d688fd..c0aa75a 100644
--- a/internal/model/types.go
+++ b/internal/model/types.go
@@ -88,6 +88,7 @@ type LiveSession struct {
type RetryEntry struct {
IssueID string
Identifier string
+ IssueTitle string
Attempt int // 1-based
DueAtMS int64
Error string
diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index c599216..2976378 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -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,
@@ -665,7 +665,7 @@ 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)
@@ -673,6 +673,7 @@ func (o *Orchestrator) scheduleRetry(issueID, identifier string, attempt int, er
entry := &model.RetryEntry{
IssueID: issueID,
Identifier: identifier,
+ IssueTitle: issueTitle,
Attempt: attempt,
DueAtMS: dueAt,
Error: errMsg,
@@ -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
}
@@ -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
}
@@ -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,
@@ -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,
@@ -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"`
@@ -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"`
diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go
index 33a1383..6b844f4 100644
--- a/internal/orchestrator/orchestrator_test.go
+++ b/internal/orchestrator/orchestrator_test.go
@@ -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{
diff --git a/internal/server/server.go b/internal/server/server.go
index 5bc030c..067015e 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -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
@@ -109,11 +115,11 @@ Runtime: %.1fs
if len(snap.Running) > 0 {
fmt.Fprintf(w, `
Running (%d)
-| Issue | State | Session | Turns | Last Event | Started | Tokens |
+| Issue | Title | State | Session | Turns | Last Event | Started | Tokens |
`, len(snap.Running))
for _, r := range snap.Running {
- fmt.Fprintf(w, "| %s | %s | %s | %d | %s | %s | %d/%d/%d |
\n",
- r.IssueIdentifier, r.State, r.SessionID, r.TurnCount, r.LastEvent,
+ fmt.Fprintf(w, "| %s | %s | %s | %s | %d | %s | %s | %d/%d/%d |
\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,
)
@@ -124,11 +130,11 @@ Runtime: %.1fs
if len(snap.Retrying) > 0 {
fmt.Fprintf(w, `Retrying (%d)
-| Issue | Attempt | Due At | Error |
+| Issue | Title | Attempt | Due At | Error |
`, len(snap.Retrying))
for _, r := range snap.Retrying {
- fmt.Fprintf(w, "| %s | %d | %s | %s |
\n",
- r.IssueIdentifier, r.Attempt, r.DueAt.Format("15:04:05"), r.Error,
+ fmt.Fprintf(w, "| %s | %s | %d | %s | %s |
\n",
+ r.IssueIdentifier, r.IssueTitle, r.Attempt, r.DueAt.Format("15:04:05"), r.Error,
)
}
fmt.Fprintf(w, "
\n")
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
new file mode 100644
index 0000000..6e1ffe2
--- /dev/null
+++ b/internal/server/server_test.go
@@ -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)
+ }
+ }
+}