tade — why a task never spans two repositories

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: merged has 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.

every-kind-of-agent · sidebardrawn by Tade
Every agent gets a lane, whatever repository it is in: working, idle, waiting for approval, failed, finished, queued, paused. What each one is comes back from git and the processes, never from a table.

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.

blognewer →The Tade project│2026-09-28│4 min│top