Closes the three specific_asks from Jobs's T1 review of the workspace architecture fix (7829acc..15e3863): 1. docs/architecture/workspace-concurrency.md: decision table upgraded from a code block to a real Markdown table so it reads consistently with the failure-modes table lower in the file. 2. docs/architecture/workspace-concurrency.md + resolve-workspace.ts: added "Sunset coding-role inference" section and a TODO marker on CODING_ROLE_PATTERN tying the regex to the v52 transition window (target 2026-05-20). Both documents now name the end-state explicitly so a future engineer knows why the regex exists and when it dies. 3. resolve-workspace.ts: enriched the WorkspaceConfigurationError message to include taskId / agentId / projectId alongside the existing companyId, so a misconfigured setup points operators at the offending row instead of just the company. No behaviour change, no fallback, no mock. typecheck green, 540 unit tests green, 10 eval tests green.
6.6 KiB
Workspace Concurrency Model
Summary
A run's workspace lock policy is inferred per task. lockMode='none'
is the default (pure cognitive work runs in parallel with no lock);
lockMode='exclusive' is the opt-in for runs that mutate the
filesystem. There is no shared lock mode and no silent fallback to
someone else's workspace.
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 is not to pick a different fallback. It is to stop inferring
filesystem use from the shape of the graph and ask the task row:
does this task actually touch the filesystem? Strategy writing,
financial planning, document drafting, PM decisions — none of them
open a file or shell out — so by default they run fully in parallel
with lockMode='none'. Coding tasks opt in, and that opt-in is the
only path that takes a lock.
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 and the
creator explicitly flips to 1 when the task mutates files (a
follow-up CTO seed commit wires this into the default template).
Coding-role inference (safety net)
A second signal fires when the resolving agent matches the coding pattern:
role matches /cto|engineer|developer|devops|sre/i
OR department === "engineering"
This is a transition-window safety net: in existing companies whose
task rows predate v52 (the backfill may not have caught every case),
a CTO heartbeat with no requires_filesystem=1 task still correctly
takes an exclusive lock on its agent workspace. Once every task
creation path sets requires_filesystem explicitly the role inference
becomes a no-op.
Sunset coding-role inference
The coding-role regex in resolve-workspace.ts exists only for the
v52 transition window. Before the next major, audit every
db.tasks.saveTask caller, every migration seed, and any workflow
that writes the tasks row; each must set requires_filesystem
explicitly based on the task's real filesystem intent. When the audit
is complete, delete the regex and the agentSignalsFilesystem branch
from inferLockMode and remove this section. Target window: post
v52 stabilisation, 2026-05-20.
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 OR agent role matches coding pattern OR agent.department = "engineering" |
exclusive |
agent.workspaceId → project primary → throws WorkspaceConfigurationError |
| Everything else (strategy, ops, finance, PM, drafting) | none |
null — no lock claimed |
Explicit task.workspaceId always wins. This is the escape hatch for
"run this PR-review task on that specific branch workspace", and
it bypasses both the filesystem signal and the coding-role
inference.
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.