diff --git a/docs/plugins.md b/docs/plugins.md index 4c81b1d1..6c815b86 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -174,8 +174,10 @@ Unrecognized lines are ignored. Timeout is 15s. Plugins are consulted in name order and the first non-empty decision wins. Failing is safe: no router, a script that errors or prints nothing, or a timeout -all spawn the task exactly as it would have. A config dir already set by hand or -by a workflow step is never overruled, and a routed task keeps its profile on +all spawn the task exactly as it would have. A config dir already set by hand, by +a workflow step, or on the **project** is never overruled — pinning a project's +config dir is how you opt it out of routing, which matters because a config dir +carries that account's MCP connectors and their per-profile OAuth logins, and a routed task keeps its profile on resume (its Claude session lives in that config dir). `HOLD` leaves a task **queued**, never blocked, and is ignored for a manually started task. Only Claude tasks are routed — `CLAUDE_CONFIG_DIR` means nothing to the other diff --git a/go.mod b/go.mod index 7da704bd..6611045e 100644 --- a/go.mod +++ b/go.mod @@ -66,12 +66,12 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.17 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.54.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 4b1ca23d..8d5f056a 100644 --- a/go.sum +++ b/go.sum @@ -148,26 +148,26 @@ github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Br github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/executor/routing.go b/internal/executor/routing.go index 33947d0e..18f73c24 100644 --- a/internal/executor/routing.go +++ b/internal/executor/routing.go @@ -54,6 +54,16 @@ func (e *Executor) routeTask(ctx context.Context, task *db.Task, allowHold bool) if strings.TrimSpace(task.ClaudeConfigDir) != "" { return true } + // A project that names its own config dir has already chosen a profile, and + // that choice is load-bearing: a config dir carries the account's MCP + // connectors and their OAuth logins, which are per-profile and cannot be + // shared. Routing an influencekit task onto a personal profile doesn't error + // — it silently runs without the Linear/InfluenceKit servers it needs, and + // the agent works around the gap. So pinning a project is also how you opt it + // out of routing. + if project := e.projectConfigDir(task.Project); project != "" { + return true + } if !e.hooks.HandlesRoute() { return true } @@ -111,3 +121,17 @@ func (e *Executor) noteRouteHold(task *db.Task, decision hooks.RouteDecision) { routeHoldLog.Store(task.ID, reason) e.logLine(task.ID, "system", fmt.Sprintf("Waiting to start — %s (plugin %q). Will retry automatically.", reason, decision.Plugin)) } + +// projectConfigDir returns the config dir a project pins, or "" when it uses the +// default. Errors read as "not pinned": the caller's next step is to route, and +// a DB hiccup shouldn't be the thing that decides a profile. +func (e *Executor) projectConfigDir(project string) string { + if project == "" { + return "" + } + p, err := e.db.GetProjectByName(project) + if err != nil || p == nil { + return "" + } + return strings.TrimSpace(p.ClaudeConfigDir) +} diff --git a/internal/executor/routing_test.go b/internal/executor/routing_test.go index 1039ed81..bdd579d8 100644 --- a/internal/executor/routing_test.go +++ b/internal/executor/routing_test.go @@ -115,6 +115,42 @@ func TestRouteTask_ExplicitConfigDirIsNotOverridden(t *testing.T) { } } +func TestRouteTask_ProjectPinnedProfileIsNotOverridden(t *testing.T) { + // A project's config dir is a real choice, not a default: it carries that + // account's MCP connectors and their per-profile OAuth logins. Routing an + // influencekit task onto a personal profile wouldn't error, it would just + // silently run without the servers it needs. + e, database := newRoutingExecutor(t, "#!/bin/sh\necho CLAUDE_CONFIG_DIR=/tmp/router-choice\n") + if err := database.CreateProject(&db.Project{Name: "pinned", Path: "/tmp/pinned", ClaudeConfigDir: "~/.claude-work"}); err != nil { + t.Fatal(err) + } + task := &db.Task{Title: "t", Type: "task", Project: "pinned", Executor: db.ExecutorClaude} + if err := database.CreateTask(task); err != nil { + t.Fatal(err) + } + + if ok := e.routeTask(context.Background(), task, true); !ok { + t.Fatal("routeTask returned false") + } + if task.ClaudeConfigDir != "" { + t.Errorf("router overrode a project-pinned profile: %q", task.ClaudeConfigDir) + } +} + +func TestRouteTask_UnpinnedProjectStillRoutes(t *testing.T) { + // The flip side: pinning is opt-out, so a project that hasn't chosen still + // gets routed. Otherwise the fix above would quietly disable the feature. + e, database := newRoutingExecutor(t, "#!/bin/sh\necho CLAUDE_CONFIG_DIR=/tmp/router-choice\n") + task := newRoutingTask(t, database, db.ExecutorClaude) // project "test", no config dir + + if ok := e.routeTask(context.Background(), task, true); !ok { + t.Fatal("routeTask returned false") + } + if task.ClaudeConfigDir != "/tmp/router-choice" { + t.Errorf("unpinned project was not routed: %q", task.ClaudeConfigDir) + } +} + func TestRouteTask_ResumedTaskStaysOnItsProfile(t *testing.T) { // Session affinity, and it is not optional: a Claude session lives inside // one config dir, so a task resumed under a different profile would find no