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
6 changes: 6 additions & 0 deletions cmd/orcha/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ func main() {
// Manager tool surface (MCP). Manager sessions' Claude connects to
// /mcp/<sessionID> to drive the orchestrator.
mux.Handle("/mcp/", http.StripPrefix("/mcp", o.ManagerMCPHandler()))
// Worker tool surface (MCP): the smaller report_result/create_note/ask_user
// subset. Coding workers connect to /wmcp/<sessionID> to hand their result back.
mux.Handle("/wmcp/", http.StripPrefix("/wmcp", o.WorkerMCPHandler()))
// Follow-up tool surface (MCP): the PR-response tools (update_pr/comment_pr/…)
// plus report_result, but not the manager's spawn/publish/mark-done tools.
mux.Handle("/fmcp/", http.StripPrefix("/fmcp", o.FollowupMCPHandler()))
// The dashboard SPA (built from ui/, embedded at compile time).
mux.Handle("/", webui.Handler())

Expand Down
16 changes: 16 additions & 0 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ type Forge interface {
// HasDiff reports whether a workspace path has uncommitted/branch changes
// worth publishing.
HasDiff(ctx context.Context, workspacePath string) (bool, error)
// Diff returns the workspace's full change relative to its base (committed and
// uncommitted), led by a --stat summary so a truncated diff still shows which
// files changed. Used to attach a worker's changes to its result handoff.
// Empty (no error) when there is nothing to show or no base to compare against.
Diff(ctx context.Context, workspacePath string) (string, error)
// PushBranch pushes the workspace branch to the repo. force must be
// explicitly requested and is recorded with a reason by the caller.
PushBranch(ctx context.Context, repo, workspacePath, branch string, force bool) (headSHA string, err error)
Expand Down Expand Up @@ -132,6 +137,7 @@ type Fake struct {
mu sync.Mutex
repos map[string]bool
diffs map[string]bool // workspacePath -> has diff
diffText map[string]string // workspacePath -> Diff() output
prs map[string]*PRState // repo#number -> state
openByBranch map[string]*PRState // repo\x00branch -> open PR (for FindOpenPR)
nextNum int
Expand Down Expand Up @@ -170,6 +176,7 @@ func NewFake() *Fake {
return &Fake{
repos: map[string]bool{},
diffs: map[string]bool{},
diffText: map[string]string{},
prs: map[string]*PRState{},
openByBranch: map[string]*PRState{},
issues: map[string]Issue{},
Expand All @@ -185,6 +192,9 @@ func (f *Fake) SetRepo(repo string, exists bool) { f.mu.Lock(); f.repos[repo] =
// SetDiff marks whether a workspace path has a diff.
func (f *Fake) SetDiff(path string, has bool) { f.mu.Lock(); f.diffs[path] = has; f.mu.Unlock() }

// SetDiffText seeds the patch text Diff returns for a workspace path.
func (f *Fake) SetDiffText(path, diff string) { f.mu.Lock(); f.diffText[path] = diff; f.mu.Unlock() }

// SetPRState seeds/overrides the host state for a PR.
func (f *Fake) SetPRState(repo string, number int, st PRState) {
f.mu.Lock()
Expand Down Expand Up @@ -215,6 +225,12 @@ func (f *Fake) HasDiff(_ context.Context, workspacePath string) (bool, error) {
return true, nil // default: has changes
}

func (f *Fake) Diff(_ context.Context, workspacePath string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.diffText[workspacePath], nil
}

func (f *Fake) CommitAll(_ context.Context, workspacePath, message string) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down
32 changes: 32 additions & 0 deletions internal/forge/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ func (g *GitForge) HasDiff(ctx context.Context, workspacePath string) (bool, err
return n > 0, nil
}

// Diff returns the workspace's change relative to its base, led by a `--stat`
// summary followed by the full unified patch. Comparing the working tree to the
// base (a two-dot `git diff <base>`) captures both committed and still-uncommitted
// changes, so it reflects everything the worker did. When no base can be resolved
// (e.g. a brand-new repo), it falls back to the uncommitted working-tree diff.
func (g *GitForge) Diff(ctx context.Context, workspacePath string) (string, error) {
base, err := g.resolveBase(ctx, workspacePath)
if err != nil {
base = ""
}
target := base
if target == "" {
target = "HEAD" // no base: show only what is not yet committed
}
stat, err := g.git(ctx, workspacePath, "diff", "--stat", target)
if err != nil {
return "", err
}
patch, err := g.git(ctx, workspacePath, "diff", target)
if err != nil {
return "", err
}
out := strings.TrimSpace(stat)
if p := strings.TrimSpace(patch); p != "" {
if out != "" {
out += "\n\n"
}
out += p
}
return out, nil
}

// resolveBase finds a ref to diff the branch against: the current branch's
// upstream if set, else the remote's default branch (origin/HEAD).
func (g *GitForge) resolveBase(ctx context.Context, dir string) (string, error) {
Expand Down
44 changes: 44 additions & 0 deletions internal/forge/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,50 @@ func TestGitForge_HasDiff(t *testing.T) {
}
}

func TestGitForge_Diff(t *testing.T) {
work, _ := setupRepo(t)
g := NewGit()
ctx := context.Background()

// Clean checkout equal to origin/main -> empty diff.
if d, err := g.Diff(ctx, work); err != nil || d != "" {
t.Fatalf("clean checkout: diff=%q err=%v", d, err)
}

// A committed change on a feature branch shows up vs the base, with the
// changed file named in the leading --stat (so a truncated diff still
// identifies it) and the added line in the patch body.
mustGit(t, work, "checkout", "-b", "feature")
if err := os.WriteFile(filepath.Join(work, "feature.txt"), []byte("hello world\n"), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, work, "add", ".")
mustGit(t, work, "commit", "-m", "add feature")

d, err := g.Diff(ctx, work)
if err != nil {
t.Fatalf("diff: %v", err)
}
if !strings.Contains(d, "feature.txt") {
t.Fatalf("diff should name the changed file:\n%s", d)
}
if !strings.Contains(d, "+hello world") {
t.Fatalf("diff should include the added line:\n%s", d)
}

// An uncommitted edit is also captured (working tree vs base).
if err := os.WriteFile(filepath.Join(work, "feature.txt"), []byte("hello world\nmore\n"), 0o644); err != nil {
t.Fatal(err)
}
d, err = g.Diff(ctx, work)
if err != nil {
t.Fatalf("diff after edit: %v", err)
}
if !strings.Contains(d, "+more") {
t.Fatalf("diff should include the uncommitted line:\n%s", d)
}
}

func TestGitForge_PushBranch(t *testing.T) {
work, bare := setupRepo(t)
g := NewGit()
Expand Down
25 changes: 16 additions & 9 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,15 +242,22 @@ type Session struct {
Goal string `json:"goal"`
CurrentActivity string `json:"current_activity,omitempty"`
LatestSummary string `json:"latest_summary,omitempty"`
TargetID string `json:"target_id,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
UsageProvider string `json:"usage_provider,omitempty"`
UsedTokens int64 `json:"used_tokens"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata JSONMap `json:"metadata,omitempty"`
// HandoffSummary is the worker-authored result relayed to the manager when the
// session finishes (set via the report_result tool). Unlike LatestSummary —
// which is scraped from the agent's last output and can capture a transitional
// line or noisy TUI pane — this is exactly what the worker chose to hand off
// (findings, a diff, references). Preferred over LatestSummary wherever a
// worker's result is relayed.
HandoffSummary string `json:"handoff_summary,omitempty"`
TargetID string `json:"target_id,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
UsageProvider string `json:"usage_provider,omitempty"`
UsedTokens int64 `json:"used_tokens"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata JSONMap `json:"metadata,omitempty"`
}

// Target is a machine where sessions can run.
Expand Down
6 changes: 1 addition & 5 deletions internal/orch/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,7 @@ func (o *Orchestrator) compactContext(objectiveID string) string {
if sessions, err := o.st.ListSessionsByObjective(objectiveID); err == nil && len(sessions) > 0 {
b.WriteString("SESSION SUMMARIES:\n")
for _, s := range sessions {
summary := s.LatestSummary
if summary == "" {
summary = s.CurrentActivity
}
fmt.Fprintf(&b, "- [%s/%s] %s: %s\n", s.Role, s.Status, s.Title, summary)
fmt.Fprintf(&b, "- [%s/%s] %s: %s\n", s.Role, s.Status, s.Title, relaySummaryLine(s))
}
b.WriteString("\n")
}
Expand Down
141 changes: 95 additions & 46 deletions internal/orch/manager_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,100 +16,149 @@ import (
// "/mcp/<sessionID>", and tool calls are scoped to that session/objective. This
// is what turns "the manager session runs" into "the manager actually manages":
// its tool calls drive the orchestrator.
//
// The manager gets the full surface. Workers get strict subsets (see
// WorkerMCPHandler / FollowupMCPHandler) so a worker cannot, say, mark the
// objective done or spawn more workers — those are the manager's to decide.
func (o *Orchestrator) ManagerMCPHandler() http.Handler {
s := mcp.NewServer("orcha", "0.1")
return mcpServer(
o.toolSpawnSession(),
o.toolAskUser(),
o.toolPublishPR(),
o.toolUpdatePR(),
o.toolCommentPR(),
o.toolAddressPRFeedback(),
o.toolCreateNote(),
o.toolMarkDone(),
o.toolCancelSession(),
)
}

obj := func(props map[string]any, required ...string) map[string]any {
return map[string]any{"type": "object", "properties": props, "required": required}
// mcpServer builds an MCP HTTP handler exposing exactly the given tools.
func mcpServer(tools ...mcp.Tool) http.Handler {
s := mcp.NewServer("orcha", "0.1")
for _, t := range tools {
s.AddTool(t)
}
str := map[string]any{"type": "string"}
return s.Handler()
}

s.AddTool(mcp.Tool{
// mcpObj/mcpStr are tiny JSON-schema builders shared by the tool constructors.
func mcpObj(props map[string]any, required ...string) map[string]any {
return map[string]any{"type": "object", "properties": props, "required": required}
}

var mcpStr = map[string]any{"type": "string"}

func (o *Orchestrator) toolSpawnSession() mcp.Tool {
return mcp.Tool{
Name: "spawn_session",
Description: "Spawn a scoped worker session under this objective. Returns the new session id. Use dependencies to make a session wait for others to succeed. To address feedback or push a fix to an existing PR, use address_pr_feedback instead — not spawn_session.",
InputSchema: obj(map[string]any{
InputSchema: mcpObj(map[string]any{
"role": map[string]any{"type": "string", "enum": []string{"implementer", "reviewer", "validator", "researcher", "custom"}},
"title": str,
"goal": str,
"title": mcpStr,
"goal": mcpStr,
"agent_hint": map[string]any{"type": "string", "enum": []string{"claude", "codex"}},
"dependencies": map[string]any{"type": "array", "items": str},
"dependencies": map[string]any{"type": "array", "items": mcpStr},
"repo": map[string]any{"type": "string", "description": "override the objective's repo for this worker's checkout (owner/repo, the upstream)"},
"push_repo": map[string]any{"type": "string", "description": "fork to push branches to (owner/repo); omit to push to repo itself"},
"base_branch": map[string]any{"type": "string", "description": "base branch for the checkout (default main)"},
"target": map[string]any{"type": "string", "description": "pin this worker to a target machine (name or id), e.g. a remote SSH box"},
"target_labels": map[string]any{"type": "array", "items": str, "description": "require a target with these labels"},
"target_labels": map[string]any{"type": "array", "items": mcpStr, "description": "require a target with these labels"},
}, "role", "title", "goal"),
Handler: o.mcpSpawnSession,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolAskUser() mcp.Tool {
return mcp.Tool{
Name: "ask_user",
Description: "Ask the user a question and block on their input. Use when requirements, credentials, setup, or direction are unclear.",
InputSchema: obj(map[string]any{"question": str, "context": str}, "question"),
InputSchema: mcpObj(map[string]any{"question": mcpStr, "context": mcpStr}, "question"),
Handler: o.mcpAskUser,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolPublishPR() mcp.Tool {
return mcp.Tool{
Name: "publish_pr",
Description: "Publish a PR from a worker session's committed changes. The orchestrator verifies mechanical safety, pushes the branch, and opens the PR.",
InputSchema: obj(map[string]any{
"session_id": str,
"title": str,
"body": str,
"commit_message": str,
InputSchema: mcpObj(map[string]any{
"session_id": mcpStr,
"title": mcpStr,
"body": mcpStr,
"commit_message": mcpStr,
}, "session_id", "title", "body"),
Handler: o.mcpPublishPR,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolUpdatePR() mcp.Tool {
return mcp.Tool{
Name: "update_pr",
Description: "Push follow-up changes to an existing PR (branch-safe: never pushes to a merged PR). After a rebase (which rewrites history) set force=true, or the push is rejected as non-fast-forward.",
InputSchema: obj(map[string]any{
"pr_id": str,
"session_id": str,
"title": str,
"body": str,
InputSchema: mcpObj(map[string]any{
"pr_id": mcpStr,
"session_id": mcpStr,
"title": mcpStr,
"body": mcpStr,
"commit_message": map[string]any{"type": "string", "description": "used only if you left changes uncommitted; prefer committing yourself with git"},
"force": map[string]any{"type": "boolean", "description": "force-push (--force-with-lease); required after a rebase or any history rewrite"},
"force_reason": map[string]any{"type": "string", "description": "why a force push is needed (e.g. 'rebased onto main to resolve conflicts')"},
}, "pr_id"),
Handler: o.mcpUpdatePR,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolCommentPR() mcp.Tool {
return mcp.Tool{
Name: "comment_pr",
Description: "Leave a comment on a PR. pr_id accepts the Orcha pr_id or the GitHub PR number.",
InputSchema: obj(map[string]any{"pr_id": str, "body": str}, "pr_id", "body"),
InputSchema: mcpObj(map[string]any{"pr_id": mcpStr, "body": mcpStr}, "pr_id", "body"),
Handler: o.mcpCommentPR,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolAddressPRFeedback() mcp.Tool {
return mcp.Tool{
Name: "address_pr_feedback",
Description: "Spawn a follow-up worker to address review feedback or push a fix to an existing PR. " +
"Use this for any PR follow-up instead of spawn_session: the worker gets a checkout of the PR " +
"branch and pushes its fix back to the same PR. pr_id accepts the Orcha pr_id or the GitHub PR number.",
InputSchema: obj(map[string]any{
"pr_id": str,
InputSchema: mcpObj(map[string]any{
"pr_id": mcpStr,
"instructions": map[string]any{"type": "string", "description": "what the follow-up should do: the review comments to address or the fix to make"},
"role": map[string]any{"type": "string", "enum": []string{"pr_followup", "ci_followup"}, "description": "ci_followup for CI/check failures; pr_followup (default) for review feedback"},
}, "pr_id", "instructions"),
Handler: o.mcpAddressPRFeedback,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolCreateNote() mcp.Tool {
return mcp.Tool{
Name: "create_note",
Description: "Record a note in the objective's shared memory (not stdout).",
InputSchema: obj(map[string]any{"title": str, "body": str}, "title", "body"),
InputSchema: mcpObj(map[string]any{"title": mcpStr, "body": mcpStr}, "title", "body"),
Handler: o.mcpCreateNote,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolMarkDone() mcp.Tool {
return mcp.Tool{
Name: "mark_objective_done",
Description: "Mark the objective complete with a concise summary.",
InputSchema: obj(map[string]any{"summary": str}, "summary"),
InputSchema: mcpObj(map[string]any{"summary": mcpStr}, "summary"),
Handler: o.mcpMarkDone,
})
s.AddTool(mcp.Tool{
}
}

func (o *Orchestrator) toolCancelSession() mcp.Tool {
return mcp.Tool{
Name: "cancel_session",
Description: "Cancel a session (and its children).",
InputSchema: obj(map[string]any{"session_id": str}, "session_id"),
InputSchema: mcpObj(map[string]any{"session_id": mcpStr}, "session_id"),
Handler: o.mcpCancelSession,
})

return s.Handler()
}
}

// managerSession resolves the calling manager session from the request context.
Expand Down
Loading
Loading