diff --git a/internal/linear/client.go b/internal/linear/client.go index 882c8c4..e6ef333 100644 --- a/internal/linear/client.go +++ b/internal/linear/client.go @@ -223,6 +223,92 @@ func (c *Client) CreateComment(ctx context.Context, issueID, body string) error return nil } +// FetchTeamID returns the team ID for the given issue. +func (c *Client) FetchTeamID(ctx context.Context, issueID string) (string, error) { + query := `query($issueId: String!) { + issue(id: $issueId) { + team { id } + } + }` + + resp, err := c.doQuery(ctx, query, map[string]any{"issueId": issueID}) + if err != nil { + return "", fmt.Errorf("linear_api_request: fetch team id: %w", err) + } + + var result struct { + Data struct { + Issue struct { + Team struct { + ID string `json:"id"` + } `json:"team"` + } `json:"issue"` + } `json:"data"` + Errors []graphqlError `json:"errors"` + } + if err := json.Unmarshal(resp, &result); err != nil { + return "", fmt.Errorf("linear_unknown_payload: %w", err) + } + if len(result.Errors) > 0 { + return "", fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message) + } + if result.Data.Issue.Team.ID == "" { + return "", fmt.Errorf("linear_team_not_found: no team found for issue %s", issueID) + } + + return result.Data.Issue.Team.ID, nil +} + +// CreateIssue creates a new Linear issue in the given team and returns its identifier (e.g. "ZYX-99"). +func (c *Client) CreateIssue(ctx context.Context, teamID, title, description string) (string, error) { + mutation := `mutation($teamId: String!, $title: String!, $description: String) { + issueCreate(input: { teamId: $teamId, title: $title, description: $description }) { + success + issue { + id + identifier + } + } + }` + + variables := map[string]any{ + "teamId": teamID, + "title": title, + } + if description != "" { + variables["description"] = description + } + + resp, err := c.doQuery(ctx, mutation, variables) + if err != nil { + return "", fmt.Errorf("linear_api_request: create issue: %w", err) + } + + var result struct { + Data struct { + IssueCreate struct { + Success bool `json:"success"` + Issue struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + } `json:"issue"` + } `json:"issueCreate"` + } `json:"data"` + Errors []graphqlError `json:"errors"` + } + if err := json.Unmarshal(resp, &result); err != nil { + return "", fmt.Errorf("linear_unknown_payload: %w", err) + } + if len(result.Errors) > 0 { + return "", fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message) + } + if !result.Data.IssueCreate.Success { + return "", fmt.Errorf("linear_create_issue_failed: issueCreate returned success=false") + } + + return result.Data.IssueCreate.Issue.Identifier, nil +} + // ExecuteGraphQL runs a raw GraphQL query (for the linear_graphql tool extension). func (c *Client) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any) (json.RawMessage, error) { return c.doQuery(ctx, query, variables) diff --git a/internal/linear/client_test.go b/internal/linear/client_test.go index b68dbf8..444a522 100644 --- a/internal/linear/client_test.go +++ b/internal/linear/client_test.go @@ -102,3 +102,159 @@ func TestCreateComment_HTTPError(t *testing.T) { t.Fatal("expected error, got nil") } } + +func TestFetchTeamID_Success(t *testing.T) { + called := false + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + vars := req["variables"].(map[string]any) + if vars["issueId"] != "issue-abc" { + t.Errorf("expected issueId=issue-abc, got %v", vars["issueId"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "issue": map[string]any{ + "team": map[string]any{"id": "team-xyz"}, + }, + }, + }) + }) + + teamID, err := client.FetchTeamID(context.Background(), "issue-abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if teamID != "team-xyz" { + t.Errorf("expected team-xyz, got %s", teamID) + } + if !called { + t.Fatal("handler was not called") + } +} + +func TestFetchTeamID_GraphQLError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": "not found"}}, + }) + }) + + _, err := client.FetchTeamID(context.Background(), "issue-abc") + if err == nil { + t.Fatal("expected error, got nil") + } + if want := "linear_graphql_errors: not found"; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} + +func TestFetchTeamID_EmptyTeam(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "issue": map[string]any{ + "team": map[string]any{"id": ""}, + }, + }, + }) + }) + + _, err := client.FetchTeamID(context.Background(), "issue-abc") + if err == nil { + t.Fatal("expected error, got nil") + } + if want := "linear_team_not_found: no team found for issue issue-abc"; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} + +func TestCreateIssue_Success(t *testing.T) { + called := false + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + vars := req["variables"].(map[string]any) + if vars["teamId"] != "team-xyz" { + t.Errorf("expected teamId=team-xyz, got %v", vars["teamId"]) + } + if vars["title"] != "Bug: nil pointer in handler" { + t.Errorf("expected title='Bug: nil pointer in handler', got %v", vars["title"]) + } + if vars["description"] != "Details here" { + t.Errorf("expected description='Details here', got %v", vars["description"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "issueCreate": map[string]any{ + "success": true, + "issue": map[string]any{"id": "new-id-1", "identifier": "ZYX-99"}, + }, + }, + }) + }) + + identifier, err := client.CreateIssue(context.Background(), "team-xyz", "Bug: nil pointer in handler", "Details here") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if identifier != "ZYX-99" { + t.Errorf("expected ZYX-99, got %s", identifier) + } + if !called { + t.Fatal("handler was not called") + } +} + +func TestCreateIssue_GraphQLError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": "unauthorized"}}, + }) + }) + + _, err := client.CreateIssue(context.Background(), "team-xyz", "title", "desc") + if err == nil { + t.Fatal("expected error, got nil") + } + if want := "linear_graphql_errors: unauthorized"; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} + +func TestCreateIssue_SuccessFalse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "issueCreate": map[string]any{ + "success": false, + "issue": nil, + }, + }, + }) + }) + + _, err := client.CreateIssue(context.Background(), "team-xyz", "title", "desc") + if err == nil { + t.Fatal("expected error, got nil") + } + if want := "linear_create_issue_failed: issueCreate returned success=false"; err.Error() != want { + t.Errorf("expected %q, got %q", want, err.Error()) + } +} diff --git a/internal/model/types.go b/internal/model/types.go index c0aa75a..7fdd201 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -139,3 +139,11 @@ type RateLimitInfo struct { Remaining int Reset time.Time } + +// Finding represents an issue discovered by the agent that is unrelated to the current task. +// Agents write these to .symphony/findings.json in the workspace; the orchestrator +// reads them after each turn and opens Linear issues for each one. +type Finding struct { + Title string `json:"title"` + Description string `json:"description"` +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 2976378..f65d059 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -2,9 +2,12 @@ package orchestrator import ( "context" + "encoding/json" "fmt" "log/slog" "math" + "os" + "path/filepath" "sort" "strings" "sync" @@ -402,6 +405,12 @@ func (o *Orchestrator) runWorkerCodex(ctx context.Context, issue model.Issue, at return } + // Fetch team ID once for creating findings issues later. + teamID, err := o.linear.FetchTeamID(ctx, issue.ID) + if err != nil { + o.logger.Warn("failed to fetch team ID, findings will not be reported", "issue_id", issue.ID, "error", err) + } + // Start agent session session, err := o.runner.StartSession(ctx, ws.Path) if err != nil { @@ -434,6 +443,11 @@ func (o *Orchestrator) runWorkerCodex(ctx context.Context, issue model.Issue, at return } + // Process any unrelated findings the agent surfaced. + if teamID != "" { + o.processFindings(ctx, ws.Path, issue, teamID) + } + // Check issue state refreshed, err := o.linear.FetchIssueStatesByIDs(ctx, []string{issue.ID}) if err != nil { @@ -481,6 +495,12 @@ func (o *Orchestrator) runWorkerClaude(ctx context.Context, issue model.Issue, a o.ws.RunAfterRunHook(ctx, ws.Path) }() + // Fetch team ID once for creating findings issues later. + teamID, err := o.linear.FetchTeamID(ctx, issue.ID) + if err != nil { + o.logger.Warn("failed to fetch team ID, findings will not be reported", "issue_id", issue.ID, "error", err) + } + // Start Claude session session, err := o.claudeRunner.StartClaudeSession(ctx, ws.Path) if err != nil { @@ -509,6 +529,11 @@ func (o *Orchestrator) runWorkerClaude(ctx context.Context, issue model.Issue, a return } + // Process any unrelated findings the agent surfaced. + if teamID != "" { + o.processFindings(ctx, ws.Path, issue, teamID) + } + // Check issue state refreshed, err := o.linear.FetchIssueStatesByIDs(ctx, []string{issue.ID}) if err != nil { @@ -527,13 +552,78 @@ func (o *Orchestrator) runWorkerClaude(ctx context.Context, issue model.Issue, a // Normal exit } +// findingsFilePath is the workspace-relative path agents write unrelated findings to. +const findingsFilePath = ".symphony/findings.json" + +// findingsPromptInstructions is appended to the first turn's prompt so the agent knows +// how to surface unrelated issues it discovers during its work. +const findingsPromptInstructions = ` +--- +If you discover bugs, problems, or issues that are UNRELATED to the current task, do NOT fix them. +Instead, record them so a new ticket can be opened automatically. Write a JSON file at: + .symphony/findings.json +containing an array of objects, each with "title" (string) and "description" (string) fields. +Example: + [{"title": "Null pointer in auth handler", "description": "auth/handler.go:142 can panic when token is nil. Reproduces when..."}] +Only include genuinely unrelated issues. The orchestrator will create a Linear ticket for each entry.` + +// processFindings reads .symphony/findings.json from the workspace, creates a Linear issue +// for each finding, then removes the file. Errors are logged but do not fail the worker. +func (o *Orchestrator) processFindings(ctx context.Context, workspacePath string, sourceIssue model.Issue, teamID string) { + path := filepath.Join(workspacePath, findingsFilePath) + + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + o.logger.Warn("findings: failed to read findings file", "path", path, "error", err) + } + return + } + + var findings []model.Finding + if err := json.Unmarshal(data, &findings); err != nil { + o.logger.Warn("findings: failed to parse findings file", "path", path, "error", err) + return + } + + for _, f := range findings { + if f.Title == "" { + continue + } + desc := fmt.Sprintf("Found while working on %s: %s\n\n%s", sourceIssue.Identifier, sourceIssue.Title, f.Description) + identifier, err := o.linear.CreateIssue(ctx, teamID, f.Title, desc) + if err != nil { + o.logger.Warn("findings: failed to create Linear issue", + "finding_title", f.Title, + "source_issue", sourceIssue.Identifier, + "error", err, + ) + } else { + o.logger.Info("findings: created new Linear issue", + "new_issue", identifier, + "finding_title", f.Title, + "source_issue", sourceIssue.Identifier, + ) + } + } + + // Remove the findings file so it is not processed again next turn. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + o.logger.Warn("findings: failed to remove findings file", "path", path, "error", err) + } +} + func (o *Orchestrator) buildTurnPrompt(issue model.Issue, attempt *int, turnNum, maxTurns int) (string, error) { o.mu.Lock() tmpl := o.wfDef.PromptTemplate o.mu.Unlock() if turnNum == 1 { - return workflow.RenderPrompt(tmpl, issue, attempt) + base, err := workflow.RenderPrompt(tmpl, issue, attempt) + if err != nil { + return "", err + } + return base + findingsPromptInstructions, nil } // Continuation turn - send guidance, not the full original prompt