diff --git a/docs/architecture/workspace-concurrency.md b/docs/architecture/workspace-concurrency.md index 3c0db9d5..2b051439 100644 --- a/docs/architecture/workspace-concurrency.md +++ b/docs/architecture/workspace-concurrency.md @@ -3,10 +3,9 @@ ## 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. +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`. @@ -22,13 +21,11 @@ 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: +The fix asks the task row for filesystem intent: **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. +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` @@ -36,57 +33,40 @@ 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 +- `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). +role. Everything else is `0`. New tasks default to `0`; creators set +`1` when the task mutates files. -## Coding-role inference (safety net) +## Legacy role backfill -A second signal fires when the resolving agent matches the coding -pattern: +Role and department matching now live only in the v52 data backfill: ``` -role matches /cto|engineer|developer|devops|sre/i +role matched /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. +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` **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 | +| `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", and -it bypasses both the filesystem signal and the coding-role -inference. +"run this PR-review task on *that specific* branch workspace". ## Why `company_fallback` was removed diff --git a/src/main/api-server/routes-meetings.ts b/src/main/api-server/routes-meetings.ts index 5a45aeb1..1527512c 100644 --- a/src/main/api-server/routes-meetings.ts +++ b/src/main/api-server/routes-meetings.ts @@ -140,6 +140,7 @@ export function registerMeetingRoutes(server: AgentApiServer) { assigneeAgentId: item.assigneeAgentId ?? null, status: item.assigneeAgentId ? "todo" : "backlog", priority: "medium", + requiresFilesystem: false, }); if (item.assigneeAgentId) { onWakeAgent(item.assigneeAgentId, token.companyId, "assignment"); diff --git a/src/main/api-server/routes-tasks.ts b/src/main/api-server/routes-tasks.ts index 8dcd02d6..d961104f 100644 --- a/src/main/api-server/routes-tasks.ts +++ b/src/main/api-server/routes-tasks.ts @@ -14,6 +14,7 @@ const taskUpdateSchema = z.object({ description: z.string().max(4000).optional(), assigneeAgentId: z.string().uuid().nullable().optional(), priority: z.enum(["critical", "high", "medium", "low"]).optional(), + requiresFilesystem: z.boolean().optional(), }); const checkoutSchema = z.object({ @@ -184,6 +185,7 @@ export function registerTaskRoutes(server: AgentApiServer) { parentId: task.parentId, assigneeAgentId: newAssignee, workspaceId: task.workspaceId, + requiresFilesystem: update.requiresFilesystem ?? task.requiresFilesystem, }; db.tasks.saveTask(input as Parameters[0]); @@ -344,6 +346,7 @@ export function registerTaskRoutes(server: AgentApiServer) { priority: input.priority, status: input.status, workspaceId: input.workspaceId ?? null, + requiresFilesystem: input.requiresFilesystem, }); } catch (error) { server.json(res, 422, { error: error instanceof Error ? error.message : "Task could not be saved" }); diff --git a/src/main/automation/automation-action-schemas.ts b/src/main/automation/automation-action-schemas.ts index 94c66e2a..486fb805 100644 --- a/src/main/automation/automation-action-schemas.ts +++ b/src/main/automation/automation-action-schemas.ts @@ -24,6 +24,7 @@ export const createTaskConfigSchema = z.object({ priority: taskPrioritySchema.optional(), assigneeAgentId: z.string().nullable().optional(), projectId: z.string().optional(), + requiresFilesystem: z.boolean().default(false), }); export const notifyAgentConfigSchema = z.object({ diff --git a/src/main/automation/automation-actions.ts b/src/main/automation/automation-actions.ts index b8aa7661..ada227e8 100644 --- a/src/main/automation/automation-actions.ts +++ b/src/main/automation/automation-actions.ts @@ -100,6 +100,7 @@ export function executeAutomationAction( description: task.description, assigneeAgentId: ctx.agentId, workspaceId: task.workspaceId, + requiresFilesystem: task.requiresFilesystem, priority: task.priority, status: "todo", }); @@ -128,6 +129,7 @@ export function executeAutomationAction( assigneeAgentId, priority, status: assigneeAgentId ? "todo" : "backlog", + requiresFilesystem: cfg.requiresFilesystem, }); handleTaskCreated(taskId, ctx.companyId); logger.info(`[automation] Created task: ${title} (${taskId})`); diff --git a/src/main/database/tasks-crud.ts b/src/main/database/tasks-crud.ts index f620b933..1372b245 100644 --- a/src/main/database/tasks-crud.ts +++ b/src/main/database/tasks-crud.ts @@ -58,13 +58,9 @@ export function buildTaskCrudMethods( priority: string; status: string; /** - * Per-task filesystem lock policy (v52+). When omitted the - * column defaults to 0 at the SQL layer for inserts, and the - * existing value is preserved on updates. Callers opt in with - * `true` when they know the task will read/write the working - * tree (CEO kickoff reads the repo, CTO code changes mutate - * it, etc.). Leaving it undefined is the safe default for - * purely cognitive tasks. + * Per-task filesystem lock policy (v52+). Production callers + * pass this explicitly. Direct DB fixtures may omit it: inserts + * use the SQL default 0 and updates preserve the stored value. */ requiresFilesystem?: boolean; }): string { diff --git a/src/main/orchestration/resolve-workspace.ts b/src/main/orchestration/resolve-workspace.ts index 5d608b57..d31dc361 100644 --- a/src/main/orchestration/resolve-workspace.ts +++ b/src/main/orchestration/resolve-workspace.ts @@ -9,9 +9,8 @@ import type { WorkspaceLockMode } from "./types"; * `lockMode='none'` is the default. Previously every resolved * workspace was held exclusively, which folded four runnable * agents onto a single company workspace lock and serialised the - * entire C-suite. 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 itself. + * entire C-suite. The fix is to ask the task row for filesystem + * intent. * * `inferLockMode` returns the pair of decisions the dispatcher * needs (mode + which workspace, if any). `resolveWorkspaceWithLockMode` @@ -20,7 +19,7 @@ import type { WorkspaceLockMode } from "./types"; * * Silent fallbacks removed in this rewrite: * - * • `company_fallback` — previously the fourth fallback step + * - `company_fallback`: previously the fourth fallback step * returned *any* workspace row in the company as the lock target. * That was silent corruption's favourite mechanism: four agents * with no workspace binding would all land on the same row and @@ -29,17 +28,9 @@ import type { WorkspaceLockMode } from "./types"; * time so the caller decides (skip heartbeat, surface error, * whatever) instead of the resolver guessing. * - * • No `lockMode='exclusive'` ever returns a null workspace. The - * dispatcher assumed this invariant (it claims a lock on - * `workspace.id` when mode is exclusive). The types now enforce - * it: exclusive always comes with a resolved workspace, none - * always comes with `workspace=null`. + * - `lockMode='exclusive'` always returns a resolved workspace. + * `lockMode='none'` always returns `workspace=null`. * - * Coding-role inference (rule 2) is a safety net for the one- - * migration transition window where legacy rows may not yet have a - * meaningful `requires_filesystem` column value set by the creator. - * Once `saveTask` is taught to set the column explicitly at insert - * time (follow-up commit), rule 2 becomes a no-op. */ export type WorkspaceResolutionSource = @@ -51,7 +42,7 @@ export type WorkspaceResolutionSource = export interface WorkspaceResolution { /** * The workspace whose lock the dispatcher will claim. Non-null iff - * `lockMode === 'exclusive'` — the type system enforces the pair. + * `lockMode === 'exclusive'`; the type system enforces the pair. */ workspace: WorkspaceRecord | null; lockMode: WorkspaceLockMode; @@ -60,7 +51,7 @@ export interface WorkspaceResolution { * Where the artifact projector should *observe* for files this run * may produce, independent of the lock decision. Set whenever the * company has at least one workspace row, even when `lockMode='none'` - * — cognitive agents still write deliverables to disk via `bash`, + * because cognitive agents still write deliverables to disk via `bash`, * and the projector needs an observation root to scan against the * mtime baseline. Null only when the company has no workspace at * all (genuinely nowhere to look). @@ -68,7 +59,7 @@ export interface WorkspaceResolution { * For `lockMode='exclusive'` this is always the same path as * `workspace.localPath`. For `lockMode='none'` this is the * company's primary/oldest workspace localPath, picked by - * `getCompanyObservationWorkspace` — *no lock claim*. + * `getCompanyObservationWorkspace`. * * Decoupling observation from locking is what fixes the regression * where cognitive agents wrote 19 files but the DocumentCenter @@ -86,23 +77,12 @@ export class WorkspaceConfigurationError extends Error { } } -// TODO(post-v52 stabilisation, target 2026-05-20): delete this safety net -// once every task-creation path sets `requires_filesystem` explicitly. -// Rationale: during v52 rollout some legacy creators still emit tasks with -// a null `requires_filesystem`, and a CTO-typed task without the flag -// would otherwise fall through to lockMode=none and happily write files -// under no lock. Once seed data + every `db.tasks.saveTask` callsite is -// audited (tracked in docs/architecture/workspace-concurrency.md -// "Sunset coding-role inference"), this regex and the rule-2 role branch -// below both get removed and the column becomes the single source of -// truth. Do not extend this regex — add columns instead. -const CODING_ROLE_PATTERN = /cto|engineer|developer|devops|sre/i; - /** * Accept the widened shapes call sites actually pass. `projectId` * lives as `string | null | undefined` in several historical call - * sites (legacy inline types), and `role`/`department` are optional - * because taskless heartbeats supply only the runtime-needed subset. + * sites (legacy inline types). `role` and `department` remain in the + * input shape for existing call sites; lock inference reads only + * workspaceId from the agent. */ type TaskInput = { workspaceId: TaskRecord["workspaceId"]; @@ -123,17 +103,16 @@ type AgentInput = Pick & { * * Decision table: * - * 1. `task.workspaceId` set → exclusive on that workspace + * 1. `task.workspaceId` set -> exclusive on that workspace * (explicit opt-in by the task author, e.g. PR branch tasks). * - * 2. Task signals filesystem need (`requiresFilesystem === true` - * OR agent role matches coding pattern OR department = - * engineering) → exclusive on agent.workspaceId / project - * primary. Throws if neither exists. + * 2. Task signals filesystem need (`requiresFilesystem === true`) + * -> exclusive on agent.workspaceId / project primary. Throws if + * neither exists. * - * 3. Otherwise → none / null. Pure cognitive work — strategy, + * 3. Otherwise -> none / null. Pure cognitive work: strategy, * planning, document drafting, financial analysis, PM decisions - * — runs with no workspace and no lock. + * runs with no workspace and no lock. */ function inferLockMode( db: AppDatabase, @@ -142,24 +121,17 @@ function inferLockMode( companyId: string, ): { lockMode: WorkspaceLockMode; workspaceId: string | null; source: WorkspaceResolutionSource } { // Rule 1: task-level explicit workspace binding is the strongest - // signal available — the task row literally names the workspace. + // signal available: the task row literally names the workspace. if (task?.workspaceId) { return { lockMode: "exclusive", workspaceId: task.workspaceId, source: "task" }; } - const taskSignalsFilesystem = task?.requiresFilesystem === true; - const agentSignalsFilesystem = - (typeof agent.role === "string" && CODING_ROLE_PATTERN.test(agent.role)) || - agent.department === "engineering"; - - const needsFilesystem = taskSignalsFilesystem || agentSignalsFilesystem; - if (!needsFilesystem) { + if (task?.requiresFilesystem !== true) { return { lockMode: "none", workspaceId: null, source: "none" }; } - // Rule 2: filesystem-touching task → agent workspace first, then - // project primary. We do not fall back to `any company workspace` - // any more — that path was the cause of the single-lock pile-up. + // Rule 2: filesystem-touching task -> agent workspace first, then + // project primary. if (agent.workspaceId) { return { lockMode: "exclusive", workspaceId: agent.workspaceId, source: "agent" }; } @@ -185,8 +157,8 @@ function inferLockMode( * Resolve the workspace + lock mode for a run context. * * Contract: - * • lockMode 'exclusive' ⇒ workspace is a live WorkspaceRecord. - * • lockMode 'none' ⇒ workspace is null. + * - lockMode 'exclusive' => workspace is a live WorkspaceRecord. + * - lockMode 'none' => workspace is null. * * Violations throw `WorkspaceConfigurationError` at resolve time so * the caller chooses how to recover (skip heartbeat, cancel run, @@ -218,7 +190,7 @@ export function resolveWorkspaceWithLockMode( if (!decision.workspaceId) { throw new WorkspaceConfigurationError( - `inferLockMode returned exclusive with no workspaceId (companyId=${companyId}) — lock mode/workspace invariant broken.`, + `inferLockMode returned exclusive with no workspaceId (companyId=${companyId}); lock mode/workspace invariant broken.`, ); } @@ -238,7 +210,7 @@ export function resolveWorkspaceWithLockMode( workspace, lockMode: "exclusive", source: decision.source, - // Exclusive runs observe the same path they lock — single source. + // Exclusive runs observe the same path they lock. observationPath: workspace.localPath, }; } diff --git a/src/renderer/components/execution/TaskCreator.tsx b/src/renderer/components/execution/TaskCreator.tsx index 04cdd044..9b83d011 100644 --- a/src/renderer/components/execution/TaskCreator.tsx +++ b/src/renderer/components/execution/TaskCreator.tsx @@ -18,7 +18,7 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on async function handleCreateTask() { setSubmitError(null); setSubmitting(true) - try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) } + try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, requiresFilesystem: Boolean(workspaceId), priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) } } const [showAdvanced, setShowAdvanced] = useState(false) diff --git a/src/renderer/components/execution/TaskInspector.tsx b/src/renderer/components/execution/TaskInspector.tsx index 5a0b86c5..e6455a64 100644 --- a/src/renderer/components/execution/TaskInspector.tsx +++ b/src/renderer/components/execution/TaskInspector.tsx @@ -90,7 +90,8 @@ export function TaskInspector({ unwrap(await window.agentCompany.saveTask({ id: task.id, companyId: task.companyId, title: task.title, description: task.description, priority: task.priority, status, assigneeAgentId: task.assigneeAgentId, - workspaceId: task.workspaceId, projectId: task.projectId, goalId: task.goalId, + workspaceId: task.workspaceId, requiresFilesystem: task.requiresFilesystem, + projectId: task.projectId, goalId: task.goalId, })) await onRefresh() setActionNotice(`Task moved to ${status.replaceAll("_", " ")}.`) diff --git a/src/renderer/components/execution/TaskInspectorEditForm.tsx b/src/renderer/components/execution/TaskInspectorEditForm.tsx index 3469aadb..f86ed2a6 100644 --- a/src/renderer/components/execution/TaskInspectorEditForm.tsx +++ b/src/renderer/components/execution/TaskInspectorEditForm.tsx @@ -43,6 +43,7 @@ export function TaskInspectorEditForm({ status, assigneeAgentId: assignee || null, workspaceId: workspace || null, + requiresFilesystem: task.requiresFilesystem, projectId: project || null, goalId: goal || null, })) diff --git a/src/shared/contracts/schemas-tasks.ts b/src/shared/contracts/schemas-tasks.ts index 6b8a6346..2609d9a6 100644 --- a/src/shared/contracts/schemas-tasks.ts +++ b/src/shared/contracts/schemas-tasks.ts @@ -32,6 +32,7 @@ export const taskInputSchema = z.object({ description: z.string().default(""), assigneeAgentId: optionalId, workspaceId: optionalId, + requiresFilesystem: z.boolean().default(false), priority: z.enum(["critical", "high", "medium", "low"]).default("medium"), status: z.enum(["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"]).default("todo"), }); diff --git a/src/shared/types/records-core.ts b/src/shared/types/records-core.ts index c9047819..f334aa2c 100644 --- a/src/shared/types/records-core.ts +++ b/src/shared/types/records-core.ts @@ -157,12 +157,10 @@ export interface TaskRecord { taskType: string | null; requiresUserReview: boolean; /** - * Per-task filesystem lock policy (v52+). When `true` the - * orchestrator resolves a workspace and holds it exclusively for - * the duration of the run; when `false` the run executes with - * `lockMode='none'` and agents run in parallel on shared read-only - * context. Inferred from agent role at task creation and toggled - * explicitly by callers that know their task mutates the tree. + * Per-task filesystem lock policy (v52+). `true` makes the + * orchestrator resolve a workspace and hold it exclusively for + * the duration of the run. `false` lets a task with no explicit + * workspace binding execute with `lockMode='none'`. */ requiresFilesystem: boolean; startedAt: string | null; @@ -395,4 +393,3 @@ export interface ModelCapability { costPerMtokOut: number; updatedAt: string; } - diff --git a/tests/unit/requires-filesystem-migration.test.ts b/tests/unit/requires-filesystem-migration.test.ts new file mode 100644 index 00000000..921620c9 --- /dev/null +++ b/tests/unit/requires-filesystem-migration.test.ts @@ -0,0 +1,50 @@ +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it } from "vitest"; + +import { createMigrationsV52V52 } from "../../src/main/database/migrations/v52-v52"; + +describe("v52 requires_filesystem migration", () => { + it("materializes legacy role evidence into the task column", () => { + const db = new DatabaseSync(":memory:"); + try { + db.exec(` + create table agents ( + id text primary key, + role text not null, + department text + ); + create table tasks ( + id text primary key, + status text not null, + active_run_id text, + assignee_agent_id text + ); + insert into agents (id, role, department) values + ('agent-cto', 'CTO', 'engineering'), + ('agent-cmo', 'CMO', 'marketing'); + insert into tasks (id, status, active_run_id, assignee_agent_id) values + ('task-coding', 'todo', null, 'agent-cto'), + ('task-cognitive', 'todo', null, 'agent-cmo'), + ('task-inflight', 'in_progress', 'run-1', 'agent-cmo'); + `); + + const migration = createMigrationsV52V52({ + db, + readAll: (sql: string) => db.prepare(sql).all() as T[], + })[0]; + migration.fn?.(); + + const rows = db.prepare( + "select id, requires_filesystem as requiresFilesystem from tasks order by id", + ).all() as Array<{ id: string; requiresFilesystem: number }>; + + expect(rows).toEqual([ + { id: "task-coding", requiresFilesystem: 1 }, + { id: "task-cognitive", requiresFilesystem: 0 }, + { id: "task-inflight", requiresFilesystem: 1 }, + ]); + } finally { + db.close(); + } + }); +}); diff --git a/tests/unit/resolve-workspace.test.ts b/tests/unit/resolve-workspace.test.ts index 3e88519c..660306d3 100644 --- a/tests/unit/resolve-workspace.test.ts +++ b/tests/unit/resolve-workspace.test.ts @@ -21,14 +21,14 @@ import { /** * Workspace lock policy (v52+). * - * `lockMode='exclusive'` is opt-in — either the task carries an - * explicit workspaceId, or the task's requiresFilesystem column is - * true, or the resolving agent is in a coding role. Every other - * case returns `lockMode='none'` and `workspace=null`, so four - * pure-cognitive agents run fully in parallel without queueing on - * a single workspace lock. The previous `company_fallback` step - * (which returned any workspace in the company as the lock target) - * has been removed and now throws `WorkspaceConfigurationError`. + * `lockMode='exclusive'` is opt-in: the task carries an explicit + * workspaceId or the task's requiresFilesystem column is true. + * Every other case returns `lockMode='none'` and `workspace=null`, + * so four pure-cognitive agents run fully in parallel without + * queueing on a single workspace lock. The previous + * `company_fallback` step has been removed and now throws + * `WorkspaceConfigurationError` for filesystem tasks with no + * resolvable workspace. */ function seedCompany(db: AppDatabase) { @@ -82,7 +82,7 @@ describe("resolveWorkspaceWithLockMode", () => { expect(result.workspace?.id).toBe(workspaceId); }); - it("coding-role agent + agent workspace → exclusive on agent workspace", () => { + it("agent role and department do not infer filesystem locking", () => { const { companyId, workspaceId } = seedCompany(db); const result = resolveWorkspaceWithLockMode( db, @@ -90,12 +90,47 @@ describe("resolveWorkspaceWithLockMode", () => { { workspaceId, role: "CTO", department: "engineering" }, companyId, ); - expect(result.source).toBe("agent"); - expect(result.lockMode).toBe("exclusive"); - expect(result.workspace?.id).toBe(workspaceId); + expect(result.source).toBe("none"); + expect(result.lockMode).toBe("none"); + expect(result.workspace).toBeNull(); }); - it("coding-role agent + project primary workspace → exclusive on project workspace", () => { + it("persisted requiresFilesystem=false stays lock-free for engineering-assigned tasks", () => { + const { companyId, workspaceId } = seedCompany(db); + const agentId = db.agents.saveAgent({ + companyId, + name: "Engineering Lead", + role: "CTO", + title: "CTO", + department: "engineering", + status: "active", + reportsTo: null, + connectorId: "openai-codex", + workspaceId, + model: null, + capabilities: "Architecture", + budgetMonthlyUsd: 100, + }); + const taskId = db.tasks.saveTask({ + companyId, + title: "Review launch plan", + description: "Read the plan and respond with strategic comments.", + assigneeAgentId: agentId, + workspaceId: null, + priority: "medium", + status: "todo", + requiresFilesystem: false, + }); + const task = db.entityCrud.getTask(taskId); + const agent = db.entityCrud.getAgent(agentId); + const result = resolveWorkspaceWithLockMode(db, task, agent, companyId); + expect(task.requiresFilesystem).toBe(false); + expect(result.source).toBe("none"); + expect(result.lockMode).toBe("none"); + expect(result.workspace).toBeNull(); + }); + + it("requiresFilesystem + project primary workspace → exclusive on project workspace", () => { const { companyId } = seedCompany(db); const projectId = db.entityCrud.saveProject({ companyId, @@ -135,7 +170,7 @@ describe("resolveWorkspaceWithLockMode", () => { expect(result.source).toBe("agent"); }); - it("pure cognitive task (no coding role, no requiresFilesystem) → none", () => { + it("pure cognitive task without requiresFilesystem → none", () => { const { companyId } = seedCompany(db); const result = resolveWorkspaceWithLockMode( db, @@ -255,7 +290,7 @@ describe("resolveWorkspaceWithLockMode", () => { const { companyId, workspaceId } = seedCompany(db); const result = resolveWorkspaceWithLockMode( db, - null, + { workspaceId: null, projectId: null, requiresFilesystem: true }, { workspaceId, role: "CTO", department: "engineering" }, companyId, ); diff --git a/tests/unit/task-input-schema.test.ts b/tests/unit/task-input-schema.test.ts new file mode 100644 index 00000000..a7991b7f --- /dev/null +++ b/tests/unit/task-input-schema.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { taskInputSchema } from "../../src/shared/contracts"; + +describe("taskInputSchema", () => { + const baseTask = { + companyId: "00000000-0000-4000-8000-000000000001", + title: "Prepare launch review", + }; + + it("normalizes omitted requiresFilesystem to false", () => { + const parsed = taskInputSchema.parse(baseTask); + expect(parsed.requiresFilesystem).toBe(false); + }); + + it("preserves explicit requiresFilesystem intent", () => { + const parsed = taskInputSchema.parse({ + ...baseTask, + requiresFilesystem: true, + }); + expect(parsed.requiresFilesystem).toBe(true); + }); +});