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: 4 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 16 additions & 16 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
24 changes: 24 additions & 0 deletions internal/executor/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
36 changes: 36 additions & 0 deletions internal/executor/routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down