Drop per-task port blocks; previews use any free loopback port

This commit is contained in:
Ian Gulliver
2026-10-21 20:49:00 -07:00
parent d88fd66c68
commit f0213e2aed
18 changed files with 441 additions and 425 deletions
+1 -6
View File
@@ -39,7 +39,7 @@ func (m *Manager) bashTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "bash",
Description: "Run a shell command on the Mac byre runs on, in your task's worktree, with $SHELL as a login shell, and get its exit status and output (stdout and stderr together, the last 64 KiB). " +
"byre runs it itself, outside any sandbox your own shell tool has, with byre's environment and $PORT set to this task's first port. " +
"byre runs it itself, outside any sandbox your own shell tool has, with byre's environment. " +
"Stdin is empty. The command runs in its own process group, which is killed at the timeout.",
InputSchema: json.RawMessage(`{
"type": "object",
@@ -65,10 +65,6 @@ func (m *Manager) bash(ctx context.Context, l *live, raw json.RawMessage) (strin
if err != nil {
return "", err
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
shell := os.Getenv("SHELL")
if shell == "" {
return "", errors.New("SHELL is not set in byre's environment")
@@ -78,7 +74,6 @@ func (m *Manager) bash(ctx context.Context, l *live, raw json.RawMessage) (strin
defer cancel()
cmd := procgroup.CommandContext(cctx, shell, "-l", "-c", args.Command)
cmd.Dir = l.dir
cmd.Env = append(os.Environ(), portEnv(task)...)
cmd.WaitDelay = 5 * time.Second
out := &tailWriter{}
cmd.Stdout = out
+2 -3
View File
@@ -98,9 +98,8 @@ func (m *Manager) takeBounce(ctx context.Context, l *live, final string) (bool,
}
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
g := guidance + fmt.Sprintf("\n- This task's ports are %d to %d; $PORT holds the first. Run dev servers and anything else that listens on these, so parallel tasks don't collide, and listen on 127.0.0.1. To show the human a running server, call byre's expose_port tool."+
"\n- This worktree starts with only what git tracks. Before other work in a new worktree, do any setup the project's AGENTS.md describes.",
task.PortBase, task.PortBase+store.PortBlock-1)
g := guidance + "\n- Run dev servers and anything else that listens on 127.0.0.1, on any free port. Other tasks' servers run on this Mac too, so when a port is taken, use another. To show the human a running server, call byre's expose_port tool." +
"\n- This worktree starts with only what git tracks. Before other work in a new worktree, do any setup the project's AGENTS.md describes."
if task.Title == "" {
g += "\n- This task has no title yet. First thing, before any other work, call set_titles with a very short, distinct, to-the-point task title (two to four words) and a merge commit title that describes the work, and call it again whenever the work moves on."
} else {
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"g.fc.run/ai/byre/internal/mcp"
)
const screenshotGuidance = "\n- When a turn changes something you can see, you can show it in your final message: save screenshots with the Playwright tools (pages you serve on this task's ports) or hands' capture with save (apps on this Mac), and embed each as a Markdown image, ![what it shows](<path>), with the path the tool returned. They go to the task's media folder, outside the worktree, so they are never committed, and are deleted when the task closes. Leave out a screenshot that adds nothing to the text."
const screenshotGuidance = "\n- When a turn changes something you can see, you can show it in your final message: save screenshots with the Playwright tools (pages this task serves) or hands' capture with save (apps on this Mac), and embed each as a Markdown image, ![what it shows](<path>), with the path the tool returned. They go to the task's media folder, outside the worktree, so they are never committed, and are deleted when the task closes. Leave out a screenshot that adds nothing to the text."
func MediaDir(worktreePath string) string {
return worktreePath + "-media"
+2 -4
View File
@@ -88,6 +88,7 @@ type Manager struct {
auditMu sync.Mutex
exposeMu sync.Mutex
previewMu sync.Mutex
previewListeners map[int]*previewListener
@@ -880,7 +881,6 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
g += stageGuidance(frozen, l.stage)
tools = append(tools, m.finishStageTool(l))
}
env := portEnv(task)
stdio := []mcp.Stdio{}
if sess.Role == api.Implementer {
media := MediaDir(task.WorktreePath)
@@ -904,7 +904,6 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
l.dir = m.reviewDir(task.ProjectId, r.Id)
g = reviewGuidance
tools = []mcp.Tool{m.submitReviewTool(l)}
env = nil
stdio = []mcp.Stdio{playwrightMCP(m.reviewMediaDir(task.ProjectId, r.Id))}
}
if l.owner != "" {
@@ -920,9 +919,8 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
}
l.notes = "\n\n" + notes
tools = append(m.leadTools(l), m.readKnowledgeTool(), m.renderDiagramTool())
env = nil
}
env = append(git.IdentityEnv(l.gitName, l.gitEmail), env...)
env := git.IdentityEnv(l.gitName, l.gitEmail)
servers := []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: tools}}
sctx, cancel := context.WithTimeout(ctx, 60*time.Second)
+34 -18
View File
@@ -22,10 +22,6 @@ import (
const maxPreviewName = 40
func portEnv(t api.Task) []string {
return []string{"PORT=" + strconv.Itoa(t.PortBase)}
}
type exposePortArgs struct {
Name string `json:"name"`
Port int `json:"port"`
@@ -35,7 +31,7 @@ func (m *Manager) exposePortTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "expose_port",
Description: "Show the human a server you are running, such as a dev server, as a link above the task's tabs that opens it in a new window. " +
"Start the server yourself first (in the background), listening on 127.0.0.1 (not 0.0.0.0 or localhost) on a port in this task's block. " +
"Start the server yourself first (in the background), listening on 127.0.0.1 (not 0.0.0.0 or localhost) on any free port. " +
"byre then serves it on the same port of byre's tailnet node, and the human's app opens it through a local link. " +
"name labels it in the tab (\"web\", \"storybook\"); exposing a name or port again replaces the earlier one. " +
"The server stops when your process does (a byre restart, compaction, the task closing); the human sees it as unreachable until you start it again.",
@@ -43,7 +39,7 @@ func (m *Manager) exposePortTool(l *live) mcp.Tool {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Short label, shown as the link's text."},
"port": {"type": "integer", "description": "The port the server listens on, at 127.0.0.1, within this task's block."}
"port": {"type": "integer", "description": "The port the server listens on, at 127.0.0.1."}
},
"required": ["name", "port"]
}`),
@@ -65,13 +61,8 @@ func (m *Manager) exposePort(ctx context.Context, l *live, raw json.RawMessage)
return "", fmt.Errorf("name must be one non-empty line of at most %d characters", maxPreviewName)
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
last := task.PortBase + store.PortBlock - 1
if args.Port < task.PortBase || args.Port > last {
return "", fmt.Errorf("port %d is outside this task's block, %d to %d", args.Port, task.PortBase, last)
if args.Port < 1 || args.Port > 65535 {
return "", fmt.Errorf("port %d is not a TCP port", args.Port)
}
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(args.Port))
@@ -81,14 +72,23 @@ func (m *Manager) exposePort(ctx context.Context, l *live, raw json.RawMessage)
}
conn.Close()
err = m.servePreview(args.Port)
if err != nil {
return "", err
m.exposeMu.Lock()
defer m.exposeMu.Unlock()
m.previewMu.Lock()
serving := m.previewListeners[args.Port] != nil
m.previewMu.Unlock()
if !serving {
err = m.servePreview(args.Port)
if err != nil {
return "", err
}
}
dropped, err := m.store.SetPreview(ctx, api.Preview{TaskId: task.Id, Name: name, Port: args.Port, CreatedAt: time.Now().UTC()}, agentActor)
dropped, err := m.store.SetPreview(ctx, api.Preview{TaskId: l.taskID, Name: name, Port: args.Port, CreatedAt: time.Now().UTC()}, agentActor)
if errors.Is(err, store.ErrNotFound) {
m.stopPreview(args.Port)
if !serving {
m.stopPreview(args.Port)
}
return "", errors.New("this task is no longer active")
}
if err != nil {
@@ -263,12 +263,28 @@ func (m *Manager) recoverPreviews(ctx context.Context) error {
}
func (m *Manager) Unexpose(ctx context.Context, taskID string) {
m.exposeMu.Lock()
defer m.exposeMu.Unlock()
previews, err := m.store.ListPreviews(ctx, taskID)
if err != nil {
slog.Error("list previews", "task", taskID, "err", err)
return
}
active, err := m.store.ActivePreviews(ctx)
if err != nil {
slog.Error("list active previews", "err", err)
return
}
held := map[int]bool{}
for _, p := range active {
if p.TaskId != taskID {
held[p.Port] = true
}
}
for _, p := range previews {
if held[p.Port] {
continue
}
m.stopPreview(p.Port)
slog.Info("stopped serving preview", "task", taskID, "name", p.Name, "port", p.Port)
}