From 15e3863cba81e5386725e97b95738a85be66c954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 15:36:08 -0400 Subject: [PATCH] saveTask: accept requires_filesystem; opt in for the onboarding kickoff task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - saveTask now takes an optional requiresFilesystem boolean. Omitted → column keeps its current value on updates, defaults to 0 on inserts. Explicit true/false → written through. - Onboarding kickoff (the CEO's very first task) sets requiresFilesystem=true. The task description already tells the agent to list directories and read README/package.json — that is filesystem work, so the task must hold the workspace exclusive. - Other saveTask call sites (automation reassign, routes-tasks patch, create_task, meetings) stay implicit — update paths preserve the existing column value, new user-created tasks default to 0 (safe cognitive default) and can be opted in later by the creator. --- src/main/database/tasks-crud.ts | 74 ++++++++++++++++++++++++--------- src/main/onboarding-service.ts | 5 +++ 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/main/database/tasks-crud.ts b/src/main/database/tasks-crud.ts index fa522ff9..f620b933 100644 --- a/src/main/database/tasks-crud.ts +++ b/src/main/database/tasks-crud.ts @@ -57,6 +57,16 @@ export function buildTaskCrudMethods( workspaceId?: string | null; 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. + */ + requiresFilesystem?: boolean; }): string { const id = ensureId(input.id ?? null); const ts = nowIso(); @@ -68,29 +78,52 @@ export function buildTaskCrudMethods( .prepare("select id from tasks where id = ? and company_id = ?") .get(id, input.companyId) as { id: string } | undefined; if (existing) { - getDb() - .prepare( - // eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: saveTask upsert path writes the full row (including status) as a single atomic UPDATE. The status→phase trigger keeps the shadow phase column in sync; no lease is ever held by saveTask callers (lease writes live behind checkoutTask). - "update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?", - ) - .run( - normalizeOptionalId(input.projectId), - normalizeOptionalId(input.goalId), - normalizeOptionalId(input.parentId), - input.title, - input.description, - assigneeAgentId, - normalizeOptionalId(input.workspaceId), - input.priority, - input.status, - ts, - id, - input.companyId, - ); + if (input.requiresFilesystem === undefined) { + getDb() + .prepare( + // eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: saveTask upsert path writes the full row (including status) as a single atomic UPDATE. The status→phase trigger keeps the shadow phase column in sync; no lease is ever held by saveTask callers (lease writes live behind checkoutTask). + "update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run( + normalizeOptionalId(input.projectId), + normalizeOptionalId(input.goalId), + normalizeOptionalId(input.parentId), + input.title, + input.description, + assigneeAgentId, + normalizeOptionalId(input.workspaceId), + input.priority, + input.status, + ts, + id, + input.companyId, + ); + } else { + getDb() + .prepare( + // eslint-disable-next-line no-restricted-syntax -- B2 Phase 1 grandfathered: saveTask upsert path writes the full row (including status) as a single atomic UPDATE. The status→phase trigger keeps the shadow phase column in sync; no lease is ever held by saveTask callers (lease writes live behind checkoutTask). + "update tasks set project_id = ?, goal_id = ?, parent_id = ?, title = ?, description = ?, assignee_agent_id = ?, workspace_id = ?, priority = ?, status = ?, requires_filesystem = ?, updated_at = ? where id = ? and company_id = ?", + ) + .run( + normalizeOptionalId(input.projectId), + normalizeOptionalId(input.goalId), + normalizeOptionalId(input.parentId), + input.title, + input.description, + assigneeAgentId, + normalizeOptionalId(input.workspaceId), + input.priority, + input.status, + input.requiresFilesystem ? 1 : 0, + ts, + id, + input.companyId, + ); + } } else { getDb() .prepare( - "insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, requires_filesystem, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .run( id, @@ -104,6 +137,7 @@ export function buildTaskCrudMethods( normalizeOptionalId(input.workspaceId), input.status, input.priority, + input.requiresFilesystem ? 1 : 0, ts, ts, ); diff --git a/src/main/onboarding-service.ts b/src/main/onboarding-service.ts index ce802f80..eb8dbb19 100644 --- a/src/main/onboarding-service.ts +++ b/src/main/onboarding-service.ts @@ -198,6 +198,11 @@ export function createOnboardingService({ workspaceId: workspaceId ?? leadAgent.workspaceId ?? null, priority: "high", status: "todo", + // Kickoff explicitly reads the workspace codebase (see + // description above — "list the top-level directory, read key + // files"), so this is a filesystem-touching task and must take + // an exclusive lock on the bound workspace. + requiresFilesystem: true, }); const startRunResult = queueTaskRun(taskId, "assignment", "system");