Enable lock-free parallel agent runs
This commit is contained in:
1 parent
d13c9d83e8
commit
1a7eb8f1b3
18 files changed
+327
-194
No files matched your search
@@ -81,6 +81,9 @@ export function reconcileWorkspaceArtifacts(
|
|||||||
db: AppDatabase,
|
db: AppDatabase,
|
||||||
ctx: ArtifactProjectorRunContext,
|
ctx: ArtifactProjectorRunContext,
|
||||||
): ProjectionOutcome[] {
|
): ProjectionOutcome[] {
|
||||||
|
if (ctx.workspaceMode !== "exclusive") {
|
||||||
|
return [{ kind: "skipped", reason: "workspace_not_exclusive" }];
|
||||||
|
}
|
||||||
return projectCollectedChanges(db, ctx, collectChangedPaths(ctx));
|
return projectCollectedChanges(db, ctx, collectChangedPaths(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +91,9 @@ function projectBashArtifacts(
|
|||||||
db: AppDatabase,
|
db: AppDatabase,
|
||||||
ctx: ArtifactProjectorRunContext,
|
ctx: ArtifactProjectorRunContext,
|
||||||
): ProjectionOutcome[] {
|
): ProjectionOutcome[] {
|
||||||
|
if (ctx.workspaceMode !== "exclusive") {
|
||||||
|
return [{ kind: "skipped", reason: "workspace_not_exclusive" }];
|
||||||
|
}
|
||||||
return projectCollectedChanges(db, ctx, collectChangedPaths(ctx));
|
return projectCollectedChanges(db, ctx, collectChangedPaths(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ArtifactProjectorRunContext = {
|
|||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
workspacePath: string | null;
|
workspacePath: string | null;
|
||||||
|
workspaceMode: "exclusive" | "shared" | "none";
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
/**
|
/**
|
||||||
* mtime snapshot captured at run start for workspaces that are NOT
|
* mtime snapshot captured at run start for workspaces that are NOT
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface ActiveRunContext {
|
|||||||
taskSessionId: string;
|
taskSessionId: string;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
workspacePath: string | null;
|
workspacePath: string | null;
|
||||||
|
workspaceMode: "exclusive" | "shared" | "none";
|
||||||
workspaceBaseline: import("./artifact-projector").WorkspaceBaseline | null;
|
workspaceBaseline: import("./artifact-projector").WorkspaceBaseline | null;
|
||||||
state: ActiveRunLifecycleState;
|
state: ActiveRunLifecycleState;
|
||||||
}
|
}
|
||||||
@@ -162,6 +163,7 @@ export function handleAfterToolCall(
|
|||||||
taskId: ctx.taskId,
|
taskId: ctx.taskId,
|
||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
workspacePath: ctx.workspacePath,
|
workspacePath: ctx.workspacePath,
|
||||||
|
workspaceMode: ctx.workspaceMode,
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
workspaceBaseline: ctx.workspaceBaseline,
|
workspaceBaseline: ctx.workspaceBaseline,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export class RuntimeBridge {
|
|||||||
taskId: ctx.taskId,
|
taskId: ctx.taskId,
|
||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
workspacePath: ctx.workspacePath,
|
workspacePath: ctx.workspacePath,
|
||||||
|
workspaceMode: ctx.workspaceMode,
|
||||||
logger: this.deps.logger,
|
logger: this.deps.logger,
|
||||||
workspaceBaseline: ctx.workspaceBaseline,
|
workspaceBaseline: ctx.workspaceBaseline,
|
||||||
});
|
});
|
||||||
@@ -394,14 +395,13 @@ export class RuntimeBridge {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
workspacePath: string | null;
|
workspacePath: string | null;
|
||||||
|
workspaceMode: "exclusive" | "shared" | "none";
|
||||||
};
|
};
|
||||||
// Capture an mtime baseline only for non-git workspaces so `bash`
|
// Baseline scans are safe only while this run owns the workspace lock.
|
||||||
// and the end-of-run reconciliation can still diff changed files
|
const baseline =
|
||||||
// against a known-start reference. Git workspaces skip the walk —
|
payload.workspaceMode === "exclusive"
|
||||||
// `git status` is the source of truth there, and paying for an
|
? captureBaselineIfRequired(payload.workspacePath, this.deps.logger)
|
||||||
// O(tree) scan on every run just to throw the result away is a
|
: null;
|
||||||
// hot-path tax the scheduler should not be paying.
|
|
||||||
const baseline = captureBaselineIfRequired(payload.workspacePath, this.deps.logger);
|
|
||||||
this.registerActiveRun({
|
this.registerActiveRun({
|
||||||
runId,
|
runId,
|
||||||
agentId: payload.agentId,
|
agentId: payload.agentId,
|
||||||
@@ -412,6 +412,7 @@ export class RuntimeBridge {
|
|||||||
taskSessionId: payload.taskSessionId,
|
taskSessionId: payload.taskSessionId,
|
||||||
workspaceId: payload.workspaceId,
|
workspaceId: payload.workspaceId,
|
||||||
workspacePath: payload.workspacePath,
|
workspacePath: payload.workspacePath,
|
||||||
|
workspaceMode: payload.workspaceMode,
|
||||||
workspaceBaseline: baseline,
|
workspaceBaseline: baseline,
|
||||||
state: "preparing",
|
state: "preparing",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { WorkspaceLockMode } from "./types";
|
|||||||
/**
|
/**
|
||||||
* Per-task workspace lock inference (v52+).
|
* 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
|
* `lockMode='none'` is the default. Previously every resolved
|
||||||
* workspace was held exclusively, which folded four runnable
|
* workspace was held exclusively, which folded four runnable
|
||||||
* agents onto a single company workspace lock and serialised the
|
* agents onto a single company workspace lock and serialised the
|
||||||
@@ -103,16 +103,14 @@ type AgentInput = Pick<AgentRecord, "workspaceId"> & {
|
|||||||
*
|
*
|
||||||
* Decision table:
|
* Decision table:
|
||||||
*
|
*
|
||||||
* 1. `task.workspaceId` set -> exclusive on that workspace
|
* 1. Cognitive task (`requiresFilesystem !== true`) -> none.
|
||||||
* (explicit opt-in by the task author, e.g. PR branch tasks).
|
* Workspace binding remains available as an observation root.
|
||||||
*
|
*
|
||||||
* 2. Task signals filesystem need (`requiresFilesystem === true`)
|
* 2. Filesystem task with `task.workspaceId` set -> exclusive on
|
||||||
* -> exclusive on agent.workspaceId / project primary. Throws if
|
* that workspace.
|
||||||
* neither exists.
|
|
||||||
*
|
*
|
||||||
* 3. Otherwise -> none / null. Pure cognitive work: strategy,
|
* 3. Filesystem task without `task.workspaceId` -> exclusive on
|
||||||
* planning, document drafting, financial analysis, PM decisions
|
* agent.workspaceId / project primary. Throws if neither exists.
|
||||||
* runs with no workspace and no lock.
|
|
||||||
*/
|
*/
|
||||||
function inferLockMode(
|
function inferLockMode(
|
||||||
db: AppDatabase,
|
db: AppDatabase,
|
||||||
@@ -120,18 +118,15 @@ function inferLockMode(
|
|||||||
agent: AgentInput,
|
agent: AgentInput,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
): { lockMode: WorkspaceLockMode; workspaceId: string | null; source: WorkspaceResolutionSource } {
|
): { 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) {
|
if (task?.requiresFilesystem !== true) {
|
||||||
return { lockMode: "none", workspaceId: null, source: "none" };
|
return { lockMode: "none", workspaceId: null, source: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 2: filesystem-touching task -> agent workspace first, then
|
if (task.workspaceId) {
|
||||||
// project primary.
|
return { lockMode: "exclusive", workspaceId: task.workspaceId, source: "task" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filesystem-touching task -> agent workspace first, then project primary.
|
||||||
if (agent.workspaceId) {
|
if (agent.workspaceId) {
|
||||||
return { lockMode: "exclusive", workspaceId: agent.workspaceId, source: "agent" };
|
return { lockMode: "exclusive", workspaceId: agent.workspaceId, source: "agent" };
|
||||||
}
|
}
|
||||||
@@ -175,11 +170,25 @@ export function resolveWorkspaceWithLockMode(
|
|||||||
if (decision.lockMode === "none") {
|
if (decision.lockMode === "none") {
|
||||||
// No lock claim, but the cognitive run can still touch the
|
// No lock claim, but the cognitive run can still touch the
|
||||||
// filesystem via `bash`. Pick a stable observation root so the
|
// filesystem via `bash`. Pick a stable observation root so the
|
||||||
// artifact projector can baseline + diff the run's footprint.
|
// artifact projector can baseline + diff the run's footprint. A
|
||||||
// `getCompanyObservationWorkspace` returns the primary or oldest
|
// task-bound workspace is the strongest observation signal; the
|
||||||
// workspace; null only when the company has no workspace row at
|
// company primary/oldest workspace remains the company-level root.
|
||||||
// all, in which case there is genuinely nothing to observe.
|
let observation: WorkspaceRecord | null = null;
|
||||||
const observation = db.core.workspaces.getCompanyObservationWorkspace(companyId);
|
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 {
|
return {
|
||||||
workspace: null,
|
workspace: null,
|
||||||
lockMode: "none",
|
lockMode: "none",
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ interface ActiveRun {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for scheduling invariants: "one agent at a time"
|
* Single source of truth for scheduling invariants: one active run per
|
||||||
* and "one exclusive-workspace lock at a time". Prior layout kept three
|
* agent and one active run per exclusive workspace lock. Prior layout
|
||||||
* parallel structures (`activeByAgent`, `activeByWorkspace`, queue) that
|
* kept three parallel structures (`activeByAgent`, `activeByWorkspace`,
|
||||||
* had to be updated in lockstep. The Map-of-ActiveRun keyed by runId is
|
* queue) that had to be updated in lockstep. The Map-of-ActiveRun keyed
|
||||||
* the only authoritative record; agent/workspace busy checks iterate it.
|
* by runId is the only authoritative record; agent/workspace busy checks
|
||||||
|
* iterate it.
|
||||||
*/
|
*/
|
||||||
const active = new Map<string, ActiveRun>();
|
const active = new Map<string, ActiveRun>();
|
||||||
const queue: RunJob[] = [];
|
const queue: RunJob[] = [];
|
||||||
|
|||||||
@@ -109,34 +109,3 @@ export function buildRedactor(env: Record<string, string>): (text: string) => st
|
|||||||
return result;
|
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<string, string>): () => void {
|
|
||||||
const saved: Record<string, string | undefined> = {};
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+104
-115
@@ -2,12 +2,12 @@ import type { Agent, AgentMessage } from "@mariozechner/pi-agent-core";
|
|||||||
import { resolveModel, resolveExtraStreamHeaders } from "../../model-resolver";
|
import { resolveModel, resolveExtraStreamHeaders } from "../../model-resolver";
|
||||||
import type { RunStatus } from "@shared/types";
|
import type { RunStatus } from "@shared/types";
|
||||||
import type { ChildMessage, RunJob } from "./helpers";
|
import type { ChildMessage, RunJob } from "./helpers";
|
||||||
import { applyEnvOverrides, buildRedactor } from "./helpers";
|
import { buildRedactor } from "./helpers";
|
||||||
import { createCodingTools } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { createPiAgent, resolveThinkingLevel, type ErasedAgentTool } from "./pi-agent-factory";
|
import { createPiAgent, resolveThinkingLevel, type ErasedAgentTool } from "./pi-agent-factory";
|
||||||
import type { WorkerRpcClient } from "../entry/rpc";
|
import type { WorkerRpcClient } from "../entry/rpc";
|
||||||
import { createHireAgentTool, HIRE_AGENT_TOOL_NAME } from "./internal-tools/hire-agent-tool";
|
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 { createFileApprovalTool, FILE_APPROVAL_TOOL_NAME } from "./internal-tools/file-approval-tool";
|
||||||
|
import { createRunScopedCodingTools, mergeRunEnvs } from "./run-tools";
|
||||||
import {
|
import {
|
||||||
classifyRunUsage,
|
classifyRunUsage,
|
||||||
hasAttributableAssistantUsage,
|
hasAttributableAssistantUsage,
|
||||||
@@ -95,7 +95,7 @@ export class WorkerRunner {
|
|||||||
|
|
||||||
private async runImpl(job: RunJob): Promise<void> {
|
private async runImpl(job: RunJob): Promise<void> {
|
||||||
const { post, rpc, isCancelled } = this.deps;
|
const { post, rpc, isCancelled } = this.deps;
|
||||||
const redact = buildRedactor(job.env);
|
let redact = buildRedactor(job.env);
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
this.abortControllers.set(job.runId, abortController);
|
this.abortControllers.set(job.runId, abortController);
|
||||||
|
|
||||||
@@ -110,14 +110,6 @@ export class WorkerRunner {
|
|||||||
let terminalStopReason: TerminalAssistantStopReason | null = null;
|
let terminalStopReason: TerminalAssistantStopReason | null = null;
|
||||||
let errorMessage: string | 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 {
|
try {
|
||||||
if (!job.model) {
|
if (!job.model) {
|
||||||
throw new Error(`Run ${job.runId} has no model selected.`);
|
throw new Error(`Run ${job.runId} has no model selected.`);
|
||||||
@@ -132,134 +124,131 @@ export class WorkerRunner {
|
|||||||
modelId: job.model,
|
modelId: job.model,
|
||||||
workspaceId: job.workspaceId ?? null,
|
workspaceId: job.workspaceId ?? null,
|
||||||
workspacePath: job.workspacePath ?? null,
|
workspacePath: job.workspacePath ?? null,
|
||||||
|
workspaceMode: job.workspaceMode,
|
||||||
});
|
});
|
||||||
if (!prepareResponse.ok) {
|
if (!prepareResponse.ok) {
|
||||||
throw new Error(prepareResponse.error);
|
throw new Error(prepareResponse.error);
|
||||||
}
|
}
|
||||||
const prepared = prepareResponse.result as PrepareRunResult;
|
const prepared = prepareResponse.result as PrepareRunResult;
|
||||||
|
const runEnv = mergeRunEnvs(job.env, prepared.extraEnv);
|
||||||
|
redact = buildRedactor(runEnv);
|
||||||
|
|
||||||
const restoreProviderEnv = applyEnvOverrides(prepared.extraEnv);
|
const model = resolveModel(prepared.provider, job.model);
|
||||||
try {
|
if (!model) {
|
||||||
const model = resolveModel(prepared.provider, job.model);
|
throw new Error(`Unknown model ${job.model} for provider ${prepared.provider}.`);
|
||||||
if (!model) {
|
}
|
||||||
throw new Error(`Unknown model ${job.model} for provider ${prepared.provider}.`);
|
resolvedModelId = model.id;
|
||||||
}
|
const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey);
|
||||||
resolvedModelId = model.id;
|
|
||||||
const extraHeaders = resolveExtraStreamHeaders(prepared.provider, prepared.apiKey);
|
|
||||||
|
|
||||||
const cwd = job.workspacePath ?? process.cwd();
|
const cwd = job.workspacePath ?? process.cwd();
|
||||||
const codingTools = createCodingTools(cwd);
|
const codingTools = createRunScopedCodingTools(cwd, runEnv);
|
||||||
const internalTools: ErasedAgentTool[] = [];
|
const internalTools: ErasedAgentTool[] = [];
|
||||||
for (const toolName of job.internalTools ?? []) {
|
for (const toolName of job.internalTools ?? []) {
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case HIRE_AGENT_TOOL_NAME:
|
case HIRE_AGENT_TOOL_NAME:
|
||||||
internalTools.push(createHireAgentTool(rpc, job.runId));
|
internalTools.push(createHireAgentTool(rpc, job.runId));
|
||||||
break;
|
break;
|
||||||
case FILE_APPROVAL_TOOL_NAME:
|
case FILE_APPROVAL_TOOL_NAME:
|
||||||
internalTools.push(createFileApprovalTool(rpc, job.runId));
|
internalTools.push(createFileApprovalTool(rpc, job.runId));
|
||||||
break;
|
break;
|
||||||
default: {
|
default: {
|
||||||
// Exhaustiveness guard: a new entry in the `internalTools`
|
// Exhaustiveness guard: a new entry in the `internalTools`
|
||||||
// array without a matching case should be a compile-time
|
// array without a matching case should be a compile-time
|
||||||
// regression.
|
// regression.
|
||||||
const _exhaustive: never = toolName;
|
const _exhaustive: never = toolName;
|
||||||
throw new Error(`Unknown internal tool requested: ${String(_exhaustive)}`);
|
throw new Error(`Unknown internal tool requested: ${String(_exhaustive)}`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tools: ErasedAgentTool[] = [...codingTools, ...internalTools];
|
}
|
||||||
|
const tools: ErasedAgentTool[] = [...codingTools, ...internalTools];
|
||||||
|
|
||||||
const agent = createPiAgent({
|
const agent = createPiAgent({
|
||||||
runId: job.runId,
|
runId: job.runId,
|
||||||
taskSessionId: job.taskSessionId,
|
taskSessionId: job.taskSessionId,
|
||||||
systemPrompt: job.systemPrompt ?? "",
|
systemPrompt: job.systemPrompt ?? "",
|
||||||
model,
|
model,
|
||||||
tools,
|
tools,
|
||||||
priorMessages: prepared.priorMessages,
|
priorMessages: prepared.priorMessages,
|
||||||
thinkingLevel: resolveThinkingLevel(job.thinkingEffort),
|
thinkingLevel: resolveThinkingLevel(job.thinkingEffort),
|
||||||
apiKey: prepared.apiKey,
|
apiKey: prepared.apiKey,
|
||||||
extraHeaders,
|
extraHeaders,
|
||||||
rpc,
|
rpc,
|
||||||
onContextCompaction: (telemetry) => {
|
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();
|
|
||||||
post({
|
post({
|
||||||
type: "agent-event",
|
type: "context-compaction",
|
||||||
runId: job.runId,
|
runId: telemetry.runId,
|
||||||
agentId: job.agentId,
|
emittedAt: telemetry.emittedAt,
|
||||||
companyId: job.companyId,
|
estimatedTokens: telemetry.estimatedTokens,
|
||||||
taskId: job.taskId,
|
originalMessageCount: telemetry.originalMessageCount,
|
||||||
emittedAt: timestamp,
|
compactedMessageCount: telemetry.compactedMessageCount,
|
||||||
event: event as { type: string } & Record<string, unknown>,
|
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) {
|
const unsubscribe = agent.subscribe(async (event) => {
|
||||||
await rpc.invoke(job.runId, "persistMessage", {
|
const timestamp = new Date().toISOString();
|
||||||
taskSessionId: job.taskSessionId,
|
post({
|
||||||
message: event.message,
|
type: "agent-event",
|
||||||
});
|
runId: job.runId,
|
||||||
const maybeAssistant = event.message as {
|
agentId: job.agentId,
|
||||||
role?: string;
|
companyId: job.companyId,
|
||||||
stopReason?: string;
|
taskId: job.taskId,
|
||||||
errorMessage?: string;
|
emittedAt: timestamp,
|
||||||
usage?: { input?: number; output?: number; cost?: { total?: number } };
|
event: event as { type: string } & Record<string, unknown>,
|
||||||
};
|
|
||||||
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 {
|
if (event.type === "message_end" && event.message) {
|
||||||
const promptContent = job.initialUserTurn.trim();
|
await rpc.invoke(job.runId, "persistMessage", {
|
||||||
if (promptContent.length === 0) {
|
taskSessionId: job.taskSessionId,
|
||||||
throw new Error(`Run ${job.runId} has an empty initial user turn.`);
|
message: event.message,
|
||||||
}
|
});
|
||||||
const promptMessage: AgentMessage = {
|
const maybeAssistant = event.message as {
|
||||||
role: "user",
|
role?: string;
|
||||||
content: [{ type: "text", text: promptContent }],
|
stopReason?: string;
|
||||||
timestamp: Date.now(),
|
errorMessage?: string;
|
||||||
|
usage?: { input?: number; output?: number; cost?: { total?: number } };
|
||||||
};
|
};
|
||||||
const runPromise = agent.prompt(promptMessage);
|
if (maybeAssistant.role === "assistant" && isTerminalAssistantStopReason(maybeAssistant.stopReason)) {
|
||||||
abortController.signal.addEventListener("abort", () => agent.abort(), { once: true });
|
terminalStopReason = maybeAssistant.stopReason;
|
||||||
await runPromise;
|
errorMessage = maybeAssistant.errorMessage ?? `Agent stopped with ${maybeAssistant.stopReason}.`;
|
||||||
} finally {
|
}
|
||||||
post({ type: "run-agent-state", runId: job.runId, state: "closing" });
|
if (hasAttributableAssistantUsage(maybeAssistant)) {
|
||||||
unsubscribe();
|
sawAttributableUsage = true;
|
||||||
this.activeAgents.delete(job.runId);
|
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 {
|
} finally {
|
||||||
restoreProviderEnv();
|
post({ type: "run-agent-state", runId: job.runId, state: "closing" });
|
||||||
|
unsubscribe();
|
||||||
|
this.activeAgents.delete(job.runId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
post({ type: "run-log", runId: job.runId, stream: "stderr", chunk: redact(errorMessage) });
|
post({ type: "run-log", runId: job.runId, stream: "stderr", chunk: redact(errorMessage) });
|
||||||
} finally {
|
} finally {
|
||||||
this.abortControllers.delete(job.runId);
|
this.abortControllers.delete(job.runId);
|
||||||
restoreJobEnv();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const status: RunStatus = isCancelled(job.runId)
|
const status: RunStatus = isCancelled(job.runId)
|
||||||
|
|||||||
@@ -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<string, string>,
|
||||||
|
preparedEnv: Record<string, string>,
|
||||||
|
): Record<string, string> {
|
||||||
|
return { ...jobEnv, ...preparedEnv };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRunScopedBashOptions(
|
||||||
|
runEnv: Record<string, string>,
|
||||||
|
): BashToolOptions {
|
||||||
|
const envSnapshot = { ...runEnv };
|
||||||
|
return {
|
||||||
|
spawnHook: (context) => ({
|
||||||
|
...context,
|
||||||
|
env: {
|
||||||
|
...context.env,
|
||||||
|
...envSnapshot,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRunScopedCodingTools(
|
||||||
|
cwd: string,
|
||||||
|
runEnv: Record<string, string>,
|
||||||
|
): ErasedAgentTool[] {
|
||||||
|
return createCodingTools(cwd, {
|
||||||
|
bash: createRunScopedBashOptions(runEnv),
|
||||||
|
}) as ErasedAgentTool[];
|
||||||
|
}
|
||||||
@@ -14,11 +14,12 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
|
|||||||
const [title, setTitle] = useState(""); const [description, setDescription] = useState("")
|
const [title, setTitle] = useState(""); const [description, setDescription] = useState("")
|
||||||
const [goalId, setGoalId] = useState(goals[0]?.id ?? ""); const [projectId, setProjectId] = useState(projects[0]?.id ?? "")
|
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 [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<string | null>(null); const [submitting, setSubmitting] = useState(false)
|
const [priority, setPriority] = useState("medium"); const [submitError, setSubmitError] = useState<string | null>(null); const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
async function handleCreateTask() {
|
async function handleCreateTask() {
|
||||||
setSubmitError(null); setSubmitting(true)
|
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)
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
@@ -41,6 +42,15 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
|
|||||||
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
|
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
|
||||||
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
|
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
|
||||||
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
|
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
|
||||||
|
<label className="flex items-center gap-2.5 md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requiresFilesystem}
|
||||||
|
onChange={(event) => setRequiresFilesystem(event.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-[13px] text-foreground">{t("exec.requiresFilesystem")}</span>
|
||||||
|
</label>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function TaskInspectorEditForm({
|
|||||||
const [status, setStatus] = useState<TaskStatus>(task.status)
|
const [status, setStatus] = useState<TaskStatus>(task.status)
|
||||||
const [assignee, setAssignee] = useState(task.assigneeAgentId ?? "")
|
const [assignee, setAssignee] = useState(task.assigneeAgentId ?? "")
|
||||||
const [workspace, setWorkspace] = useState(task.workspaceId ?? "")
|
const [workspace, setWorkspace] = useState(task.workspaceId ?? "")
|
||||||
|
const [requiresFilesystem, setRequiresFilesystem] = useState(task.requiresFilesystem)
|
||||||
const [project, setProject] = useState(task.projectId ?? "")
|
const [project, setProject] = useState(task.projectId ?? "")
|
||||||
const [goal, setGoal] = useState(task.goalId ?? "")
|
const [goal, setGoal] = useState(task.goalId ?? "")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -43,7 +44,7 @@ export function TaskInspectorEditForm({
|
|||||||
status,
|
status,
|
||||||
assigneeAgentId: assignee || null,
|
assigneeAgentId: assignee || null,
|
||||||
workspaceId: workspace || null,
|
workspaceId: workspace || null,
|
||||||
requiresFilesystem: task.requiresFilesystem,
|
requiresFilesystem,
|
||||||
projectId: project || null,
|
projectId: project || null,
|
||||||
goalId: goal || null,
|
goalId: goal || null,
|
||||||
}))
|
}))
|
||||||
@@ -66,6 +67,15 @@ export function TaskInspectorEditForm({
|
|||||||
<Select label={t("exec.status")} value={status} onChange={(v) => setStatus(v as TaskStatus)} options={["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"].map((v) => ({ value: v, label: v.replaceAll("_", " ") }))} />
|
<Select label={t("exec.status")} value={status} onChange={(v) => setStatus(v as TaskStatus)} options={["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"].map((v) => ({ value: v, label: v.replaceAll("_", " ") }))} />
|
||||||
<Select label={t("exec.assignee")} value={assignee} onChange={setAssignee} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
|
<Select label={t("exec.assignee")} value={assignee} onChange={setAssignee} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
|
||||||
<Select label={t("exec.workspace")} value={workspace} onChange={setWorkspace} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
|
<Select label={t("exec.workspace")} value={workspace} onChange={setWorkspace} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
|
||||||
|
<label className="flex items-center gap-2.5 md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requiresFilesystem}
|
||||||
|
onChange={(event) => setRequiresFilesystem(event.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-border accent-[color:var(--primary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-[13px] text-foreground">{t("exec.requiresFilesystem")}</span>
|
||||||
|
</label>
|
||||||
<Select label={t("exec.project")} value={project} onChange={setProject} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
|
<Select label={t("exec.project")} value={project} onChange={setProject} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
|
||||||
<Select label={t("exec.goal")} value={goal} onChange={setGoal} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
|
<Select label={t("exec.goal")} value={goal} onChange={setGoal} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
|
||||||
<div className="md:col-span-2 flex gap-3">
|
<div className="md:col-span-2 flex gap-3">
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ export const en: Record<string, string> = {
|
|||||||
"exec.unassigned": "Unassigned",
|
"exec.unassigned": "Unassigned",
|
||||||
"exec.workspace": "Workspace",
|
"exec.workspace": "Workspace",
|
||||||
"exec.noWorkspace": "No workspace",
|
"exec.noWorkspace": "No workspace",
|
||||||
|
"exec.requiresFilesystem": "Requires filesystem lock",
|
||||||
"exec.project": "Project",
|
"exec.project": "Project",
|
||||||
"exec.noProject": "No project",
|
"exec.noProject": "No project",
|
||||||
"exec.goal": "Goal",
|
"exec.goal": "Goal",
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ export const zh: Record<string, string> = {
|
|||||||
"exec.unassigned": "未分配",
|
"exec.unassigned": "未分配",
|
||||||
"exec.workspace": "工作区",
|
"exec.workspace": "工作区",
|
||||||
"exec.noWorkspace": "无工作区",
|
"exec.noWorkspace": "无工作区",
|
||||||
|
"exec.requiresFilesystem": "需要文件系统锁",
|
||||||
"exec.project": "项目",
|
"exec.project": "项目",
|
||||||
"exec.noProject": "无项目",
|
"exec.noProject": "无项目",
|
||||||
"exec.goal": "目标",
|
"exec.goal": "目标",
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ describe("work_products bulk retirement", () => {
|
|||||||
taskId: harness.taskId,
|
taskId: harness.taskId,
|
||||||
workspaceId: harness.workspaceId,
|
workspaceId: harness.workspaceId,
|
||||||
workspacePath: harness.workspacePath,
|
workspacePath: harness.workspacePath,
|
||||||
|
workspaceMode: "exclusive",
|
||||||
workspaceBaseline: baseline,
|
workspaceBaseline: baseline,
|
||||||
};
|
};
|
||||||
const outcomes = reconcileWorkspaceArtifacts(harness.db, ctx);
|
const outcomes = reconcileWorkspaceArtifacts(harness.db, ctx);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { AppDatabase } from "../../src/main/database";
|
|||||||
import {
|
import {
|
||||||
captureBaselineIfRequired,
|
captureBaselineIfRequired,
|
||||||
projectToolExecutionToWorkProduct,
|
projectToolExecutionToWorkProduct,
|
||||||
|
reconcileWorkspaceArtifacts,
|
||||||
type ArtifactProjectorRunContext,
|
type ArtifactProjectorRunContext,
|
||||||
} from "../../src/main/agent-runtime/artifact-projector";
|
} from "../../src/main/agent-runtime/artifact-projector";
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ describe("artifact projector error taxonomy", () => {
|
|||||||
taskId: "task-artifact-error",
|
taskId: "task-artifact-error",
|
||||||
workspaceId: "workspace-artifact-error",
|
workspaceId: "workspace-artifact-error",
|
||||||
workspacePath,
|
workspacePath,
|
||||||
|
workspaceMode: "exclusive",
|
||||||
logger,
|
logger,
|
||||||
workspaceBaseline: null,
|
workspaceBaseline: null,
|
||||||
};
|
};
|
||||||
@@ -83,4 +85,29 @@ describe("artifact projector error taxonomy", () => {
|
|||||||
);
|
);
|
||||||
expect(logger.error).not.toHaveBeenCalled();
|
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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -64,6 +64,7 @@ function seedCofounderRun(db: AppDatabase) {
|
|||||||
taskSessionId: "session-1",
|
taskSessionId: "session-1",
|
||||||
workspaceId: null,
|
workspaceId: null,
|
||||||
workspacePath: null,
|
workspacePath: null,
|
||||||
|
workspaceMode: "none",
|
||||||
workspaceBaseline: null,
|
workspaceBaseline: null,
|
||||||
state: "agent_active",
|
state: "agent_active",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,14 +22,13 @@ import {
|
|||||||
/**
|
/**
|
||||||
* Workspace lock policy (v52+).
|
* Workspace lock policy (v52+).
|
||||||
*
|
*
|
||||||
* `lockMode='exclusive'` is opt-in: the task carries an explicit
|
* `lockMode='exclusive'` is opt-in: the task's requiresFilesystem
|
||||||
* workspaceId or the task's requiresFilesystem column is true.
|
* column is true. A workspaceId can still provide the observation
|
||||||
* Every other case returns `lockMode='none'` and `workspace=null`,
|
* root for artifact projection, but it does not imply a write lock.
|
||||||
* so four pure-cognitive agents run fully in parallel without
|
* Pure-cognitive agents run fully in parallel without queueing on a
|
||||||
* queueing on a single workspace lock. The previous
|
* single workspace lock. The previous `company_fallback` step has
|
||||||
* `company_fallback` step has been removed and now throws
|
* been removed and now throws `WorkspaceConfigurationError` for
|
||||||
* `WorkspaceConfigurationError` for filesystem tasks with no
|
* filesystem tasks with no resolvable workspace.
|
||||||
* resolvable workspace.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function seedCompany(db: AppDatabase) {
|
function seedCompany(db: AppDatabase) {
|
||||||
@@ -70,11 +69,33 @@ describe("resolveWorkspaceWithLockMode", () => {
|
|||||||
await rm(tempDir, { recursive: true, force: true });
|
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 { companyId, workspaceId } = seedCompany(db);
|
||||||
const result = resolveWorkspaceWithLockMode(
|
const result = resolveWorkspaceWithLockMode(
|
||||||
db,
|
db,
|
||||||
{ workspaceId, projectId: null },
|
{ workspaceId, projectId: null, requiresFilesystem: true },
|
||||||
{ workspaceId: null, role: "CMO", department: "marketing" },
|
{ workspaceId: null, role: "CMO", department: "marketing" },
|
||||||
companyId,
|
companyId,
|
||||||
);
|
);
|
||||||
@@ -194,7 +215,7 @@ describe("resolveWorkspaceWithLockMode", () => {
|
|||||||
expect(result.workspace).toBeNull();
|
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 { companyId, workspaceId: agentWsId } = seedCompany(db);
|
||||||
const taskWsId = db.core.workspaces.saveWorkspace({
|
const taskWsId = db.core.workspaces.saveWorkspace({
|
||||||
companyId,
|
companyId,
|
||||||
@@ -206,7 +227,7 @@ describe("resolveWorkspaceWithLockMode", () => {
|
|||||||
});
|
});
|
||||||
const result = resolveWorkspaceWithLockMode(
|
const result = resolveWorkspaceWithLockMode(
|
||||||
db,
|
db,
|
||||||
{ workspaceId: taskWsId, projectId: null },
|
{ workspaceId: taskWsId, projectId: null, requiresFilesystem: true },
|
||||||
{ workspaceId: agentWsId, role: "CTO", department: "engineering" },
|
{ workspaceId: agentWsId, role: "CTO", department: "engineering" },
|
||||||
companyId,
|
companyId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user