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
36 changes: 36 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,46 @@ system. The ones dispatched today:
| `task.blocked` | Task needs input |
| `task.failed` | Agent execution failed |
| `task.auth_required` | Executor session needs re-authentication |
| `task.route` | **Before** a task spawns — the one event you *answer*. See [Routing](#routing-pre-spawn) |

A plugin may declare any event string; it only runs for events TaskYou actually
emits, so unknown events are harmless.

## Routing (pre-spawn)

Every other hook is a notification — it fires after the fact and nothing waits for
it. `task.route` fires *before* a task spawns, TaskYou waits for it, and reads the
script's **stdout back as a decision**. Stdout is the decision channel; put
diagnostics on stderr.

```sh
#!/bin/sh
echo "CLAUDE_CONFIG_DIR=$HOME/.claude-work" # run this task under that profile
echo "REASON=7% of its limits used" # optional, for the task log
```

| Key | Effect |
|-----|--------|
| `CLAUDE_CONFIG_DIR` | Run the task under that Claude profile (config dir) |
| `HOLD=1` | Don't start yet; leave it queued and reconsider next tick |
| `REASON=…` | Free text for the task log |

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
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
executors.

Routing hooks also get `TASK_EXECUTOR` and `TASK_CLAUDE_CONFIG_DIR`.

Worked example: **claude-profile-router** in the
[community collection](https://github.com/taskyou/plugins).

## Environment

Every hook receives the standard task variables:
Expand Down
17 changes: 17 additions & 0 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,23 @@ func (db *DB) UpdateTaskPermissionMode(taskID int64, mode string) error {
return nil
}

// UpdateTaskClaudeConfigDir sets the per-task CLAUDE_CONFIG_DIR override,
// which is how a task is pinned to one Claude profile (account). Writing it as
// its own column update — rather than through UpdateTask — matters at spawn
// time: the routing decision is made from a task struct the daemon has been
// holding, and a full-row write would stomp any field another surface (the TUI,
// a hook) changed in the meantime.
func (db *DB) UpdateTaskClaudeConfigDir(taskID int64, configDir string) error {
_, err := db.Exec(`
UPDATE tasks SET claude_config_dir = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, configDir, taskID)
if err != nil {
return fmt.Errorf("update task claude config dir: %w", err)
}
return nil
}

// UpdateTaskPinned updates only the pinned flag for a task.
func (db *DB) UpdateTaskPinned(taskID int64, pinned bool) error {
_, err := db.Exec(`
Expand Down
18 changes: 18 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1793,10 +1793,23 @@ func (e *Executor) processNextTask(ctx context.Context) {
// A step deferred for branch contention serves its backoff here. Without
// this gate the task is re-entered on every 2s tick, and each pass writes
// a fresh "Starting task #N" line for a step that cannot start.
//
// This gate goes before routing deliberately: it is a map lookup, while
// routing may shell out to a plugin. A task sitting out a branch backoff
// shouldn't pay for a usage probe on every tick to learn it still can't run.
if !e.branchWaitDue(task.ID) {
continue
}

// Last decision before the spawn: which Claude profile does this run
// under? A routing plugin may pick one (stamping task.ClaudeConfigDir,
// which both command builders already honor) or ask to hold the task
// when every account is out of headroom. With no router installed this
// is a no-op. See routing.go.
if !e.routeTask(ctx, task, true) {
continue
}

// Atomically check-and-set to prevent race where two ticks
// both see the task as not-running and spawn duplicate goroutines
e.mu.Lock()
Expand Down Expand Up @@ -1860,6 +1873,11 @@ func (e *Executor) ExecuteNow(ctx context.Context, taskID int64) error {
e.runningTasks[taskID] = true
e.mu.Unlock()

// Route this run to a Claude profile too, so a manually started task lands
// on the same account the queue would have chosen. A hold is not honored
// here: the user asked for this task to run now.
e.routeTask(ctx, task, false)

e.executeTask(ctx, task)
return nil
}
Expand Down
113 changes: 113 additions & 0 deletions internal/executor/routing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package executor

import (
"context"
"fmt"
"strings"
"sync"

"github.com/bborn/workflow/internal/db"
"github.com/bborn/workflow/internal/hooks"
)

// Profile routing gives a plugin the last word on which Claude account a task
// runs under, at the only moment where that word is still worth anything: after
// the task is cleared to run, before its command is built.
//
// Everything downstream already supports this — Task.ClaudeConfigDir has always
// been the per-task profile lever, honored identically by the daemon's command
// builder and the TUI's. What was missing was anyone to set it automatically.
// Routing fills that in: it stamps the column and lets the existing machinery
// carry the decision the rest of the way, so there is no second code path for a
// routed task and no chance of the two builders disagreeing about which profile
// is in play.
//
// Two rules keep it from getting in the way:
//
// - An explicit choice always wins. A task that already names a config dir —
// set by hand, by a workflow step, or by an earlier routing pass — is left
// alone. Routing fills a vacuum; it does not overrule a person.
// - Silence means "carry on". No router installed, a script that fails, times
// out, or prints nothing: the task spawns exactly as it would have before
// any of this existed.

// routeHoldLog remembers the last hold reason logged per task, so a task parked
// behind exhausted profiles writes one log line rather than one per daemon tick.
var routeHoldLog sync.Map // taskID -> last reason written

// routeTask consults the task.route plugin hook and applies its decision.
//
// It returns false only when a router asked to hold the task — every other
// outcome, including every kind of failure, returns true and lets the spawn
// proceed. allowHold is false on the manual "run this now" path: a person who
// explicitly started a task has already made the call, and silently refusing
// would look like the button was broken.
func (e *Executor) routeTask(ctx context.Context, task *db.Task, allowHold bool) bool {
if task == nil || e.hooks == nil {
return true
}
// CLAUDE_CONFIG_DIR is a Claude concept; a codex or gemini task has no
// profile to route between.
if task.Executor != "" && task.Executor != db.ExecutorClaude {
return true
}
if strings.TrimSpace(task.ClaudeConfigDir) != "" {
return true
}
if !e.hooks.HandlesRoute() {
return true
}

decision := e.hooks.Route(ctx, task)
if decision.Empty() {
routeHoldLog.Delete(task.ID)
return true
}

if decision.Hold && allowHold {
e.noteRouteHold(task, decision)
return false
}
routeHoldLog.Delete(task.ID)

dir := strings.TrimSpace(decision.ClaudeConfigDir)
if dir == "" {
return true
}
resolved := ResolveClaudeConfigDir(dir)
if err := e.db.UpdateTaskClaudeConfigDir(task.ID, resolved); err != nil {
// The write is what makes the decision visible to the TUI and to a
// later resume. If it fails, don't apply the route in memory either —
// a task whose spawned profile disagrees with its recorded one is the
// exact confusion this feature is supposed to remove.
e.logger.Error("Failed to record routed Claude profile", "id", task.ID, "dir", resolved, "error", err)
return true
}
task.ClaudeConfigDir = resolved

msg := fmt.Sprintf("Routed to Claude profile %s (by plugin %q)", resolved, decision.Plugin)
if decision.Reason != "" {
msg += ": " + decision.Reason
}
e.logger.Info("Routed task to Claude profile", "id", task.ID, "dir", resolved, "plugin", decision.Plugin)
e.logLine(task.ID, "system", msg)
return true
}

// noteRouteHold records a hold, writing to the task log only when the reason
// changes. The daemon reconsiders a queued task every tick, so an unconditional
// log line would bury the task's real history under thousands of repeats of
// "waiting for headroom".
func (e *Executor) noteRouteHold(task *db.Task, decision hooks.RouteDecision) {
reason := decision.Reason
if reason == "" {
reason = "no Claude profile has headroom right now"
}
e.logger.Info("Holding task: no Claude profile available", "id", task.ID, "plugin", decision.Plugin, "reason", reason)

if prev, ok := routeHoldLog.Load(task.ID); ok && prev == reason {
return
}
routeHoldLog.Store(task.ID, reason)
e.logLine(task.ID, "system", fmt.Sprintf("Waiting to start — %s (plugin %q). Will retry automatically.", reason, decision.Plugin))
}
Loading