Files
AgentCompany/docs/architecture/workspace-concurrency.md
T
ZacharyZhang-NY e15bf67c9f Workspace concurrency review iteration: Jobs's 3 taste asks
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.
2026-04-20 15:40:46 -04:00

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.