121 lines
5.4 KiB
Markdown
121 lines
5.4 KiB
Markdown
# Workspace Concurrency Model
|
|
|
|
## Summary
|
|
|
|
A run's workspace lock policy is inferred per task. `lockMode='none'`
|
|
is the default for pure cognitive work. `lockMode='exclusive'` is
|
|
selected by an explicit `task.workspaceId` or by
|
|
`task.requires_filesystem=1`.
|
|
|
|
All decisions live in one function: `resolveWorkspaceWithLockMode` in
|
|
`src/main/orchestration/resolve-workspace.ts`.
|
|
|
|
## Why the default is `none`
|
|
|
|
Before v52, every resolved workspace was held exclusively for the
|
|
duration of the run. In a multi-agent company the resolver's
|
|
four-step fallback chain (task → agent → project → any company
|
|
workspace) always converged onto the single company workspace row;
|
|
four agents trying to heartbeat simultaneously all claimed the same
|
|
exclusive lock and only one ever executed. The user experience
|
|
degraded from "parallel C-suite" to "one agent at a time" with no
|
|
error and no log line naming the cause.
|
|
|
|
The fix asks the task row for filesystem intent:
|
|
**does this task actually touch the filesystem?** Strategy writing,
|
|
financial planning, document drafting, and PM decisions run fully in
|
|
parallel with `lockMode='none'`. Filesystem tasks opt in through
|
|
`requires_filesystem=1`.
|
|
|
|
## The per-task signal: `tasks.requires_filesystem`
|
|
|
|
v52 added `tasks.requires_filesystem INTEGER NOT NULL DEFAULT 0`.
|
|
The column answers one question: "does this particular task mutate
|
|
the working tree?"
|
|
|
|
- `0` (default) -> the task is cognitive. `lockMode='none'`.
|
|
- `1` -> the task mutates files. `lockMode='exclusive'` on an
|
|
explicit workspace.
|
|
|
|
Backfill at migration time is conservative — `1` is only set when we
|
|
have positive evidence: in-flight tasks already holding a lease, and
|
|
todo/backlog tasks whose assignee is in an engineering-flavoured
|
|
role. Everything else is `0`. New tasks default to `0`; creators set
|
|
`1` when the task mutates files.
|
|
|
|
## Legacy role backfill
|
|
|
|
Role and department matching now live only in the v52 data backfill:
|
|
|
|
```
|
|
role matched /cto|engineer|developer|devops|sre/i
|
|
OR department === "engineering"
|
|
```
|
|
|
|
The runtime resolver reads task state only: `task.workspaceId`,
|
|
`task.requiresFilesystem`, `task.projectId`, and `agent.workspaceId`.
|
|
Every production task creation path writes `requiresFilesystem`
|
|
explicitly.
|
|
|
|
## Decision table
|
|
|
|
| Signal on the (task, agent) pair | Lock mode | Workspace used |
|
|
| --- | --- | --- |
|
|
| `task.workspaceId` set (explicit opt-in, e.g. PR-review task) | `exclusive` | The task's workspace |
|
|
| `task.requiresFilesystem = 1` | `exclusive` | `agent.workspaceId` -> project primary -> throws `WorkspaceConfigurationError` |
|
|
| Everything else (strategy, ops, finance, PM, drafting) | `none` | `null` |
|
|
|
|
Explicit `task.workspaceId` always wins. This is the escape hatch for
|
|
"run this PR-review task on *that specific* branch workspace".
|
|
|
|
## Why `company_fallback` was removed
|
|
|
|
The old fourth fallback step — return any workspace in the company
|
|
as the lock target — was the silent-corruption path. Four CTOs with
|
|
no workspace binding would all resolve to the same row; the first
|
|
claimed the lock; the other three reported `cancelled` with no
|
|
operator-visible error. There was no way to tell from the outside
|
|
whether the company had one workspace intentionally (and was
|
|
correctly serialising) or four workspaces mis-configured (and was
|
|
silently bottlenecking).
|
|
|
|
Post-v52: a filesystem-touching run with no legitimate workspace
|
|
binding throws `WorkspaceConfigurationError` at resolve time. The
|
|
caller decides recovery — `heartbeat.ts` releases the task lease
|
|
and returns `SKIPPED('workspace_unresolved')` so the task re-enters
|
|
the pool; `task-runner.ts` releases the lease and returns a
|
|
`WORKSPACE_UNRESOLVED` error so the UI can show the operator the
|
|
actual cause. Silent corruption is replaced by a visible
|
|
configuration error.
|
|
|
|
## Invariants enforced by the type
|
|
|
|
The return type makes the invariants impossible to violate in a
|
|
caller without a compile error:
|
|
|
|
```
|
|
lockMode = 'exclusive' ⇒ workspace is a live WorkspaceRecord
|
|
lockMode = 'none' ⇒ workspace is null
|
|
```
|
|
|
|
Specifically, a caller that reads `resolution.workspace.id` with
|
|
`lockMode === 'exclusive'` is always safe; a caller that reads it
|
|
with `lockMode === 'none'` is a type error. `run-dispatch.ts` claims
|
|
a workspace lock only in the exclusive branch, which is why no lock
|
|
is ever acquired for a cognitive run.
|
|
|
|
## Failure modes and recovery
|
|
|
|
| Condition | Behaviour |
|
|
| ---------------------------------------------------- | --------------------------------------------------------------- |
|
|
| Cognitive task (no filesystem signal) | `lockMode='none'`, run dispatches, no lock, full parallelism |
|
|
| Coding task + agent workspace bound | `lockMode='exclusive'` on agent workspace, lock claimed |
|
|
| Coding task + no agent workspace + project primary | `lockMode='exclusive'` on project primary, lock claimed |
|
|
| Coding task + no workspace anywhere | `WorkspaceConfigurationError`; heartbeat skips, lease released |
|
|
| Two coding agents racing the same workspace | First claims, second cancelled by dispatcher, retries next beat |
|
|
|
|
The last row is the pre-existing serialisation contract for truly
|
|
shared code repos — one writer at a time — and it continues to work
|
|
because the exclusive lock is still real. What changed is that
|
|
non-coding agents no longer participate in the race at all.
|