blog /
Why a task never spans two repositories
One orchestrator runs agents in every repository at once. What it refuses to do is let a single task span two of them, and the reason is git rather than tidiness.
The Tade project·4 min read·multi-repodesignagents
The program that draws this website and the website itself are two repositories, and one window runs agents in both. Over the seven days to 28 September 2026 that was 109 tasks and 168 runs across the two, with the tabs along the top saying which project each belonged to.
That part is easy. The hard part is the request that reaches into both at
once — change the screens in tade, then update the site that shows them —
and the honest answer to it is a refusal.
The obvious design, and why it is wrong
A change spanning three repositories looks like one task with three workspaces. Tade does not have that, and will not.
Four things break at once, and each of them is load-bearing somewhere else:
- A lane has one
cwd. An agent is a process in a directory. Three directories is three processes, which is three agents wearing one name. - A task has one worktree and one git snapshot. Everything Tade says about a task — what it changed, how far ahead of its base it is, which commits are its own — is a query against one repository.
done: mergedhas no meaning across three branches. Two landed and one did not is not a state; it is a list.- The
Tade-Task:trailer would stop naming one history. That trailer is the only thing that says which work a commit belongs to once the commit is on somebody else’s machine. A trailer naming a unit that spans three histories names none of them.
Those are not implementation problems to be worked around later. They are what makes the ordinary single-repository case answerable at all.
What an effort is instead
Said plainly first: no effort has run on this machine. Everything in the numbers above was an ordinary single-repository task, and what follows is the design and its reasoning rather than a story about it — which is where the argument lives anyway, because the argument is about what was refused.
An effort is a name, the sentence somebody said, and one ordinary task per repository underneath it — each with its own branch, agent, checks, review and done rule exactly as tasks work today. Git, the forge and the checks record learn no new word.
It lives nowhere new. TaskFile.effort is one optional field, and an effort is
the fold of the task files that name it. Status already reads every task
file in every project, so the grouping is free, a removed task leaves the
effort correctly smaller, and there is nothing to rebuild or keep in sync.
A table would have been the other option, and a table is wrong the moment somebody removes a task.
An effort has no state, deliberately
Until every task in it has finished, an effort has a list, not a status.
“Two of three” is not something anybody can act on. Which one is not is. So the fold produces the unfinished tasks and no rollup, and there is no effort-level done rule, merge, review or branch at all.
The reason is worth saying plainly: a fourth rule sitting above three rules is a rule that will eventually disagree with them, and nothing would be able to say which was right.
A cross-repository wait is a wait on when
Waits work across repositories, and this is where it nearly went wrong.
Queued work in a worktree starts on top of whatever it waited on, so it has
that work to build on. The upstream map is flat across every project, because
a wait is — and without a project on it, a task in sentry-cli waiting on one
in sentry was handed sentry’s branch name to start from. That fails in the
good case. In the bad case it finds a ref of that name that is somebody else’s
work entirely.
startFrom now takes the project and skips every dependency outside it. A
cross-repository wait is a wait on when, not on what: the other
repository’s commits are not this one’s to build on, so the work begins from
its own base exactly as it would with no wait at all.
agents.workspace, per project
Where agents work is one setting with a per-project answer over it:
agents.workspace is checkout — every agent in the project’s own checkout,
each task a folder under .tade/tasks/<name> — or worktree, a worktree and
branch each. projects.<name>.workspace overrides it.
That is not a preference. It changes what other rules mean, which is why a
plan spanning repositories asks it per project rather than once: done: committed and done: merged turn on it, and the start-time collision check
only exists in a shared checkout, because with a branch each nothing is being
changed under anybody.
On this machine both projects are shared checkouts, which is why anything
running git on a task’s directory has to ask task.workspace rather than
assume the directory is that task’s alone.
What it is worth
The seven days above produced 130 commits in tade, +91,708 −35,378 across
3,215 files, of which 91 carry a task trailer and 39 do not. Every one of the
91 is traceable to the task that made it, in one repository, by a string in
the commit message that survives being pushed.
That is the whole return on the refusal. A unit that spanned repositories would have bought a nicer-looking overview and cost the one fact that is actually useful six months later.
npm i -g tade-sh
packages/core/src/effort.ts is 115 lines, most of them the reasoning; the
tests are packages/app/test/efforts.test.ts. Both are in
the repository.