Drive repositories and tracker teams with one devintern worker: workspace.toml routing and isolated per-task worktrees

Workspaces (Multi-Repo Fleet)

Workspace mode lets one devintern worker process serve every repository you automate. Instead of one worker per repo, you describe repositories once in ~/.devintern/workspace.toml, then use either one default tracker query or several isolated team tracker sources.

The shortest path is devintern worker init inside a checkout: that writes a 1-repo workspace, validates [defaults].task_query, and asks about task pickup hours, conflict handling, and automatic CI repair. Add more repos later with devintern worker add-repo.

Workspace mode runs under the same automation access as the rest of the worker: any Supporter, Team, or Business key covers it, and a signed-in user can first use the no-card Worker Pilot for 14 days with no task-count limit. One entitlement spans all applicable repositories in the fleet.

How it works

  • Without [[teams]], the worker polls [defaults].task_query. With teams, it creates one isolated tracker client, query, cursor, and dedupe scope per team.
  • A team can set repo for a fixed destination. A team spanning repositories omits repo and uses routing rules. A task runs only when its applicable rules agree on one repository; unmatched or ambiguous work is recorded rather than guessed. A 1-repo workspace needs no routing rules — N=1 already implies the only checkout (devintern worker init starts this way).
  • The worker manages a bare clone of each repository under ~/.devintern/repos/ and runs every task in a fresh, disposable worktree under ~/.devintern/worktrees/. Your own checkouts are never touched. Worktrees are removed after a successful run, kept for debugging when a run fails, and swept after worktrees_ttl_days — at worker startup and then hourly while the worker runs.
  • Every task worktree is ready for the agent before it starts: git hooks are isolated when the worktree is created (so package postinstalls like lefthook cannot rewrite the shared .git/hooks), then dependencies are installed after the task’s final branch is prepared by auto-detecting the package manager from the lockfile (bun/pnpm/yarn/npm, uv/poetry/pip, bundle, go, cargo, composer, maven, gradle). The install inherits the same layered workspace, repo, and team environment as the agent, including registry auth; if no lockfile is found or the install fails, the run continues with a warning — the agent can still set dependencies up itself.
  • The worker queue, cursors, PR registry, run records, and routing skips live at ~/.devintern/state/queue.db. Worker auth, license, and relay state lives at ~/.devintern/state/code/. Task subprocesses derive that path from their worker workspace context; regular CLI commands remain project scoped. devintern worker init copies an existing project sign-in there if the workspace has no session, and existing workspace credentials and relay pairing are carried forward from the old location. Use devintern worker login (or devintern worker login --workspace /path/to/workspace.toml) to sign in later. Worker-managed clones also exclude known .devintern-code/ runtime files through .git/info/exclude, while new project settings.json, .env.example, and automations.toml remain stageable in task PRs. Checkouts under /tmp made from your own repository are not modified.
  • Agent runs use one workspace supervisor. The default global and per-repository limits are both 1; you can explicitly opt into bounded host concurrency as described below. One systemd unit (or one terminal) drives the whole fleet.

workspace.toml

[workspace]
worktrees_ttl_days = 7
dashboard = true
# dashboard_port = 4400
# Batch automatic conflict resolution off-peak instead of instant (default "auto"):
# conflict_resolution = "scheduled"
# conflict_resolution_cron = "0 3 * * *"      # worker host timezone
# conflict_resolution_interval = "1d"         # exactly one of cron / interval
# Or turn it off entirely: conflict_resolution = "disabled"

# Optional bounded host concurrency (see warning below):
# [workspace.execution]
# isolation = "best_effort_host"
# max_concurrency = 4
# max_concurrency_per_repo = 1

[defaults]
tracker = "jira"
task_query = "sprint in openSprints() AND labels = devintern"
worker_task_args = "--create-pr"
poll_interval = 60
# pr_labels = ["devintern", "auto-pr"]

[[repos]]
name = "backend"
remote = "git@github.com:acme/backend.git"
default_branch = "main"
# pr_labels = ["backend"]
# env_file = "env/backend.env"        # optional, relative to ~/.devintern
  [repos.env]                         # optional per-repo overrides
  GITHUB_REPO = "acme/backend"

[[repos]]
name = "frontend"
remote = "git@github.com:acme/frontend.git"

[[routing.rules]]
repo = "backend"
project = "BACK"

[[routing.rules]]
repo = "frontend"
project = "WEB"
labels = ["frontend"]

[worker]
auto_update = true           # false disables the worker's idle CLI self-update

[worker.schedule]
active = ["22:00-06:00"]     # optional quiet hours: drain new tasks only at night
blocked = []                 # subtract from active windows (conflicts resolve to quiet)
timezone = ""                # blank = worker machine's local time
catch_up_missed = true

[[automations]]
id = "backend-maintenance"
enabled = true
interval = "6h"
repo = "backend"
prompt = "Inspect the backend and implement one safe maintenance improvement."

[[automations]]
id = "weekly-frontend-cleanup"
enabled = true
cron = "0 9 * * 1"
repo = "frontend"
prompt = "Review the frontend and clean up one source of recurring noise."
  • [defaults].tracker picks the tracker for the single-source fleet query; any tracker with polling support works (Jira, Linear, GitHub Issues, GitLab Issues, Azure DevOps, Asana, Trello, Markdown).
  • pr_labels applies labels to every PR the fleet creates (GitHub only). A repo’s pr_labels overrides [defaults].pr_labels. Outside a workspace, single-repo users get the same behavior by setting PR_LABELS (comma-separated) in .devintern-code/.env.
  • Each repository follows its advertised origin/HEAD unless its [[repos]] entry sets an explicit default_branch override.
  • Repo names must be unique and filesystem-safe; they become directory names under repos/ and worktrees/.
  • Rule criteria combine with AND; list values (components, labels) match when the task carries any of them. Comparisons are case-insensitive. project matches the task key prefix for PROJ-123 style keys (Jira, Linear); trackers with numeric or opaque ids route via labels or components.
  • [worker.schedule] gates only new-task pickup: multiple windows union, windows may cross midnight, blocked wins on overlap, and a missed whole window triggers one catch-up drain at startup. Timezone/DST semantics and devintern worker run-now are covered in Running the Worker Unattended: Working windows.
  • [worker].auto_update keeps a globally installed CLI current while the worker is idle (checked at most daily, installed without interrupting work, restart on success). See Running the Worker Unattended: Keeping the worker up to date.
  • [[automations]] uses the same schema as single-repo .devintern-code/automations.toml. An entry must name repo when the workspace has more than one repository. open_pr (boolean, default false) decides whether occurrences open a pull request: opt-in per automation, and workspace-level worker_task_args never overrides it. See Worker Daemon → Recurring automations for prompt-writing guidance and schedule semantics.
  • [[estimations]] schedules unattended story-point sweeps (tracker query + cron/interval, no prompt, no repo). The workspace tracker must support estimation. See Worker Daemon → Scheduled story-point estimation.
  • [[error_monitors]] maps each Sentry project to one repo and an optional team, with per-source credential layers for multi-project setups. See Sentry Auto-fixes.

Bounded host concurrency

By default the worker admits one agent job globally and one disposable-worktree task per repository. To process independent work concurrently, explicitly acknowledge the shared-host execution model:

[workspace.execution]
isolation = "best_effort_host"
max_concurrency = 4
max_concurrency_per_repo = 1

max_concurrency bounds all agent work across polling, relay events, retries, error monitors, reviews, mentions, CI fixes, conflict resolution, automations, and estimations. max_concurrency_per_repo bounds task jobs that receive disposable worktrees in one repository. Shared-base jobs such as reviews and CI fixes remain serialized per repository even when the per-repository task limit is larger.

The limits are a live cap, not a one-time batch gate: polling keeps running while jobs are in flight, so each poll fills every free slot with newly available tasks, and a task is admitted as soon as an in-flight job frees its slot. The worker never admits more than the configured limits at once.

Raising either limit above 1 requires the explicit best_effort_host value. This mode is not a security or isolation boundary. Concurrent jobs share the host kernel, network namespace, localhost ports, process table, Docker daemon, browser profiles, package-manager caches, and linked Git metadata. Typical consequences include EADDRINUSE, Docker Compose published-port conflicts, cache/profile lock contention, Git ref-lock failures, and several jobs consuming the same agent account quota. Leftover child processes from one run may affect another. Each tracker task still has a separate worktree, but that does not isolate those host resources.

Use concurrency only for repositories and tasks that tolerate those conflicts. A later isolated execution mode will retain these capacity keys while running each workflow in its own private clone and microVM.

Multiple teams and tracker boards

Use [[teams]] when one worker must poll separate boards, tracker accounts, or tracker products. Each team has a stable name, tracker, query, and optional credential layers:

[[teams]]
name = "platform"
tracker = "jira"
task_query = "project = PLAT AND labels = devintern"
repo = "api"
env_file = "env/platform.env"

[[teams]]
name = "growth"
tracker = "linear"
task_query = '{"team":{"key":{"eq":"GROW"}}}'
repo = "web"
  [teams.env]
  LINEAR_API_KEY = "lin_api_..."

repo is a fixed mapping: every task acquired from that team runs in that repository, regardless of task labels or key shape. This is the simplest and safest setup when a tracker board belongs to one codebase. The named repository must exist in [[repos]]. A fixed team cannot also have team-scoped routing rules, because two competing routing models would make precedence unclear.

When one team owns several repositories, omit repo and add rules scoped to the team:

[[teams]]
name = "platform"
tracker = "jira"
task_query = "project in (PLAT, SRE) AND labels = devintern"
env_file = "env/platform.env"

[[repos]]
name = "api"
remote = "git@github.com:acme/api.git"

[[repos]]
name = "infra"
remote = "git@github.com:acme/infra.git"

[[routing.rules]]
team = "platform"
repo = "api"
project = "PLAT"

[[routing.rules]]
team = "platform"
repo = "infra"
project = "SRE"

[[routing.rules]]
repo = "infra"
labels = ["infrastructure"] # unscoped: available to every non-fixed team

Team routing follows these rules:

  • Rules naming another team are invisible. Rules naming the acquiring team and rules without team are applicable.
  • Set criteria are ANDed; lists are any-of. If applicable matches disagree on the repository, the task is recorded as ambiguous and not run.
  • An unfixed team in a multi-repo workspace must have at least one applicable rule. Tasks that match none are recorded as unrouted.
  • Fixed teams ignore unscoped routing rules and always use their configured repo.
  • In a one-repo workspace, omitting both team.repo and routing rules still selects the only repository.

Credentials layer as workspace .env < team env_file < inline [teams.env] for tracker clients. Task subprocesses retain repository settings and then apply the acquiring team’s credential layers, with TASK_TRACKER pinned to that team’s tracker so comments and transitions go back to the correct board. Team cursor keys use tracker:team (for example jira:platform), so separate boards of the same tracker never share polling cursors or dedupe records.

The shared .env can also namespace credentials as <TRACKER>_<TEAM>_<SETTING>. For example, JIRA_PLATFORM_URL, JIRA_PLATFORM_EMAIL, and JIRA_PLATFORM_API_TOKEN are projected onto Jira’s normal variables only for team platform. Team names are uppercased and punctuation becomes _; team env_file and inline values still win.

[defaults].tracker and [defaults].task_query are optional fallbacks for team entries. Once any [[teams]] exist, there is no separate defaults poller. Scheduled estimations still use [defaults].tracker; configure it explicitly when using [[estimations]].

Team task_query and repo changes live-reload along with routing rules. Team names, tracker types, env_file, and inline credentials are startup-only because changing them requires rebuilding tracker clients and detectors; restart the worker after changing those fields.

Tracker relay registrations and task.changed envelopes carry the stable team name. Connect same-tracker boards separately with devintern worker connect jira --team platform and devintern worker connect jira --team growth; each tracker/team pair gets its own idempotent ingest URL and dispatches only to that exact team, even if task keys overlap. Unknown or removed teams are safely skipped and acknowledged. Legacy team-less buffered envelopes still use source-only routing when the tracker has one unambiguous source. GitHub repository relay is unchanged.

Deploy the team-aware relay control plane before the matching CLI release. A new CLI against an old relay fails team-scoped registration with an actionable upgrade message; old CLI team-less registrations remain supported by the new control plane.

Automatic conflict resolution: auto vs scheduled vs disabled

When a watched PR conflicts with its base branch, the worker normally resolves it right away (conflict_resolution = "auto", the default — no behavior change on upgrade). Every resolution hands the conflicted files to the AI agent, which consumes tokens — even at 3am when nobody is reviewing the PR anyway.

Set conflict_resolution = "scheduled" to batch those resolutions into an off-peak window. Polling still detects every conflict immediately and queues it (the PR stays conflicted until then, and the worker logs which mode is active at startup); the agent only runs inside the window:

[workspace]
conflict_resolution = "scheduled"
conflict_resolution_cron = "0 3 * * *"   # or conflict_resolution_interval = "1d"

The schedule uses the same format as [[automations]]: a five-field cron expression (worker host timezone) or a positive 15m/6h/1d interval — exactly one of the two. Exactly one window pass runs per occurrence; if the worker is down when the window arrives (a missed nightly run), the queued conflicts resolve on the first tick after restart. Inside a window the usual safety rules still apply: failed attempts wait out their retry backoff, PRs whose head is still moving wait out the quiet period, and anything not finished before the window closes (60 minutes by default, WORKER_RESOLVE_WINDOW_GRACE_MINUTES) waits for the next one. PRs merged upstream before the window opens are skipped — the worker re-checks GitHub’s mergeability before invoking the agent.

Two things are never delayed by scheduled mode: review feedback on the agent’s PRs is addressed immediately as usual, and you can always run devintern resolve-conflicts <pr-url> by hand to fix one PR without waiting for the window — once GitHub reports the PR conflict-free, the queued event never triggers an agent run.

The setting is workspace-wide (per-repo overrides are not supported in v1) and live-reloads with the rest of the runtime configuration. The tradeoff to keep in mind: between windows a conflicted PR cannot be merged, so on fast-moving branches where an instant rebase unblocks a waiting reviewer, auto stays the better choice. See Worker Daemon → Merge conflicts on the agent’s PRs for how resolution itself works.

Set conflict_resolution = "disabled" to turn automatic conflict resolution off entirely: the worker stops watching for conflicts on the agent’s PRs altogether — no detection, no queuing, no agent runs. A PR that conflicts with its base simply stays conflicted until someone resolves it (by hand, or on demand via devintern resolve-conflicts <pr-url>). Review feedback and @mention handling are unaffected. This is a valid choice when the team prefers to rebase manually, or when the agent is not trusted to resolve conflicts in a sensitive repository.

How workspace automations differ from single-repo ones

The scheduling is identical; only where the work runs changes:

  • Each occurrence runs in the repo’s persistent base worktree (~/.devintern/worktrees/<repo>/base) with the same layered environment as review work: shared .env → repo env_file → [repos.env]. When open_pr = true, dependencies are reinstalled after the occurrence prepares its final branch, so they stay aligned with its lockfile.
  • It takes the normal per-repo run lock, so it never mutates a checkout concurrently with a task or PR run.
  • Occurrence task files land under the workspace home (~/.devintern/automations/<id>/), next to repos/, worktrees/, and the central database — not inside the repo worktrees.

Creating a workspace

devintern worker scaffold     # scaffold ~/.devintern/workspace.toml and .env
cd ~/code/backend
devintern worker add-repo     # add this repo to the workspace
cd ~/code/frontend
devintern worker add-repo

devintern worker connect sentry # add a Sentry auto-fix project

worker add-repo reads the repo’s origin remote and its .devintern-code/.env:

  • The remote becomes a [[repos]] entry (name derived from the remote, unique and filesystem-safe; default branch from origin/HEAD when it differs from the workspace default).
  • Env keys the workspace does not have yet are merged into the shared .env. Values that conflict with the workspace .env are kept repo-local in that repo’s [repos.env]; nothing is silently overwritten.
  • When the repo’s env carries a default project key (Jira or Linear), a starter routing rule is seeded for it.
  • Re-running add-repo for the same repo is a no-op. Hand-written comments in workspace.toml are preserved; new entries are appended.
  • .devintern-code/settings.json needs no migration: it travels with the repo and applies inside each task worktree.

Environment

Secrets live in one shared owner-only ~/.devintern/.env (tracker credentials, GITHUB_TOKEN, agent settings). Worker setup and repository imports enforce mode 0600. Advanced no-relay installations may also keep customer-owned GitHub App credentials there. Each repo can layer more on top:

The workspace worker uses this shared .env even when started inside a repository. It does not load that repository’s .devintern-code/.env; use the repo layers below for credentials that should differ by repository. Worker task subprocesses also use their composed workspace and repo layers instead of reading the checkout’s local .env. Manual devintern resolve-conflicts <pr-url> and devintern address-review <pr-url> use the workspace and repo credential layers and the workspace login session when the current checkout and PR URL both match a registered repo. Other checkouts keep their project-local configuration.

  1. Shared workspace .env
  2. The repo’s env_file (if set)
  3. Inline [repos.env] values (highest precedence)

For GitHub remotes the worker fills GITHUB_REPO automatically from the remote URL. GitHub review, CI, mention, and relay requests use the matching repo’s GITHUB_TOKEN layers. When a fixed GitHub team targets that repo, its env_file and [teams.env] can override the token. Without a repo or team override, those requests use the workspace token.

Running

devintern worker            # auto-detects ~/.devintern/workspace.toml
devintern worker --workspace /path/to/workspace.toml

The single-source fleet query comes from [defaults].task_query; multi-team workspaces use each team’s task_query. A workspace with automations, estimations, or an enabled error monitor can omit the defaults query. Poll interval, per-task flags, and the embedded dashboard are also set in workspace.toml (poll_interval, worker_task_args, [worker.schedule] quiet hours, [workspace].dashboard / dashboard_port). Direct webhooks are an advanced repo-local service: run devintern webhook serve from that repository as a separate process. Automation and estimation schedule state and leases, plus task-polling and error-monitor deduplication state, live in the central workspace database.

While the daemon is running you can request one immediate drain (for example while quiet hours are closed) with devintern worker run-now; see Working windows.

Editing workspace.toml while running

The worker watches workspace.toml and reloads it automatically a moment after you save — no restart, and no missed tracker events or relay messages during the bounce:

  • Routing rules, repos, defaults/team task_query, team repo, [[automations]], [[estimations]], worker_task_args, poll_interval, worktrees_ttl_days, execution concurrency limits, conflict-resolution mode/schedules, and [worker].auto_update apply to subsequent work. Runs already in progress finish under the configuration they started with; everything picked up afterwards uses the new one. Lowering a concurrency limit does not cancel work already admitted. Changing a repo’s remote updates its managed bare clone the next time that repo is prepared.
  • Team identity and credentials are startup-only. Restart after changing a team’s name, tracker, env_file, or inline [teams.env] values.
  • Error monitor clients are startup-only. Restart after changing [[error_monitors]], including project routing or source credentials.
  • A broken edit never takes the daemon down. The reload validates the file first; parse or schema errors are logged (naming the offending entries) and the last valid configuration keeps serving until you fix it. Rewriting identical content is ignored.
  • Manual fallback: send SIGHUP (kill -HUP <pid>) to force an immediate reload if file watching is unavailable on your system.
  • Startup-only settings still require a restart: tracker credentials in the workspace .env and [defaults].tracker (the tracker client and its detector are built once), [worker.schedule] quiet hours (the working-window gate is built once at startup), plus [workspace].dashboard / dashboard_port. A reload that changes one of these settings is rejected in full, so the active config remains internally consistent.

devintern worker init can install and start a user-level systemd unit on Linux or launchd agent on macOS (declining keeps it manual and prints the commands). One service runs the whole workspace. For a hand-written Linux unit:

[Unit]
Description=DevIntern fleet worker
After=network-online.target

[Service]
ExecStart=/usr/local/bin/devintern worker
Restart=on-failure
WorkingDirectory=/home/you/.devintern

[Install]
WantedBy=multi-user.target

Reviews, mentions, and the relay

With GitHub credentials in the workspace .env, the fleet worker also reacts to PR activity across every GitHub repo in the workspace:

  • The agent’s own PRs: one poller watches every PR the fleet created (the registry is shared across repos) and addresses actionable review feedback automatically. With [workspace].ci_failure_fix = true, it also repairs failing CI. Entries for repos no longer in workspace.toml are unwatched at startup.
  • @mentions on any PR: each GitHub repo gets a mention sweep. Mention-triggered runs are permission gated: the mentioning user needs write, maintain, or admin access, and the gate fails closed on API errors. Fork PRs are skipped unless maintainer edits are allowed. Standard workspaces recognize the central devintern-ai identity through the relay and use GITHUB_TOKEN for local API calls. No-relay installations need an advanced customer-owned App.
  • Relay (instant events): accept relay setup in devintern worker init; its durable pairing is stored owner-only under the workspace home and starts automatically with the worker. GitHub uses the central App. GitLab.com and Self-Managed projects receive automatically tested project hooks when local credentials have Maintainer or Owner access; only reference metadata reaches the relay, and every action re-fetches authoritative state locally. Tracker events re-run the applicable defaults/team query and then use the same fixed mapping or routing rules as polling. A tracker type used by several teams stays polling-only because current relay envelopes do not identify the team registration; the worker fails closed instead of guessing. Events for repositories not in the workspace are ignored.

To reconnect after adding repositories, run devintern worker connect. The command walks every GitHub and GitLab repository in workspace.toml, skips verified GitHub App pairings, and creates or rotates GitLab hooks without manual URLs or secrets. devintern worker connect status reports GitHub and GitLab readiness independently. Use devintern worker connect gitlab for a focused repair, or add --disconnect to remove only remembered GitLab hooks/routes while preserving polling. Tracker targets such as devintern worker connect linear --team growth compose the selected team’s credentials on top of the shared workspace .env.

Review and mention runs execute as subprocesses in the repo’s persistent base checkout under ~/.devintern/worktrees/<repo>/base, with the same layered environment as task runs.

Skipped tasks

Ambiguous and unmatched tasks are recorded in the workspace database with the rules that matched. Fix the routing rules (or the task’s labels), touch the task, and the worker picks it up on the next change. Skips never loop: a skipped task is not retried until it changes.