diff --git a/src/main/agent-runtime/artifact-projector.ts b/src/main/agent-runtime/artifact-projector.ts index 8d4e57a3..fde232ec 100644 --- a/src/main/agent-runtime/artifact-projector.ts +++ b/src/main/agent-runtime/artifact-projector.ts @@ -81,6 +81,9 @@ export function reconcileWorkspaceArtifacts( db: AppDatabase, ctx: ArtifactProjectorRunContext, ): ProjectionOutcome[] { + if (ctx.workspaceMode !== "exclusive") { + return [{ kind: "skipped", reason: "workspace_not_exclusive" }]; + } return projectCollectedChanges(db, ctx, collectChangedPaths(ctx)); } @@ -88,6 +91,9 @@ function projectBashArtifacts( db: AppDatabase, ctx: ArtifactProjectorRunContext, ): ProjectionOutcome[] { + if (ctx.workspaceMode !== "exclusive") { + return [{ kind: "skipped", reason: "workspace_not_exclusive" }]; + } return projectCollectedChanges(db, ctx, collectChangedPaths(ctx)); } diff --git a/src/main/agent-runtime/artifact-types.ts b/src/main/agent-runtime/artifact-types.ts index 8e42b496..51102fc5 100644 --- a/src/main/agent-runtime/artifact-types.ts +++ b/src/main/agent-runtime/artifact-types.ts @@ -14,6 +14,7 @@ export type ArtifactProjectorRunContext = { taskId: string | null; workspaceId: string | null; workspacePath: string | null; + workspaceMode: "exclusive" | "shared" | "none"; logger?: Logger; /** * mtime snapshot captured at run start for workspaces that are NOT diff --git a/src/main/agent-runtime/hook-handlers.ts b/src/main/agent-runtime/hook-handlers.ts index 5696cfd5..ddcc4eeb 100644 --- a/src/main/agent-runtime/hook-handlers.ts +++ b/src/main/agent-runtime/hook-handlers.ts @@ -56,6 +56,7 @@ export interface ActiveRunContext { taskSessionId: string; workspaceId: string | null; workspacePath: string | null; + workspaceMode: "exclusive" | "shared" | "none"; workspaceBaseline: import("./artifact-projector").WorkspaceBaseline | null; state: ActiveRunLifecycleState; } @@ -162,6 +163,7 @@ export function handleAfterToolCall( taskId: ctx.taskId, workspaceId: ctx.workspaceId, workspacePath: ctx.workspacePath, + workspaceMode: ctx.workspaceMode, logger: deps.logger, workspaceBaseline: ctx.workspaceBaseline, }; diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index bb649eeb..51425ab8 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -152,6 +152,7 @@ export class RuntimeBridge { taskId: ctx.taskId, workspaceId: ctx.workspaceId, workspacePath: ctx.workspacePath, + workspaceMode: ctx.workspaceMode, logger: this.deps.logger, workspaceBaseline: ctx.workspaceBaseline, }); @@ -394,14 +395,13 @@ export class RuntimeBridge { modelId: string; workspaceId: string | null; workspacePath: string | null; + workspaceMode: "exclusive" | "shared" | "none"; }; - // Capture an mtime baseline only for non-git workspaces so `bash` - // and the end-of-run reconciliation can still diff changed files - // against a known-start reference. Git workspaces skip the walk — - // `git status` is the source of truth there, and paying for an - // O(tree) scan on every run just to throw the result away is a - // hot-path tax the scheduler should not be paying. - const baseline = captureBaselineIfRequired(payload.workspacePath, this.deps.logger); + // Baseline scans are safe only while this run owns the workspace lock. + const baseline = + payload.workspaceMode === "exclusive" + ? captureBaselineIfRequired(payload.workspacePath, this.deps.logger) + : null; this.registerActiveRun({ runId, agentId: payload.agentId, @@ -412,6 +412,7 @@ export class RuntimeBridge { taskSessionId: payload.taskSessionId, workspaceId: payload.workspaceId, workspacePath: payload.workspacePath, + workspaceMode: payload.workspaceMode, workspaceBaseline: baseline, state: "preparing", }); diff --git a/src/main/orchestration/resolve-workspace.ts b/src/main/orchestration/resolve-workspace.ts index 658d73e6..d0b57dcd 100644 --- a/src/main/orchestration/resolve-workspace.ts +++ b/src/main/orchestration/resolve-workspace.ts @@ -5,7 +5,7 @@ import type { WorkspaceLockMode } from "./types"; /** * Per-task workspace lock inference (v52+). * - * The core invariant is: `lockMode='exclusive'` is an *opt-in*, + * The core invariant is: `lockMode='exclusive'` is an opt-in, * `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 @@ -103,16 +103,14 @@ type AgentInput = Pick & { * * Decision table: * - * 1. `task.workspaceId` set -> exclusive on that workspace - * (explicit opt-in by the task author, e.g. PR branch tasks). + * 1. Cognitive task (`requiresFilesystem !== true`) -> none. + * Workspace binding remains available as an observation root. * - * 2. Task signals filesystem need (`requiresFilesystem === true`) - * -> exclusive on agent.workspaceId / project primary. Throws if - * neither exists. + * 2. Filesystem task with `task.workspaceId` set -> exclusive on + * that workspace. * - * 3. Otherwise -> none / null. Pure cognitive work: strategy, - * planning, document drafting, financial analysis, PM decisions - * runs with no workspace and no lock. + * 3. Filesystem task without `task.workspaceId` -> exclusive on + * agent.workspaceId / project primary. Throws if neither exists. */ function inferLockMode( db: AppDatabase, @@ -120,18 +118,15 @@ function inferLockMode( agent: AgentInput, 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. - if (task?.workspaceId) { - return { lockMode: "exclusive", workspaceId: task.workspaceId, source: "task" }; - } - if (task?.requiresFilesystem !== true) { return { lockMode: "none", workspaceId: null, source: "none" }; } - // Rule 2: filesystem-touching task -> agent workspace first, then - // project primary. + if (task.workspaceId) { + return { lockMode: "exclusive", workspaceId: task.workspaceId, source: "task" }; + } + + // Filesystem-touching task -> agent workspace first, then project primary. if (agent.workspaceId) { return { lockMode: "exclusive", workspaceId: agent.workspaceId, source: "agent" }; } @@ -175,11 +170,25 @@ export function resolveWorkspaceWithLockMode( if (decision.lockMode === "none") { // No lock claim, but the cognitive run can still touch the // filesystem via `bash`. Pick a stable observation root so the - // artifact projector can baseline + diff the run's footprint. - // `getCompanyObservationWorkspace` returns the primary or oldest - // workspace; null only when the company has no workspace row at - // all, in which case there is genuinely nothing to observe. - const observation = db.core.workspaces.getCompanyObservationWorkspace(companyId); + // artifact projector can baseline + diff the run's footprint. A + // task-bound workspace is the strongest observation signal; the + // company primary/oldest workspace remains the company-level root. + let observation: WorkspaceRecord | null = null; + if (task?.workspaceId) { + observation = db.core.workspaces.getWorkspace(task.workspaceId); + if (!observation) { + throw new WorkspaceConfigurationError( + `Task observation workspace ${task.workspaceId} does not exist (companyId=${companyId}).`, + ); + } + if (observation.companyId !== companyId) { + throw new WorkspaceConfigurationError( + `Task observation workspace ${observation.id} belongs to company ${observation.companyId}, not ${companyId}.`, + ); + } + } else { + observation = db.core.workspaces.getCompanyObservationWorkspace(companyId); + } return { workspace: null, lockMode: "none", diff --git a/src/main/worker/entry/index.ts b/src/main/worker/entry/index.ts index f36a0364..908df9c9 100644 --- a/src/main/worker/entry/index.ts +++ b/src/main/worker/entry/index.ts @@ -15,11 +15,12 @@ interface ActiveRun { } /** - * Single source of truth for scheduling invariants: "one agent at a time" - * and "one exclusive-workspace lock at a time". Prior layout kept three - * parallel structures (`activeByAgent`, `activeByWorkspace`, queue) that - * had to be updated in lockstep. The Map-of-ActiveRun keyed by runId is - * the only authoritative record; agent/workspace busy checks iterate it. + * Single source of truth for scheduling invariants: one active run per + * agent and one active run per exclusive workspace lock. Prior layout + * kept three parallel structures (`activeByAgent`, `activeByWorkspace`, + * queue) that had to be updated in lockstep. The Map-of-ActiveRun keyed + * by runId is the only authoritative record; agent/workspace busy checks + * iterate it. */ const active = new Map(); const queue: RunJob[] = []; diff --git a/src/main/worker/runtime/helpers.ts b/src/main/worker/runtime/helpers.ts index 3d157962..f73f015f 100644 --- a/src/main/worker/runtime/helpers.ts +++ b/src/main/worker/runtime/helpers.ts @@ -109,34 +109,3 @@ export function buildRedactor(env: Record): (text: string) => st return result; }; } - -/** - * Pushes extra env vars onto `process.env` and returns a restore function - * that is **infallible** — each individual restore is guarded so a single - * malformed key cannot leave a partial restore that keeps a provider - * secret in the worker's env for the next run. Intended for short-lived - * turn-scope env (`AGENT_COMPANY_*`, connector secrets). The worker runs - * one agent at a time, so this is safe for the current scheduler; do not - * convert it to concurrent run isolation — pass per-spawn env at the - * child-process level instead. - */ -export function applyEnvOverrides(overrides: Record): () => void { - const saved: Record = {}; - for (const [key, value] of Object.entries(overrides)) { - saved[key] = process.env[key]; - process.env[key] = value; - } - return () => { - for (const [key, previous] of Object.entries(saved)) { - try { - if (previous === undefined) delete process.env[key]; - else process.env[key] = previous; - } catch { - // Individual restore failure is never fatal — move on so the - // remaining keys still revert. Should never happen in practice - // (the Node env-var assignment is synchronous and infallible for - // string values), but the guard lets the outer finally complete. - } - } - }; -} diff --git a/src/main/worker/runtime/index.ts b/src/main/worker/runtime/index.ts index 8a2ebc2d..f78f81b5 100644 --- a/src/main/worker/runtime/index.ts +++ b/src/main/worker/runtime/index.ts @@ -2,12 +2,12 @@ import type { Agent, AgentMessage } from "@mariozechner/pi-agent-core"; import { resolveModel, resolveExtraStreamHeaders } from "../../model-resolver"; import type { RunStatus } from "@shared/types"; import type { ChildMessage, RunJob } from "./helpers"; -import { applyEnvOverrides, buildRedactor } from "./helpers"; -import { createCodingTools } from "@mariozechner/pi-coding-agent"; +import { buildRedactor } from "./helpers"; import { createPiAgent, resolveThinkingLevel, type ErasedAgentTool } from "./pi-agent-factory"; import type { WorkerRpcClient } from "../entry/rpc"; import { createHireAgentTool, HIRE_AGENT_TOOL_NAME } from "./internal-tools/hire-agent-tool"; import { createFileApprovalTool, FILE_APPROVAL_TOOL_NAME } from "./internal-tools/file-approval-tool"; +import { createRunScopedCodingTools, mergeRunEnvs } from "./run-tools"; import { classifyRunUsage, hasAttributableAssistantUsage, @@ -95,7 +95,7 @@ export class WorkerRunner { private async runImpl(job: RunJob): Promise { const { post, rpc, isCancelled } = this.deps; - const redact = buildRedactor(job.env); + let redact = buildRedactor(job.env); const abortController = new AbortController(); this.abortControllers.set(job.runId, abortController); @@ -110,14 +110,6 @@ export class WorkerRunner { let terminalStopReason: TerminalAssistantStopReason | null = null; let errorMessage: string | null = null; - // Inject the AgentCompany job env (AGENT_COMPANY_API_URL / API_KEY / - // AGENT_ID / COMPANY_ID / RUN_ID / TASK_ID / WAKE_REASON + provider - // secret bindings) into the worker's process.env BEFORE Agent - // construction, so the agent's bash-tool children can call back into - // our HTTP API via fetch(process.env.AGENT_COMPANY_API_URL). Restored - // in the outer finally regardless of success / abort / throw. - const restoreJobEnv = applyEnvOverrides(job.env); - try { if (!job.model) { throw new Error(`Run ${job.runId} has no model selected.`); @@ -132,134 +124,131 @@ export class WorkerRunner { modelId: job.model, workspaceId: job.workspaceId ?? null, workspacePath: job.workspacePath ?? null, + workspaceMode: job.workspaceMode, }); if (!prepareResponse.ok) { throw new Error(prepareResponse.error); } const prepared = prepareResponse.result as PrepareRunResult; + const runEnv = mergeRunEnvs(job.env, prepared.extraEnv); + redact = buildRedactor(runEnv); - const restoreProviderEnv = applyEnvOverrides(prepared.extraEnv); - try { - const model = resolveModel(prepared.provider, job.model); - if (!model) { - throw new Error(`Unknown model ${job.model} for provider ${prepared.provider}.`); - } - resolvedModelId = model.id; - const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey); + const model = resolveModel(prepared.provider, job.model); + if (!model) { + throw new Error(`Unknown model ${job.model} for provider ${prepared.provider}.`); + } + resolvedModelId = model.id; + const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey); - const cwd = job.workspacePath ?? process.cwd(); - const codingTools = createCodingTools(cwd); - const internalTools: ErasedAgentTool[] = []; - for (const toolName of job.internalTools ?? []) { - switch (toolName) { - case HIRE_AGENT_TOOL_NAME: - internalTools.push(createHireAgentTool(rpc, job.runId)); - break; - case FILE_APPROVAL_TOOL_NAME: - internalTools.push(createFileApprovalTool(rpc, job.runId)); - break; - default: { - // Exhaustiveness guard: a new entry in the `internalTools` - // array without a matching case should be a compile-time - // regression. - const _exhaustive: never = toolName; - throw new Error(`Unknown internal tool requested: ${String(_exhaustive)}`); - } + const cwd = job.workspacePath ?? process.cwd(); + const codingTools = createRunScopedCodingTools(cwd, runEnv); + const internalTools: ErasedAgentTool[] = []; + for (const toolName of job.internalTools ?? []) { + switch (toolName) { + case HIRE_AGENT_TOOL_NAME: + internalTools.push(createHireAgentTool(rpc, job.runId)); + break; + case FILE_APPROVAL_TOOL_NAME: + internalTools.push(createFileApprovalTool(rpc, job.runId)); + break; + default: { + // Exhaustiveness guard: a new entry in the `internalTools` + // array without a matching case should be a compile-time + // regression. + const _exhaustive: never = toolName; + throw new Error(`Unknown internal tool requested: ${String(_exhaustive)}`); } } - const tools: ErasedAgentTool[] = [...codingTools, ...internalTools]; + } + const tools: ErasedAgentTool[] = [...codingTools, ...internalTools]; - const agent = createPiAgent({ - runId: job.runId, - taskSessionId: job.taskSessionId, - systemPrompt: job.systemPrompt ?? "", - model, - tools, - priorMessages: prepared.priorMessages, - thinkingLevel: resolveThinkingLevel(job.thinkingEffort), - apiKey: prepared.apiKey, - extraHeaders, - rpc, - onContextCompaction: (telemetry) => { - post({ - type: "context-compaction", - runId: telemetry.runId, - emittedAt: telemetry.emittedAt, - estimatedTokens: telemetry.estimatedTokens, - originalMessageCount: telemetry.originalMessageCount, - compactedMessageCount: telemetry.compactedMessageCount, - compactedOlderMessageCount: telemetry.compactedOlderMessageCount, - retainedRecentMessageCount: telemetry.retainedRecentMessageCount, - }); - }, - }); - this.activeAgents.set(job.runId, agent); - post({ type: "run-agent-state", runId: job.runId, state: "agent_active" }); - - const unsubscribe = agent.subscribe(async (event) => { - const timestamp = new Date().toISOString(); + const agent = createPiAgent({ + runId: job.runId, + taskSessionId: job.taskSessionId, + systemPrompt: job.systemPrompt ?? "", + model, + tools, + priorMessages: prepared.priorMessages, + thinkingLevel: resolveThinkingLevel(job.thinkingEffort), + apiKey: prepared.apiKey, + extraHeaders, + rpc, + onContextCompaction: (telemetry) => { post({ - type: "agent-event", - runId: job.runId, - agentId: job.agentId, - companyId: job.companyId, - taskId: job.taskId, - emittedAt: timestamp, - event: event as { type: string } & Record, + type: "context-compaction", + runId: telemetry.runId, + emittedAt: telemetry.emittedAt, + estimatedTokens: telemetry.estimatedTokens, + originalMessageCount: telemetry.originalMessageCount, + compactedMessageCount: telemetry.compactedMessageCount, + compactedOlderMessageCount: telemetry.compactedOlderMessageCount, + retainedRecentMessageCount: telemetry.retainedRecentMessageCount, }); + }, + }); + this.activeAgents.set(job.runId, agent); + post({ type: "run-agent-state", runId: job.runId, state: "agent_active" }); - if (event.type === "message_end" && event.message) { - await rpc.invoke(job.runId, "persistMessage", { - taskSessionId: job.taskSessionId, - message: event.message, - }); - const maybeAssistant = event.message as { - role?: string; - stopReason?: string; - errorMessage?: string; - usage?: { input?: number; output?: number; cost?: { total?: number } }; - }; - if (maybeAssistant.role === "assistant" && isTerminalAssistantStopReason(maybeAssistant.stopReason)) { - terminalStopReason = maybeAssistant.stopReason; - errorMessage = maybeAssistant.errorMessage ?? `Agent stopped with ${maybeAssistant.stopReason}.`; - } - if (hasAttributableAssistantUsage(maybeAssistant)) { - sawAttributableUsage = true; - finalUsage = { - inputTokens: typeof maybeAssistant.usage.input === "number" ? maybeAssistant.usage.input : finalUsage.inputTokens, - outputTokens: typeof maybeAssistant.usage.output === "number" ? maybeAssistant.usage.output : finalUsage.outputTokens, - }; - } - } + const unsubscribe = agent.subscribe(async (event) => { + const timestamp = new Date().toISOString(); + post({ + type: "agent-event", + runId: job.runId, + agentId: job.agentId, + companyId: job.companyId, + taskId: job.taskId, + emittedAt: timestamp, + event: event as { type: string } & Record, }); - try { - const promptContent = job.initialUserTurn.trim(); - if (promptContent.length === 0) { - throw new Error(`Run ${job.runId} has an empty initial user turn.`); - } - const promptMessage: AgentMessage = { - role: "user", - content: [{ type: "text", text: promptContent }], - timestamp: Date.now(), + if (event.type === "message_end" && event.message) { + await rpc.invoke(job.runId, "persistMessage", { + taskSessionId: job.taskSessionId, + message: event.message, + }); + const maybeAssistant = event.message as { + role?: string; + stopReason?: string; + errorMessage?: string; + usage?: { input?: number; output?: number; cost?: { total?: number } }; }; - const runPromise = agent.prompt(promptMessage); - abortController.signal.addEventListener("abort", () => agent.abort(), { once: true }); - await runPromise; - } finally { - post({ type: "run-agent-state", runId: job.runId, state: "closing" }); - unsubscribe(); - this.activeAgents.delete(job.runId); + if (maybeAssistant.role === "assistant" && isTerminalAssistantStopReason(maybeAssistant.stopReason)) { + terminalStopReason = maybeAssistant.stopReason; + errorMessage = maybeAssistant.errorMessage ?? `Agent stopped with ${maybeAssistant.stopReason}.`; + } + if (hasAttributableAssistantUsage(maybeAssistant)) { + sawAttributableUsage = true; + finalUsage = { + inputTokens: typeof maybeAssistant.usage.input === "number" ? maybeAssistant.usage.input : finalUsage.inputTokens, + outputTokens: typeof maybeAssistant.usage.output === "number" ? maybeAssistant.usage.output : finalUsage.outputTokens, + }; + } } + }); + + try { + const promptContent = job.initialUserTurn.trim(); + if (promptContent.length === 0) { + throw new Error(`Run ${job.runId} has an empty initial user turn.`); + } + const promptMessage: AgentMessage = { + role: "user", + content: [{ type: "text", text: promptContent }], + timestamp: Date.now(), + }; + const runPromise = agent.prompt(promptMessage); + abortController.signal.addEventListener("abort", () => agent.abort(), { once: true }); + await runPromise; } finally { - restoreProviderEnv(); + post({ type: "run-agent-state", runId: job.runId, state: "closing" }); + unsubscribe(); + this.activeAgents.delete(job.runId); } } catch (error) { errorMessage = error instanceof Error ? error.message : String(error); post({ type: "run-log", runId: job.runId, stream: "stderr", chunk: redact(errorMessage) }); } finally { this.abortControllers.delete(job.runId); - restoreJobEnv(); } const status: RunStatus = isCancelled(job.runId) diff --git a/src/main/worker/runtime/run-tools.ts b/src/main/worker/runtime/run-tools.ts new file mode 100644 index 00000000..111eb335 --- /dev/null +++ b/src/main/worker/runtime/run-tools.ts @@ -0,0 +1,35 @@ +import { createCodingTools, type BashToolOptions } from "@mariozechner/pi-coding-agent"; +import type { AgentState } from "@mariozechner/pi-agent-core"; + +type ErasedAgentTool = AgentState["tools"][number]; + +export function mergeRunEnvs( + jobEnv: Record, + preparedEnv: Record, +): Record { + return { ...jobEnv, ...preparedEnv }; +} + +export function createRunScopedBashOptions( + runEnv: Record, +): BashToolOptions { + const envSnapshot = { ...runEnv }; + return { + spawnHook: (context) => ({ + ...context, + env: { + ...context.env, + ...envSnapshot, + }, + }), + }; +} + +export function createRunScopedCodingTools( + cwd: string, + runEnv: Record, +): ErasedAgentTool[] { + return createCodingTools(cwd, { + bash: createRunScopedBashOptions(runEnv), + }) as ErasedAgentTool[]; +} diff --git a/src/renderer/components/execution/TaskCreator.tsx b/src/renderer/components/execution/TaskCreator.tsx index 9b83d011..4df930dc 100644 --- a/src/renderer/components/execution/TaskCreator.tsx +++ b/src/renderer/components/execution/TaskCreator.tsx @@ -14,11 +14,12 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on const [title, setTitle] = useState(""); const [description, setDescription] = useState("") const [goalId, setGoalId] = useState(goals[0]?.id ?? ""); const [projectId, setProjectId] = useState(projects[0]?.id ?? "") const [assigneeAgentId, setAssigneeAgentId] = useState(agents[0]?.id ?? ""); const [workspaceId, setWorkspaceId] = useState(workspaces[0]?.id ?? "") + const [requiresFilesystem, setRequiresFilesystem] = useState(false) const [priority, setPriority] = useState("medium"); const [submitError, setSubmitError] = useState(null); const [submitting, setSubmitting] = useState(false) 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, requiresFilesystem: Boolean(workspaceId), 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, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); setRequiresFilesystem(false); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) } } const [showAdvanced, setShowAdvanced] = useState(false) @@ -41,6 +42,15 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on ({ value: a.id, label: a.name }))]} /> setRequiresFilesystem(event.target.checked)} + className="h-4 w-4 rounded border-border accent-[color:var(--primary)]" + /> + {t("exec.requiresFilesystem")} + ) : null} diff --git a/src/renderer/components/execution/TaskInspectorEditForm.tsx b/src/renderer/components/execution/TaskInspectorEditForm.tsx index f86ed2a6..c26d290e 100644 --- a/src/renderer/components/execution/TaskInspectorEditForm.tsx +++ b/src/renderer/components/execution/TaskInspectorEditForm.tsx @@ -27,6 +27,7 @@ export function TaskInspectorEditForm({ const [status, setStatus] = useState(task.status) const [assignee, setAssignee] = useState(task.assigneeAgentId ?? "") const [workspace, setWorkspace] = useState(task.workspaceId ?? "") + const [requiresFilesystem, setRequiresFilesystem] = useState(task.requiresFilesystem) const [project, setProject] = useState(task.projectId ?? "") const [goal, setGoal] = useState(task.goalId ?? "") const [saving, setSaving] = useState(false) @@ -43,7 +44,7 @@ export function TaskInspectorEditForm({ status, assigneeAgentId: assignee || null, workspaceId: workspace || null, - requiresFilesystem: task.requiresFilesystem, + requiresFilesystem, projectId: project || null, goalId: goal || null, })) @@ -66,6 +67,15 @@ export function TaskInspectorEditForm({ ({ value: a.id, label: a.name }))]} /> setRequiresFilesystem(event.target.checked)} + className="h-4 w-4 rounded border-border accent-[color:var(--primary)]" + /> + {t("exec.requiresFilesystem")} + ({ value: g.id, label: g.title }))]} />
diff --git a/src/renderer/i18n/en.ts b/src/renderer/i18n/en.ts index 9a691d20..5b14d459 100644 --- a/src/renderer/i18n/en.ts +++ b/src/renderer/i18n/en.ts @@ -415,6 +415,7 @@ export const en: Record = { "exec.unassigned": "Unassigned", "exec.workspace": "Workspace", "exec.noWorkspace": "No workspace", + "exec.requiresFilesystem": "Requires filesystem lock", "exec.project": "Project", "exec.noProject": "No project", "exec.goal": "Goal", diff --git a/src/renderer/i18n/zh.ts b/src/renderer/i18n/zh.ts index 0b5efbab..97f25c7a 100644 --- a/src/renderer/i18n/zh.ts +++ b/src/renderer/i18n/zh.ts @@ -415,6 +415,7 @@ export const zh: Record = { "exec.unassigned": "未分配", "exec.workspace": "工作区", "exec.noWorkspace": "无工作区", + "exec.requiresFilesystem": "需要文件系统锁", "exec.project": "项目", "exec.noProject": "无项目", "exec.goal": "目标", diff --git a/tests/integration/work-products-lifecycle.spec.ts b/tests/integration/work-products-lifecycle.spec.ts index 531e11fe..758fa6a9 100644 --- a/tests/integration/work-products-lifecycle.spec.ts +++ b/tests/integration/work-products-lifecycle.spec.ts @@ -324,6 +324,7 @@ describe("work_products bulk retirement", () => { taskId: harness.taskId, workspaceId: harness.workspaceId, workspacePath: harness.workspacePath, + workspaceMode: "exclusive", workspaceBaseline: baseline, }; const outcomes = reconcileWorkspaceArtifacts(harness.db, ctx); diff --git a/tests/unit/artifact-projector-errors.test.ts b/tests/unit/artifact-projector-errors.test.ts index ce44e032..97102920 100644 --- a/tests/unit/artifact-projector-errors.test.ts +++ b/tests/unit/artifact-projector-errors.test.ts @@ -6,6 +6,7 @@ import type { AppDatabase } from "../../src/main/database"; import { captureBaselineIfRequired, projectToolExecutionToWorkProduct, + reconcileWorkspaceArtifacts, type ArtifactProjectorRunContext, } from "../../src/main/agent-runtime/artifact-projector"; @@ -56,6 +57,7 @@ describe("artifact projector error taxonomy", () => { taskId: "task-artifact-error", workspaceId: "workspace-artifact-error", workspacePath, + workspaceMode: "exclusive", logger, workspaceBaseline: null, }; @@ -83,4 +85,29 @@ describe("artifact projector error taxonomy", () => { ); expect(logger.error).not.toHaveBeenCalled(); }); + + it("skips broad bash projection when the run has no exclusive workspace lock", async () => { + const workspacePath = await createTempDir("agentcompany-shared-artifacts-"); + const ctx: ArtifactProjectorRunContext = { + runId: "run-shared", + agentId: "agent-shared", + companyId: "company-shared", + taskId: "task-shared", + workspaceId: null, + workspacePath, + workspaceMode: "none", + logger: createLogger(), + workspaceBaseline: null, + }; + + expect(projectToolExecutionToWorkProduct({} as AppDatabase, ctx, { + toolName: "bash", + args: { command: "touch report.md" }, + result: null, + isError: false, + })).toEqual([{ kind: "skipped", reason: "workspace_not_exclusive" }]); + expect(reconcileWorkspaceArtifacts({} as AppDatabase, ctx)).toEqual([ + { kind: "skipped", reason: "workspace_not_exclusive" }, + ]); + }); }); diff --git a/tests/unit/cofounder-approval-gate.test.ts b/tests/unit/cofounder-approval-gate.test.ts index 22447d5d..2e0dc451 100644 --- a/tests/unit/cofounder-approval-gate.test.ts +++ b/tests/unit/cofounder-approval-gate.test.ts @@ -64,6 +64,7 @@ function seedCofounderRun(db: AppDatabase) { taskSessionId: "session-1", workspaceId: null, workspacePath: null, + workspaceMode: "none", workspaceBaseline: null, state: "agent_active", }; diff --git a/tests/unit/resolve-workspace.test.ts b/tests/unit/resolve-workspace.test.ts index 387f050b..1a2a31b4 100644 --- a/tests/unit/resolve-workspace.test.ts +++ b/tests/unit/resolve-workspace.test.ts @@ -22,14 +22,13 @@ import { /** * Workspace lock policy (v52+). * - * `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. + * `lockMode='exclusive'` is opt-in: the task's requiresFilesystem + * column is true. A workspaceId can still provide the observation + * root for artifact projection, but it does not imply a write lock. + * 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) { @@ -70,11 +69,33 @@ describe("resolveWorkspaceWithLockMode", () => { await rm(tempDir, { recursive: true, force: true }); }); - it("task.workspaceId set → exclusive on that workspace", () => { + it("workspace-bound cognitive task stays lock-free and observes that workspace", () => { + const { companyId } = seedCompany(db); + const taskWorkspaceId = db.core.workspaces.saveWorkspace({ + companyId, + name: "Task workspace", + localPath: "/tmp/task-observation", + repoUrl: "https://github.com/example/task-observation", + repoRef: "main", + isPrimary: false, + }); + const result = resolveWorkspaceWithLockMode( + db, + { workspaceId: taskWorkspaceId, projectId: null, requiresFilesystem: false }, + { workspaceId: null, role: "CMO", department: "marketing" }, + companyId, + ); + expect(result.source).toBe("none"); + expect(result.lockMode).toBe("none"); + expect(result.workspace).toBeNull(); + expect(result.observationPath).toBe("/tmp/task-observation"); + }); + + it("requiresFilesystem + task.workspaceId → exclusive on that workspace", () => { const { companyId, workspaceId } = seedCompany(db); const result = resolveWorkspaceWithLockMode( db, - { workspaceId, projectId: null }, + { workspaceId, projectId: null, requiresFilesystem: true }, { workspaceId: null, role: "CMO", department: "marketing" }, companyId, ); @@ -194,7 +215,7 @@ describe("resolveWorkspaceWithLockMode", () => { expect(result.workspace).toBeNull(); }); - it("task.workspaceId wins over agent.workspaceId even when both are set", () => { + it("filesystem task workspaceId wins over agent.workspaceId when both are set", () => { const { companyId, workspaceId: agentWsId } = seedCompany(db); const taskWsId = db.core.workspaces.saveWorkspace({ companyId, @@ -206,7 +227,7 @@ describe("resolveWorkspaceWithLockMode", () => { }); const result = resolveWorkspaceWithLockMode( db, - { workspaceId: taskWsId, projectId: null }, + { workspaceId: taskWsId, projectId: null, requiresFilesystem: true }, { workspaceId: agentWsId, role: "CTO", department: "engineering" }, companyId, ); diff --git a/tests/unit/run-tools-env.test.ts b/tests/unit/run-tools-env.test.ts new file mode 100644 index 00000000..777bcc0d --- /dev/null +++ b/tests/unit/run-tools-env.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { createRunScopedBashOptions } from "../../src/main/worker/runtime/run-tools"; + +describe("run-scoped bash environment", () => { + it("merges run env into the spawned command environment", () => { + const options = createRunScopedBashOptions({ + AGENT_COMPANY_RUN_ID: "run-a", + AGENT_COMPANY_API_KEY: "secret-a", + }); + + const context = options.spawnHook?.({ + command: "env", + cwd: "/tmp", + env: { + PATH: "/usr/bin", + AGENT_COMPANY_RUN_ID: "base", + }, + }); + + expect(context?.env.PATH).toBe("/usr/bin"); + expect(context?.env.AGENT_COMPANY_RUN_ID).toBe("run-a"); + expect(context?.env.AGENT_COMPANY_API_KEY).toBe("secret-a"); + }); + + it("keeps two run env hooks independent", () => { + const runA = createRunScopedBashOptions({ AGENT_COMPANY_RUN_ID: "run-a" }); + const runB = createRunScopedBashOptions({ AGENT_COMPANY_RUN_ID: "run-b" }); + const base = { command: "env", cwd: "/tmp", env: { PATH: "/usr/bin" } }; + + expect(runA.spawnHook?.(base).env.AGENT_COMPANY_RUN_ID).toBe("run-a"); + expect(runB.spawnHook?.(base).env.AGENT_COMPANY_RUN_ID).toBe("run-b"); + }); + + it("freezes the env snapshot at factory creation", () => { + const env = { AGENT_COMPANY_RUN_ID: "run-a" }; + const options = createRunScopedBashOptions(env); + env.AGENT_COMPANY_RUN_ID = "mutated"; + + const context = options.spawnHook?.({ + command: "env", + cwd: "/tmp", + env: {}, + }); + + expect(context?.env.AGENT_COMPANY_RUN_ID).toBe("run-a"); + }); +});