This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 19:40:20 -04:00
1 parent 2ab001b18a
commit 810e2d5967
28 files changed
+1015 -85

No files matched your search

+10 -10
View File
@@ -16,7 +16,7 @@ export type PlatformDownload = {
assets: PlatformDownloadAsset[];
};
export const releaseVersion = "0.4.180";
export const releaseVersion = "0.4.182";
export const platformDownloads: PlatformDownload[] = [
{
@@ -29,14 +29,14 @@ export const platformDownloads: PlatformDownload[] = [
architectures: ["arm64"],
id: "macos-arm64-dmg",
label: "Apple Silicon DMG",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180-arm64.dmg",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182-arm64.dmg",
note: "arm64",
},
{
architectures: ["x64"],
id: "macos-x64-dmg",
label: "Intel Mac DMG",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180.dmg",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182.dmg",
note: "x64",
},
],
@@ -51,7 +51,7 @@ export const platformDownloads: PlatformDownload[] = [
architectures: ["arm64", "x64"],
id: "windows-universal-installer",
label: "Windows Universal Installer",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.180.exe",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany.Setup.0.4.182.exe",
note: "arm64 + x64",
},
],
@@ -66,42 +66,42 @@ export const platformDownloads: PlatformDownload[] = [
architectures: ["x64"],
id: "linux-x64-appimage",
label: "Linux x64 AppImage",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180.AppImage",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182.AppImage",
note: "x64",
},
{
architectures: ["x64"],
id: "linux-x64-deb",
label: "Linux x64 DEB",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.180_amd64.deb",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.182_amd64.deb",
note: "x64",
},
{
architectures: ["x64"],
id: "linux-x64-rpm",
label: "Linux x64 RPM",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.180.x86_64.rpm",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.182.x86_64.rpm",
note: "x64",
},
{
architectures: ["arm64"],
id: "linux-arm64-appimage",
label: "Linux ARM64 AppImage",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.180-arm64.AppImage",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/AgentCompany-0.4.182-arm64.AppImage",
note: "arm64",
},
{
architectures: ["arm64"],
id: "linux-arm64-deb",
label: "Linux ARM64 DEB",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.180_arm64.deb",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company_0.4.182_arm64.deb",
note: "arm64",
},
{
architectures: ["arm64"],
id: "linux-arm64-rpm",
label: "Linux ARM64 RPM",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.180.aarch64.rpm",
href: "https://pub-a19a3a2c1d2e4fa3a9c120b184024448.r2.dev/AC/agent-company-0.4.182.aarch64.rpm",
note: "arm64",
},
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-company",
"version": "0.4.180",
"version": "0.4.182",
"private": true,
"type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
+49 -4
View File
@@ -121,6 +121,34 @@ export class RuntimeBridge {
this.getOrCreateStore(context.taskSessionId);
}
private evictActiveRun(runId: string): ActiveRunContext | null {
const ctx = this.activeRuns.get(runId) ?? null;
this.activeRuns.delete(runId);
this.inboundCorrelations.deleteRun(runId);
if (ctx) this.sessionStores.delete(ctx.taskSessionId);
return ctx;
}
private assertRunContextCurrent(
ctx: ActiveRunContext,
operation: string,
): { ok: true } | { ok: false; error: string } {
const runState = this.deps.db.core.runs.getRunAuthState(ctx.runId);
if (!runState) {
this.evictActiveRun(ctx.runId);
const error = `${operation}: stale run ${ctx.runId} no longer exists.`;
this.deps.logger.warn(`[runtime-bridge] ${error}`);
return { ok: false, error };
}
if (runState.companyId !== ctx.companyId || runState.agentId !== ctx.agentId) {
this.evictActiveRun(ctx.runId);
const error = `${operation}: stale run ${ctx.runId} context does not match the persisted run.`;
this.deps.logger.warn(`[runtime-bridge] ${error}`);
return { ok: false, error };
}
return { ok: true };
}
/**
* Evicts the per-run `activeRuns` context, the per-session store cache,
* and the inbound-correlation log. Callers must invoke after a
@@ -136,12 +164,14 @@ export class RuntimeBridge {
const ctx = this.activeRuns.get(runId);
let result: RunResourceReleaseResult = { ok: true };
try {
if (ctx) result = this.reconcileArtifactsForRun(ctx);
if (ctx) {
const current = this.assertRunContextCurrent(ctx, "releaseRunResources");
if (!current.ok) return { ok: true };
result = this.reconcileArtifactsForRun(ctx);
}
return result;
} finally {
this.activeRuns.delete(runId);
this.inboundCorrelations.deleteRun(runId);
if (ctx) this.sessionStores.delete(ctx.taskSessionId);
this.evictActiveRun(runId);
}
}
@@ -256,6 +286,10 @@ export class RuntimeBridge {
if (envelope.event.type === "message_end") {
const ctx = this.activeRuns.get(envelope.runId);
if (ctx) {
const current = this.assertRunContextCurrent(ctx, "agent-event");
if (!current.ok) return;
}
const messageRaw = envelope.event.message as
| {
role?: string;
@@ -330,6 +364,8 @@ export class RuntimeBridge {
if (!ctx) {
return { ok: false, error: `invokeInternalTool: no active run ${runId}` };
}
const current = this.assertRunContextCurrent(ctx, "invokeInternalTool");
if (!current.ok) return { ok: false, error: current.error };
const callbacks = this.internalToolCallbacks;
if (!callbacks) {
return {
@@ -374,6 +410,13 @@ export class RuntimeBridge {
workspacePath: string | null;
workspaceMode: "exclusive" | "shared" | "none";
};
const runState = this.deps.db.core.runs.getRunAuthState(runId);
if (!runState) {
return { ok: false, error: `prepareRun: stale run ${runId} no longer exists.` };
}
if (runState.companyId !== payload.companyId || runState.agentId !== payload.agentId) {
return { ok: false, error: `prepareRun: run ${runId} context does not match the persisted run.` };
}
const baseline = captureBaselineIfRequired(payload.workspacePath, this.deps.logger);
this.registerActiveRun({
runId,
@@ -454,6 +497,8 @@ export class RuntimeBridge {
if (!ctx) {
return { ok: false, error: `beforeToolCall: no active run ${runId}` };
}
const current = this.assertRunContextCurrent(ctx, "beforeToolCall");
if (!current.ok) return { ok: false, error: current.error };
const result = handleBeforeToolCall(this.deps, ctx, payload);
return { ok: true, result };
}
+32 -3
View File
@@ -5,6 +5,7 @@ import { IMPORTANT_DOC_TYPES } from "@shared/document-categories";
import { canAssignTask, canUpdateTask } from "../agent-permissions";
import { inferTaskType } from "../connector-matching";
import { validateTaskCreation } from "../task-quality-gates";
import { resolveTaskWorkspaceBinding } from "../task-workspace-binding";
import { requireParam, type AgentApiServer } from "./server-core";
const taskUpdateSchema = z.object({
@@ -146,6 +147,21 @@ export function registerTaskRoutes(server: AgentApiServer) {
reviewRequest = { reviewerAgentName: actorAgent.name, documents: docs };
}
const nextRequiresFilesystem = update.requiresFilesystem ?? task.requiresFilesystem;
const workspaceBinding = resolveTaskWorkspaceBinding(db, {
companyId: task.companyId,
requiresFilesystem: nextRequiresFilesystem,
explicitWorkspaceId: task.workspaceId,
projectId: task.projectId,
assigneeAgentId: newAssignee,
creatorAgentId: token.agentId,
currentRunId: token.runId ?? null,
});
if (!workspaceBinding.ok) {
server.json(res, 422, { error: workspaceBinding.error });
return;
}
const updateResult = db.core.tasks.updateTaskWithReviewSideEffects({
taskId: task.id,
companyId: task.companyId,
@@ -160,8 +176,8 @@ export function registerTaskRoutes(server: AgentApiServer) {
goalId: task.goalId,
parentId: task.parentId,
assigneeAgentId: newAssignee,
workspaceId: task.workspaceId,
requiresFilesystem: update.requiresFilesystem ?? task.requiresFilesystem,
workspaceId: workspaceBinding.workspaceId,
requiresFilesystem: nextRequiresFilesystem,
},
comment: update.comment,
reviewRequest,
@@ -297,6 +313,19 @@ export function registerTaskRoutes(server: AgentApiServer) {
let id: string;
try {
const workspaceBinding = resolveTaskWorkspaceBinding(db, {
companyId,
requiresFilesystem: input.requiresFilesystem,
explicitWorkspaceId: input.workspaceId ?? null,
projectId: input.projectId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
creatorAgentId: token.agentId,
currentRunId: token.runId ?? null,
});
if (!workspaceBinding.ok) {
server.json(res, 422, { error: workspaceBinding.error });
return;
}
id = db.core.tasks.saveTask({
companyId,
title: input.title,
@@ -307,7 +336,7 @@ export function registerTaskRoutes(server: AgentApiServer) {
goalId: input.goalId ?? null,
priority: input.priority,
status: input.status,
workspaceId: input.workspaceId ?? null,
workspaceId: workspaceBinding.workspaceId,
requiresFilesystem: input.requiresFilesystem,
});
} catch (error) {
@@ -1,6 +1,7 @@
import type { AutomationAction } from "@shared/types";
import { dispatchAgentMessage } from "../messaging/dispatch";
import { validateTaskCreation } from "../task-quality-gates";
import { resolveTaskWorkspaceBinding } from "../task-workspace-binding";
import {
createAutomationActionHandler,
type AutomationActionHandler,
@@ -106,12 +107,26 @@ function handleCreateTask({ deps, callbacks, ctx, config }: TypedAutomationActio
return;
}
const workspaceBinding = resolveTaskWorkspaceBinding(db, {
companyId: ctx.companyId,
requiresFilesystem: config.requiresFilesystem,
explicitWorkspaceId: config.workspaceId ?? null,
projectId: ctx.projectId ?? config.projectId ?? null,
assigneeAgentId,
creatorAgentId: ctx.agentId ?? null,
});
if (!workspaceBinding.ok) {
logger.warn(`[automation] Skipping create_task - ${workspaceBinding.error}`);
return;
}
const taskId = db.core.tasks.saveTask({
companyId: ctx.companyId,
projectId: ctx.projectId ?? config.projectId ?? null,
title,
description,
assigneeAgentId,
workspaceId: workspaceBinding.workspaceId,
priority,
status: assigneeAgentId ? "todo" : "backlog",
requiresFilesystem: config.requiresFilesystem,
@@ -24,6 +24,7 @@ export const createTaskConfigSchema = z.object({
priority: taskPrioritySchema.optional(),
assigneeAgentId: z.string().nullable().optional(),
projectId: z.string().optional(),
workspaceId: z.string().nullable().optional(),
requiresFilesystem: z.boolean().default(false),
});
@@ -0,0 +1,130 @@
import type { DatabaseSync } from "node:sqlite";
import { nowIso, tableExists } from "./types";
export const FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION = 60;
export const FILESYSTEM_TASK_WORKSPACES_SCHEMA_NAME = "filesystem_task_workspaces";
const ACTIVE_TASK_STATUSES = "'backlog', 'todo', 'in_progress', 'blocked'";
export function migrateFilesystemTaskWorkspacesV60(db: DatabaseSync): void {
backfillProjectWorkspaces(db);
backfillAssigneeWorkspaces(db);
backfillSingleCompanyWorkspaces(db);
db
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
.run(FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION, FILESYSTEM_TASK_WORKSPACES_SCHEMA_NAME, nowIso());
assertFilesystemTaskWorkspacesV60(db);
}
export function assertFilesystemTaskWorkspacesV60(db: DatabaseSync): void {
if (!tableExists(db, "tasks") || !tableExists(db, "workspaces")) {
throw new Error("Schema v60 drift: tasks/workspaces tables are missing.");
}
const unresolved = db
.prepare(
`select count(*) as count
from tasks t
where t.requires_filesystem = 1
and t.workspace_id is null
and t.status in (${ACTIVE_TASK_STATUSES})
and (
select count(*)
from workspaces w
where w.company_id = t.company_id
) = 1`,
)
.get() as { count: number } | undefined;
if ((unresolved?.count ?? 0) > 0) {
throw new Error("Schema v60 drift: single-workspace companies still have unresolved filesystem tasks.");
}
}
function backfillProjectWorkspaces(db: DatabaseSync): void {
if (!hasRequiredTables(db)) return;
db
.prepare(
`update tasks
set workspace_id = (
select w.id
from workspaces w
where w.company_id = tasks.company_id
and w.project_id = tasks.project_id
order by w.is_primary desc, w.created_at asc
limit 1
),
updated_at = ?
where requires_filesystem = 1
and workspace_id is null
and status in (${ACTIVE_TASK_STATUSES})
and project_id is not null
and exists (
select 1
from workspaces w
where w.company_id = tasks.company_id
and w.project_id = tasks.project_id
)`,
)
.run(nowIso());
}
function backfillAssigneeWorkspaces(db: DatabaseSync): void {
if (!hasRequiredTables(db) || !tableExists(db, "agents")) return;
db
.prepare(
`update tasks
set workspace_id = (
select a.workspace_id
from agents a
join workspaces w
on w.id = a.workspace_id
and w.company_id = a.company_id
where a.id = tasks.assignee_agent_id
and a.company_id = tasks.company_id
limit 1
),
updated_at = ?
where requires_filesystem = 1
and workspace_id is null
and status in (${ACTIVE_TASK_STATUSES})
and assignee_agent_id is not null
and exists (
select 1
from agents a
join workspaces w
on w.id = a.workspace_id
and w.company_id = a.company_id
where a.id = tasks.assignee_agent_id
and a.company_id = tasks.company_id
)`,
)
.run(nowIso());
}
function backfillSingleCompanyWorkspaces(db: DatabaseSync): void {
if (!hasRequiredTables(db)) return;
db
.prepare(
`update tasks
set workspace_id = (
select w.id
from workspaces w
where w.company_id = tasks.company_id
order by w.is_primary desc, w.created_at asc
limit 1
),
updated_at = ?
where requires_filesystem = 1
and workspace_id is null
and status in (${ACTIVE_TASK_STATUSES})
and (
select count(*)
from workspaces w
where w.company_id = tasks.company_id
) = 1`,
)
.run(nowIso());
}
function hasRequiredTables(db: DatabaseSync): boolean {
return tableExists(db, "tasks") && tableExists(db, "workspaces");
}
+21 -6
View File
@@ -92,12 +92,27 @@ export function buildModelCapabilityMethods(
*/
function seedModelCapabilities(rows: Array<Omit<ModelCapability, "updatedAt">>): number {
return transaction(() => {
const existing = readAll<{ cnt: number }>(
"SELECT COUNT(*) AS cnt FROM model_capabilities",
)[0]?.cnt ?? 0;
if (existing > 0) return 0;
for (const row of rows) upsertModelCapability(row);
return rows.length;
let inserted = 0;
const ts = nowIso();
const stmt = getDb().prepare(
`INSERT OR IGNORE INTO model_capabilities (
provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const row of rows) {
const result = stmt.run(
row.provider,
row.modelId,
row.reasoning,
row.coding,
row.writing,
row.tier,
row.costPerMtokOut,
ts,
);
inserted += Number(result.changes ?? 0);
}
return inserted;
});
}
+23 -1
View File
@@ -24,12 +24,17 @@ import {
assertSocialCompanyScopeV59,
migrateSocialCompanyScopeV59,
} from "./migrations/v59-social-company-scope";
import {
FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION,
assertFilesystemTaskWorkspacesV60,
migrateFilesystemTaskWorkspacesV60,
} from "./migrations/v60-filesystem-task-workspaces";
import {
type ReadAll,
type Transaction,
} from "./migrations/types";
const LATEST_SCHEMA_VERSION = SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION;
const LATEST_SCHEMA_VERSION = FILESYSTEM_TASK_WORKSPACES_SCHEMA_VERSION;
export function initializeDatabase(
db: DatabaseSync,
@@ -50,6 +55,18 @@ export function initializeDatabase(
assertTaskLeasesV57(db);
assertApprovalPoliciesV58(db);
assertSocialCompanyScopeV59(db);
assertFilesystemTaskWorkspacesV60(db);
});
return;
}
if (currentVersion === SOCIAL_COMPANY_SCOPE_SCHEMA_VERSION) {
tx(() => {
seedSettings(db);
assertTaskLeasesV57(db);
assertApprovalPoliciesV58(db);
assertSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
});
return;
}
@@ -60,6 +77,7 @@ export function initializeDatabase(
assertTaskLeasesV57(db);
assertApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
});
return;
}
@@ -70,6 +88,7 @@ export function initializeDatabase(
assertTaskLeasesV57(db);
migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
});
return;
}
@@ -83,6 +102,7 @@ export function initializeDatabase(
migrateTaskLeasesV57(db);
migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
});
return;
}
@@ -97,6 +117,7 @@ export function initializeDatabase(
migrateTaskLeasesV57(db);
migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
});
return;
}
@@ -109,6 +130,7 @@ export function initializeDatabase(
migrateTaskLeasesV57(db);
migrateApprovalPoliciesV58(db);
migrateSocialCompanyScopeV59(db);
migrateFilesystemTaskWorkspacesV60(db);
});
}
+18 -1
View File
@@ -7,6 +7,7 @@ import {
} from "@shared/contracts";
import { fail, ok, type RegisterCoreHandlersDependencies } from "./ipc-core-common";
import { validateTaskCreation, checkCapabilityMatch } from "./task-quality-gates";
import { resolveTaskWorkspaceBinding } from "./task-workspace-binding";
export function registerCoreTaskGraphHandlers({
registerHandle,
@@ -109,6 +110,7 @@ export function registerCoreTaskGraphHandlers({
}
let previousStatus: string | null = null;
let previousAssigneeAgentId: string | null = null;
let existingWorkspaceId: string | null = null;
if (!isNew) {
const taskId = parsed.id;
if (!taskId) return fail("INVALID_TASK_STATE", "Task ID required for updates.");
@@ -116,15 +118,30 @@ export function registerCoreTaskGraphHandlers({
const existingTask = db.core.entity.getTask(taskId);
previousStatus = existingTask.status;
previousAssigneeAgentId = existingTask.assigneeAgentId;
existingWorkspaceId = existingTask.workspaceId;
} catch (err) {
logger.warn(`[ipc/task-graph] getTask failed for ${taskId}; treating as new task:`, err instanceof Error ? err.message : String(err));
previousStatus = null;
previousAssigneeAgentId = null;
existingWorkspaceId = null;
}
}
let id: string;
try {
id = db.core.tasks.saveTask(parsed);
const workspaceBinding = resolveTaskWorkspaceBinding(db, {
companyId: parsed.companyId,
requiresFilesystem: parsed.requiresFilesystem,
explicitWorkspaceId: parsed.workspaceId ?? (parsed.requiresFilesystem ? existingWorkspaceId : null),
projectId: parsed.projectId ?? null,
assigneeAgentId: parsed.assigneeAgentId ?? null,
});
if (!workspaceBinding.ok) {
return fail("INVALID_TASK_STATE", workspaceBinding.error);
}
id = db.core.tasks.saveTask({
...parsed,
workspaceId: workspaceBinding.workspaceId,
});
} catch (error) {
return fail("INVALID_TASK_STATE", error instanceof Error ? error.message : "Task could not be saved.");
}
+175 -44
View File
@@ -1,31 +1,14 @@
import type { ModelCapability } from "@shared/types";
import { CONNECTORS } from "./connector-definitions";
import { resolveModel, resolveModelCostFallback, resolveModels } from "./model-resolver";
/**
* Initial capability matrix written into the DB on first boot when the
* table is empty. The user's five-axis ranking from the dispatch design
* discussion is compressed to three axes:
*
* reasoning = architecture/review ∧ math ∧ research
* coding = coding (unchanged)
* writing = language/copy (unchanged — the other two collapsed upward)
*
* Scores are 1..5 on each axis. Tier is the hard lower bound the dispatcher
* clears before considering `costPerMtokOut` — a tier-3 (`hard`) task never
* runs on a tier-1 (`trivial`) model even if the trivial model's domain
* score happens to clear the numeric threshold.
*
* Cost-per-Mtok-output figures are the public list price at the provider's
* own API as of 2026-04 (approximate; the table is user-editable after
* seeding so prices can be corrected in-app without a code change).
*
* Adding a new model: append a row here AND publish a migration or an IPC
* call that writes it through `upsertModelCapability` — the seed only runs
* when the table is empty, so it does not back-fill on upgrade.
*/
type ModelCapabilitySeedRow = Omit<ModelCapability, "updatedAt">;
type CapabilityProfile = Pick<ModelCapabilitySeedRow, "reasoning" | "coding" | "writing" | "tier">;
type CatalogModel = { id: string; name: string };
export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> = [
// ── Anthropic (Claude) ────────────────────────────────────────────────────
// Claude Opus 4.7: strong across the board, top coding, top writing.
const DEFAULT_COST_PER_MTOK_OUT = 1;
const CURATED_MODEL_CAPABILITY_SEED: ModelCapabilitySeedRow[] = [
{
provider: "anthropic",
modelId: "claude-opus-4-7",
@@ -50,17 +33,9 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
reasoning: 3,
coding: 4,
writing: 4,
// Haiku sits at the standard tier. Its scores already clear the
// standard domain floor (>=3), so marking the tier any lower would
// exclude it from standard tasks in the dispatcher's tier-rank guard.
// The cost advantage (~5x cheaper than Sonnet) is the whole point.
tier: "standard",
costPerMtokOut: 5,
},
// ── OpenAI (GPT) ──────────────────────────────────────────────────────────
// GPT family leads reasoning/architecture/math/research; coding trails
// Claude; writing trails Claude and Kimi.
{
provider: "openai",
modelId: "gpt-5",
@@ -85,15 +60,9 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
reasoning: 3,
coding: 3,
writing: 3,
// Same reasoning as Haiku: the model clears the standard domain
// floor, so it should live at the standard tier. Llama-70B below is
// the only genuinely trivial-tier seed row.
tier: "standard",
costPerMtokOut: 2,
},
// ── Fireworks curated (open-weight) ───────────────────────────────────────
// Kimi K2: strong writing and coding, near-Claude. Reasoning solid.
{
provider: "fireworks",
modelId: "accounts/fireworks/models/kimi-k2-instruct",
@@ -103,7 +72,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard",
costPerMtokOut: 3,
},
// Kimi K2.6 Turbo via Fire Pass router — newer, faster, similar quality.
{
provider: "fireworks-firepass",
modelId: "accounts/fireworks/routers/kimi-k2p6-turbo",
@@ -113,7 +81,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard",
costPerMtokOut: 3,
},
// GLM 4.5: mid-tier across reasoning and coding, decent writing.
{
provider: "fireworks",
modelId: "accounts/fireworks/models/glm-4p5",
@@ -123,7 +90,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard",
costPerMtokOut: 2,
},
// Qwen3 Coder 480B: coding specialist.
{
provider: "fireworks",
modelId: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
@@ -133,7 +99,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard",
costPerMtokOut: 3,
},
// DeepSeek V3.1: strong reasoning, capable coder, writing ok.
{
provider: "fireworks",
modelId: "accounts/fireworks/models/deepseek-v3p1",
@@ -143,7 +108,6 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
tier: "standard",
costPerMtokOut: 2,
},
// Llama 3.3 70B: generalist trivial-tier fallback.
{
provider: "fireworks",
modelId: "accounts/fireworks/models/llama-v3p3-70b-instruct",
@@ -154,3 +118,170 @@ export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> =
costPerMtokOut: 1,
},
];
const CURATED_MODEL_CAPABILITY_BY_KEY = new Map(
CURATED_MODEL_CAPABILITY_SEED.map((row) => [capabilityKey(row.provider, row.modelId), row]),
);
function capabilityKey(provider: string, modelId: string): string {
return `${provider}::${modelId}`;
}
function profile(reasoning: number, coding: number, writing: number, tier: CapabilityProfile["tier"]): CapabilityProfile {
return { reasoning, coding, writing, tier };
}
function searchableText(provider: string, model: CatalogModel): string {
return `${provider} ${model.id} ${model.name}`.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
}
function hasAny(text: string, terms: string[]): boolean {
return terms.some((term) => text.includes(term));
}
function hasWord(text: string, word: string): boolean {
return new RegExp(`\\b${word}\\b`).test(text);
}
function selectCapabilityProfile(text: string): CapabilityProfile {
if (text.includes("claude") && text.includes("opus")) return profile(5, 5, 5, "hard");
if (text.includes("claude") && text.includes("sonnet")) return profile(4, 5, 5, "standard");
if (text.includes("claude") && text.includes("haiku")) return profile(3, 4, 4, "standard");
if (text.includes("gpt 5") && text.includes("codex")) return profile(5, 5, 3, "hard");
if (hasAny(text, ["gpt 5", "gpt 4", "gpt 4o", "o3", "o4"])) return profile(5, 4, 3, "hard");
if (text.includes("gpt oss 120b")) return profile(4, 4, 3, "standard");
if (text.includes("gpt oss")) return profile(3, 3, 3, "standard");
if (text.includes("gemini") && hasAny(text, ["pro", "ultra"])) return profile(5, 4, 4, "hard");
if (text.includes("gemini")) return profile(3, 3, 3, "standard");
if (text.includes("grok 4") || text.includes("grok 3")) return profile(5, 4, 4, "hard");
if (text.includes("grok")) return profile(4, 3, 4, "standard");
if (hasAny(text, ["glm 5", "zai glm 5"])) return profile(5, 4, 4, "hard");
if (hasAny(text, ["glm 4 7", "glm 4 6", "zai glm 4 7"])) return profile(4, 4, 4, "standard");
if (hasAny(text, ["glm 4 5", "glm 4p5"])) return profile(3, 3, 4, "standard");
if (text.includes("kimi") && text.includes("thinking")) return profile(5, 4, 5, "hard");
if (text.includes("kimi")) return profile(4, 4, 5, "standard");
if (text.includes("deepseek") && hasAny(text, ["r1", "reasoner"])) return profile(5, 4, 3, "hard");
if (hasAny(text, ["deepseek", "deepresearch", "qwq", "thinking"])) return profile(4, 4, 3, "standard");
if (text.includes("coder") || text.includes("codestral") || text.includes("devstral")) return profile(3, 5, 3, "standard");
if (text.includes("qwen")) return profile(4, 4, 3, "standard");
if (text.includes("minimax") || text.includes("mimo")) return profile(3, 4, 4, "standard");
if (hasAny(text, ["mistral large", "mistral medium", "magistral"])) return profile(4, 4, 4, "standard");
if (text.includes("mistral") || text.includes("ministral")) return profile(3, 3, 3, "standard");
if (text.includes("nova premier") || text.includes("nova pro")) return profile(4, 3, 3, "standard");
if (text.includes("nova")) return profile(2, 2, 2, "trivial");
if (text.includes("llama 4") || text.includes("llama 70b") || text.includes("llama v3p3 70b")) {
return profile(3, 3, 3, "standard");
}
if (text.includes("llama")) return profile(2, 2, 2, "trivial");
return profile(3, 3, 3, "standard");
}
function applySizeAdjustment(selected: CapabilityProfile, text: string): CapabilityProfile {
if (hasWord(text, "nano") || hasWord(text, "micro") || hasWord(text, "3b") || hasWord(text, "7b") || hasWord(text, "8b")) {
return profile(2, 2, 2, "trivial");
}
if (
hasWord(text, "mini") ||
hasWord(text, "lite") ||
hasWord(text, "flash") ||
hasWord(text, "flashx") ||
hasWord(text, "air") ||
hasWord(text, "small") ||
hasWord(text, "instant") ||
text.includes("compound mini")
) {
return profile(
Math.min(selected.reasoning, 3),
Math.min(selected.coding, 3),
Math.min(selected.writing, 3),
selected.tier === "trivial" ? "trivial" : "standard",
);
}
return selected;
}
function inferCapabilityProfile(provider: string, model: CatalogModel): CapabilityProfile {
const text = searchableText(provider, model);
return applySizeAdjustment(selectCapabilityProfile(text), text);
}
function normalizeCost(value: number): number {
if (!Number.isFinite(value) || value < 0) return DEFAULT_COST_PER_MTOK_OUT;
return Number(value.toFixed(4));
}
function resolveCatalogCostPerMtokOut(provider: string, modelId: string): number {
const resolved = resolveModel(provider, modelId);
if (!resolved) return DEFAULT_COST_PER_MTOK_OUT;
const priced = resolveModelCostFallback(resolved, provider);
return normalizeCost(priced.cost.output);
}
export function deriveModelCapabilitySeed(provider: string, model: CatalogModel): ModelCapabilitySeedRow {
const curated = CURATED_MODEL_CAPABILITY_BY_KEY.get(capabilityKey(provider, model.id));
const selectedProfile = curated ?? inferCapabilityProfile(provider, model);
return {
provider,
modelId: model.id,
reasoning: selectedProfile.reasoning,
coding: selectedProfile.coding,
writing: selectedProfile.writing,
tier: selectedProfile.tier,
costPerMtokOut: resolveCatalogCostPerMtokOut(provider, model.id),
};
}
function listStaticCatalogModels(): Array<{ provider: string; model: CatalogModel }> {
const modelsByKey = new Map<string, { provider: string; model: CatalogModel }>();
for (const connector of CONNECTORS) {
for (const model of resolveModels(connector.provider)) {
modelsByKey.set(capabilityKey(connector.provider, model.id), {
provider: connector.provider,
model: { id: model.id, name: model.name },
});
}
if (connector.auth.type !== "local" && connector.defaultModel.trim()) {
modelsByKey.set(capabilityKey(connector.provider, connector.defaultModel), {
provider: connector.provider,
model: { id: connector.defaultModel, name: connector.defaultModel },
});
}
}
return Array.from(modelsByKey.values());
}
export function buildModelCapabilitySeed(): ModelCapabilitySeedRow[] {
const rowsByKey = new Map<string, ModelCapabilitySeedRow>();
for (const row of CURATED_MODEL_CAPABILITY_SEED) {
rowsByKey.set(capabilityKey(row.provider, row.modelId), row);
}
for (const entry of listStaticCatalogModels()) {
rowsByKey.set(capabilityKey(entry.provider, entry.model.id), deriveModelCapabilitySeed(entry.provider, entry.model));
}
return Array.from(rowsByKey.values()).sort((a, b) => {
const providerOrder = a.provider.localeCompare(b.provider);
if (providerOrder !== 0) return providerOrder;
return a.modelId.localeCompare(b.modelId);
});
}
export const MODEL_CAPABILITY_SEED: ModelCapabilitySeedRow[] = buildModelCapabilitySeed();
+17
View File
@@ -127,6 +127,22 @@ export function createOnboardingService({
});
}
function bindWorkspaceToProject(workspaceId: string | null, projectId: string) {
if (!workspaceId) return;
const workspace = db.core.workspaces.getWorkspace(workspaceId);
if (!workspace || workspace.projectId === projectId) return;
db.core.workspaces.saveWorkspace({
id: workspace.id,
companyId: workspace.companyId,
name: workspace.name,
localPath: workspace.localPath,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
projectId,
isPrimary: workspace.isPrimary,
});
}
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): InitialExecutionLoopResult {
const companyRows = db.queryAll<{ id: string; name: string; description: string }>(
"select id, name, description from companies where id = ? limit 1",
@@ -171,6 +187,7 @@ export function createOnboardingService({
targetDate: null,
status: "in_progress",
});
bindWorkspaceToProject(workspaceId, projectId);
const kickoffLocale = getLocale();
const kickoffTaskTitle = activeGoal
+1 -1
View File
@@ -147,7 +147,7 @@ export function createHeartbeat(
}
const pick = pickModelForAgent(agent, {
getConnector: (id) => db.core.connectors.getConnector(id),
getDisabledModels: (id) => db.core.settings.getDisabledModels(agent.companyId, id),
getDisabledModels: (companyId, connectorId) => db.core.settings.getDisabledModels(companyId, connectorId),
listModelCapabilities: () => db.core.modelCapabilities.listModelCapabilities(),
readConnectors: () => db.core.connectors.readConnectors(),
});
+1 -1
View File
@@ -59,7 +59,7 @@ export function createTaskRunner(
}
const pick = pickModelForAgent(agent, {
getConnector: (id) => db.core.connectors.getConnector(id),
getDisabledModels: (id) => db.core.settings.getDisabledModels(agent.companyId, id),
getDisabledModels: (companyId, connectorId) => db.core.settings.getDisabledModels(companyId, connectorId),
listModelCapabilities: () => db.core.modelCapabilities.listModelCapabilities(),
readConnectors: () => db.core.connectors.readConnectors(),
});
+1 -1
View File
@@ -351,7 +351,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
})),
companyWorkspaces: snapshot.workspaces
.filter((workspace) => workspace.companyId === companyId)
.map((workspace) => ({ name: workspace.name, localPath: workspace.localPath })),
.map((workspace) => ({ id: workspace.id, name: workspace.name, localPath: workspace.localPath })),
companyDocuments: snapshot.documents.filter((d) => d.companyId === companyId),
}) : null;
@@ -16,11 +16,13 @@ Body: {
"parentId": "{parentIssueId — for subtasks}",
"projectId": "{projectId — for traceability}",
"goalId": "{goalId — for strategic alignment}",
"workspaceId": "{workspaceId — required for filesystem work when the task cannot inherit one}",
"requiresFilesystem": true,
"priority": "critical|high|medium|low",
"status": "todo"
}
Always set parentId when creating subtasks. Always set projectId and goalId when available.
Set workspaceId for filesystem tasks when a workspace id is listed in the Company Capability Registry.
Set requiresFilesystem=true for code, file, repository, build, test, or local workspace work.
Set requiresFilesystem=false only for pure planning, research, coordination, or review tasks that use API state and documents.
Example: Create a task assigned to a report:
@@ -15,7 +15,7 @@ export function buildCapabilityRegistrySection(ctx: Pick<PromptContext,
if (ctx.companyWorkspaces && ctx.companyWorkspaces.length > 0) {
const workspaces = ctx.companyWorkspaces
.slice(0, 6)
.map((workspace) => `- ${workspace.name}: ${workspace.localPath}`);
.map((workspace) => `- ${workspace.name} (\`${workspace.id}\`): ${workspace.localPath}`);
parts.push(`## Workspaces\n${workspaces.join("\n")}`);
}
+1 -1
View File
@@ -44,7 +44,7 @@ export interface PromptContext {
/** Company-wide skill catalog for capability-aware delegation. */
companySkillCatalog?: Array<{ name: string; slug: string; description: string | null }>;
/** Company workspaces visible to the current run. */
companyWorkspaces?: Array<{ name: string; localPath: string }>;
companyWorkspaces?: Array<{ id: string; name: string; localPath: string }>;
}
export function buildTaskPrompt(ctx: PromptContext): string {
+103
View File
@@ -0,0 +1,103 @@
import type { AppDatabase } from "./database";
type WorkspaceBindingSource =
| "explicit"
| "project"
| "assignee"
| "run"
| "creator"
| "single_company"
| "none";
export type TaskWorkspaceBindingResult =
| { ok: true; workspaceId: string | null; source: WorkspaceBindingSource }
| { ok: false; error: string };
export interface TaskWorkspaceBindingInput {
companyId: string;
requiresFilesystem: boolean;
explicitWorkspaceId?: string | null;
projectId?: string | null;
assigneeAgentId?: string | null;
creatorAgentId?: string | null;
currentRunId?: string | null;
}
export function resolveTaskWorkspaceBinding(
db: AppDatabase,
input: TaskWorkspaceBindingInput,
): TaskWorkspaceBindingResult {
if (input.explicitWorkspaceId) {
const explicit = getCompanyWorkspace(db, input.companyId, input.explicitWorkspaceId);
if (!explicit) {
return { ok: false, error: "Selected workspace does not belong to this company." };
}
return { ok: true, workspaceId: explicit.id, source: "explicit" };
}
if (!input.requiresFilesystem) {
return {
ok: true,
workspaceId: null,
source: "none",
};
}
const project = db.core.workspaces.getProjectPrimaryWorkspace(input.projectId ?? null, input.companyId);
if (project) return { ok: true, workspaceId: project.id, source: "project" };
const assignee = getAgentWorkspace(db, input.companyId, input.assigneeAgentId ?? null);
if (assignee) return { ok: true, workspaceId: assignee.id, source: "assignee" };
const run = getRunWorkspace(db, input.companyId, input.currentRunId ?? null);
if (run) return { ok: true, workspaceId: run.id, source: "run" };
const creator = getAgentWorkspace(db, input.companyId, input.creatorAgentId ?? null);
if (creator) return { ok: true, workspaceId: creator.id, source: "creator" };
const singleCompanyWorkspace = getSingleCompanyWorkspace(db, input.companyId);
if (singleCompanyWorkspace) {
return { ok: true, workspaceId: singleCompanyWorkspace.id, source: "single_company" };
}
return {
ok: false,
error: "Filesystem tasks require a workspace. Provide workspaceId, bind the project to a workspace, or bind the assignee/creator agent to a workspace.",
};
}
function getCompanyWorkspace(db: AppDatabase, companyId: string, workspaceId: string | null): { id: string } | null {
if (!workspaceId) return null;
const workspace = db.core.workspaces.getWorkspace(workspaceId);
return workspace && workspace.companyId === companyId ? workspace : null;
}
function getAgentWorkspace(db: AppDatabase, companyId: string, agentId: string | null): { id: string } | null {
if (!agentId) return null;
try {
const agent = db.core.entity.getAgent(agentId);
if (agent.companyId !== companyId) return null;
return getCompanyWorkspace(db, companyId, agent.workspaceId);
} catch {
return null;
}
}
function getRunWorkspace(db: AppDatabase, companyId: string, runId: string | null): { id: string } | null {
if (!runId) return null;
try {
const run = db.core.runs.getRun(runId);
if (run.companyId !== companyId) return null;
return getCompanyWorkspace(db, companyId, run.workspaceId);
} catch {
return null;
}
}
function getSingleCompanyWorkspace(db: AppDatabase, companyId: string): { id: string } | null {
const workspaces = db.projection.snapshot
.listSnapshot({ companyId })
.workspaces
.filter((workspace) => workspace.companyId === companyId);
return workspaces.length === 1 ? workspaces[0]! : null;
}
@@ -66,14 +66,15 @@ async function createHarness() {
workspaceId: null,
connectorId: "openai-codex",
});
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const bridge = new RuntimeBridge({
db,
emitEvent: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
logger,
credentialResolver: {
resolve: vi.fn(async () => ({ apiKey: "test-api-key", extraEnv: {} })),
} as unknown as CredentialResolver,
@@ -105,6 +106,7 @@ async function createHarness() {
agentId,
taskId,
runId: run.id,
logger,
};
}
@@ -143,4 +145,43 @@ describe("artifact projection durable ack", () => {
harness.db.close();
}
});
it("evicts deleted run contexts without writing artifact projection events", async () => {
const harness = await createHarness();
try {
harness.db.core.company.deleteCompany(harness.companyId);
const result = harness.bridge.releaseRunResources(harness.runId);
expect(result).toEqual({ ok: true });
} finally {
harness.db.close();
}
});
it("rejects destructive tool hooks from deleted run contexts before approval lookup", async () => {
const harness = await createHarness();
try {
harness.db.core.company.deleteCompany(harness.companyId);
const response = await harness.bridge.handleRpc({
rpcId: "before-tool-call",
method: "beforeToolCall",
runId: harness.runId,
payload: {
toolName: "bash",
toolCallId: "tool-1",
args: {},
},
});
expect(response).toEqual({
ok: false,
error: expect.stringContaining("stale run"),
});
expect(harness.logger.error).not.toHaveBeenCalled();
} finally {
harness.db.close();
}
});
});
@@ -6,6 +6,7 @@ import { AppDatabase } from "../../src/main/database";
import { createBudgetEnforcementService } from "../../src/main/budget-enforcement-service";
import { createRunDispatch } from "../../src/main/orchestration/run-dispatch";
import { createTaskRunner } from "../../src/main/orchestration/task-runner";
import { resolveModels } from "../../src/main/model-resolver";
const tempDirs: string[] = [];
@@ -64,6 +65,93 @@ function createOrchestrationHarness(db: AppDatabase) {
}
describe("connector model enforcement", () => {
it("auto picker excludes disabled connector models before run eligibility validation", async () => {
const profileDir = await createTempDir("agentcompany-auto-model-pool-");
const db = new AppDatabase(profileDir);
db.init();
db.core.connectors.updateConnectorHealth({
id: "openai-codex",
status: "ready",
authState: "authenticated",
version: "test",
lastError: null,
});
db.core.connectors.updateConnectorHealth({
id: "zai",
status: "ready",
authState: "authenticated",
version: "test",
lastError: null,
});
const companyId = db.core.company.saveCompany({
name: "Auto Model Pool Co",
description: "Routes around disabled connector model pools",
status: "active",
});
const workspaceId = db.core.workspaces.saveWorkspace({
companyId,
name: "Primary",
localPath: profileDir,
repoUrl: "",
repoRef: "",
isPrimary: true,
});
const ceoId = db.core.agents.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: "Chief Executive Officer",
status: "idle",
reportsTo: null,
connectorId: "zai",
workspaceId,
model: "glm-5.1",
capabilities: "Lead the company",
budgetMonthlyUsd: 250,
});
const architectId = db.core.agents.saveAgent({
companyId,
name: "Architect",
role: "Architect",
title: "Principal Architect",
status: "idle",
reportsTo: ceoId,
connectorId: "openai-codex",
workspaceId,
model: "gpt-5.4",
modelStrategy: "auto",
capabilities: "Plan technical architecture",
budgetMonthlyUsd: 250,
});
const taskId = db.core.tasks.saveTask({
companyId,
title: "Plan architecture",
description: "Needs a hard reasoning model.",
assigneeAgentId: architectId,
workspaceId,
priority: "high",
status: "todo",
});
db.core.settings.setDisabledModels(
companyId,
"openai-codex",
resolveModels("openai-codex").map((model) => ({ id: model.id, label: model.name })),
);
const { deps, taskRunner } = createOrchestrationHarness(db);
const result = taskRunner.queueTaskRun(taskId, "manual", "board");
expect(result.ok).toBe(true);
expect(deps.enqueueWorkerRun).toHaveBeenCalledTimes(1);
expect(deps.enqueueWorkerRun.mock.calls[0]?.[0]?.connectorId).toBe("zai");
expect(deps.enqueueWorkerRun.mock.calls[0]?.[0]?.model).toMatch(/^glm-/);
db.close();
});
it("blocks queueTaskRun when an agent keeps a disabled explicit model", async () => {
const profileDir = await createTempDir("agentcompany-model-enforcement-");
const db = new AppDatabase(profileDir);
@@ -200,7 +200,7 @@ describe("skill-aware capability registry", () => {
})),
companyWorkspaces: snapshot.workspaces
.filter((workspace) => workspace.companyId === companyId)
.map((workspace) => ({ name: workspace.name, localPath: workspace.localPath })),
.map((workspace) => ({ id: workspace.id, name: workspace.name, localPath: workspace.localPath })),
companyDocuments: [],
skills: [
{
@@ -214,7 +214,7 @@ describe("skill-aware capability registry", () => {
expect(prompt).toContain("# Company Capability Registry");
expect(prompt).toContain("React UI (`react-ui`)");
expect(prompt).toContain("Primary:");
expect(prompt).toMatch(/Primary \(`[0-9a-f-]+`\):/);
expect(prompt).toContain("Ready Connectors");
expect(prompt).toContain("<available_skills>");
expect(prompt).toContain("<name>react-standards</name>");
+2 -2
View File
@@ -8,7 +8,7 @@ describe("buildCapabilityRegistrySection", () => {
{ name: "React Standards", slug: "react-standards", description: "Use when building React UI" },
],
companyWorkspaces: [
{ name: "Primary", localPath: "/tmp/workspace" },
{ id: "workspace-1", name: "Primary", localPath: "/tmp/workspace" },
],
allConnectors: [
{ id: "openai-codex", label: "Codex", status: "ready", enabledModels: ["gpt-5.4"] },
@@ -37,7 +37,7 @@ describe("buildCapabilityRegistrySection", () => {
expect(section).toContain("# Company Capability Registry");
expect(section).toContain("React Standards (`react-standards`)");
expect(section).toContain("Primary: /tmp/workspace");
expect(section).toContain("Primary (`workspace-1`): /tmp/workspace");
expect(section).toContain("Codex (`openai-codex`) — models: gpt-5.4");
expect(section).toContain("architecture_decision");
});
+2
View File
@@ -482,6 +482,7 @@ describe("AppDatabase", () => {
{ version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]);
db.close();
@@ -496,6 +497,7 @@ describe("AppDatabase", () => {
{ version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]);
});
@@ -176,6 +176,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]);
expect(appDb.queryAll<{ companyId: string }>("select company_id as companyId from secrets where id = 'secret-1'")[0])
.toEqual({ companyId: "company-1" });
@@ -210,6 +211,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]);
expect(appDb.queryAll("select id from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'"))
.toHaveLength(1);
@@ -235,6 +237,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]);
} finally {
appDb.close();
@@ -394,6 +397,7 @@ describe("legacy to v56 baseline migration", () => {
{ version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]);
} finally {
appDb.close();
+101
View File
@@ -0,0 +1,101 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectorId } from "@shared/types";
vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
import { CONNECTORS } from "../../src/main/connector-definitions";
import { resolveModels } from "../../src/main/model-resolver";
import { pickFromCandidates } from "../../src/main/model-dispatch";
describe("model capability seed", () => {
let tempDir: string;
let db: AppDatabase;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-model-capabilities-"));
db = new AppDatabase(tempDir);
db.init();
});
afterEach(async () => {
db.close();
await rm(tempDir, { recursive: true, force: true });
});
it("keeps catalog-seeded rows current on existing profiles without overwriting user edits", () => {
db.core.modelCapabilities.upsertModelCapability({
provider: "zai",
modelId: "glm-4.6",
reasoning: 2,
coding: 2,
writing: 2,
tier: "trivial",
costPerMtokOut: 99,
});
db.core.modelCapabilities.deleteModelCapability("zai", "glm-5.1");
db.close();
db = new AppDatabase(tempDir);
db.init();
expect(db.core.modelCapabilities.getModelCapability("zai", "glm-5.1")).toMatchObject({
provider: "zai",
modelId: "glm-5.1",
reasoning: 5,
coding: 4,
writing: 4,
tier: "hard",
});
expect(db.core.modelCapabilities.getModelCapability("zai", "glm-4.6")).toMatchObject({
reasoning: 2,
coding: 2,
writing: 2,
tier: "trivial",
costPerMtokOut: 99,
});
});
it("seeds a capability row for every static connector catalog model", () => {
const seeded = new Set(
db.core.modelCapabilities
.listModelCapabilities()
.map((entry) => `${entry.provider}::${entry.modelId}`),
);
const expected = new Set<string>();
for (const connector of CONNECTORS) {
for (const model of resolveModels(connector.provider)) {
expected.add(`${connector.provider}::${model.id}`);
}
if (connector.auth.type !== "local" && connector.defaultModel.trim()) {
expected.add(`${connector.provider}::${connector.defaultModel}`);
}
}
expect(expected.size).toBeGreaterThan(700);
for (const key of expected) {
expect(seeded.has(key), key).toBe(true);
}
});
it("uses the same catalog seed path for hard reasoning picks", () => {
const winner = pickFromCandidates(
{ domain: "reasoning", requiredTier: "hard" },
[{ provider: "zai", connectorId: "zai" as ConnectorId, modelId: "glm-5.1" }],
db.core.modelCapabilities.listModelCapabilities(),
);
expect(winner?.modelId).toBe("glm-5.1");
});
});
@@ -18,6 +18,7 @@ vi.mock("electron", () => ({
}));
import { AppDatabase } from "../../src/main/database";
import { migrateFilesystemTaskWorkspacesV60 } from "../../src/main/database/migrations/v60-filesystem-task-workspaces";
const tempDirs: string[] = [];
@@ -116,16 +117,74 @@ describe("legacy to v56 baseline migration", () => {
{ id: "task-inflight", requiresFilesystem: 1 },
]);
const migrations = appDb.queryAll<{ version: number; name: string }>(
"select version, name from schema_migrations where version in (56, 57, 58, 59) order by version",
"select version, name from schema_migrations where version in (56, 57, 58, 59, 60) order by version",
);
expect(migrations).toEqual([
{ version: 56, name: "baseline_v56" },
{ version: 57, name: "task_leases_runtime_ownership" },
{ version: 58, name: "approval_policy_table" },
{ version: 59, name: "social_company_scope" },
{ version: 60, name: "filesystem_task_workspaces" },
]);
} finally {
appDb.close();
}
});
it("backfills active filesystem tasks in single-workspace companies", async () => {
const profileDir = await createTempDir("agentcompany-fs-workspace-v60-");
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
const ts = "2026-05-15T00:00:00.000Z";
try {
rawDb.exec(`
create table schema_migrations (version integer primary key, name text not null, applied_at text not null);
create table workspaces (
id text primary key,
company_id text not null,
project_id text,
is_primary integer not null,
created_at text not null
);
create table agents (
id text primary key,
company_id text not null,
workspace_id text
);
create table tasks (
id text primary key,
company_id text not null,
project_id text,
assignee_agent_id text,
workspace_id text,
status text not null,
requires_filesystem integer not null,
updated_at text not null
);
`);
rawDb
.prepare("insert into workspaces (id, company_id, project_id, is_primary, created_at) values (?, ?, ?, ?, ?)")
.run("workspace-1", "company-1", null, 1, ts);
rawDb
.prepare("insert into agents (id, company_id, workspace_id) values (?, ?, ?)")
.run("agent-1", "company-1", null);
rawDb
.prepare("insert into tasks (id, company_id, project_id, assignee_agent_id, workspace_id, status, requires_filesystem, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-stuck", "company-1", "project-1", "agent-1", null, "todo", 1, ts);
rawDb
.prepare("insert into tasks (id, company_id, project_id, assignee_agent_id, workspace_id, status, requires_filesystem, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-cognitive", "company-1", "project-1", "agent-1", null, "todo", 0, ts);
migrateFilesystemTaskWorkspacesV60(rawDb);
const rows = rawDb
.prepare("select id, workspace_id as workspaceId from tasks order by id")
.all() as Array<{ id: string; workspaceId: string | null }>;
expect(rows).toEqual([
{ id: "task-cognitive", workspaceId: null },
{ id: "task-stuck", workspaceId: "workspace-1" },
]);
} finally {
rawDb.close();
}
});
});
+108
View File
@@ -0,0 +1,108 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
import { resolveTaskWorkspaceBinding } from "../../src/main/task-workspace-binding";
describe("resolveTaskWorkspaceBinding", () => {
let tempDir: string;
let db: AppDatabase;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-task-workspace-binding-"));
db = new AppDatabase(tempDir);
db.init();
});
afterEach(async () => {
db.close();
await rm(tempDir, { recursive: true, force: true });
});
it("binds filesystem tasks to the creator agent workspace", () => {
const { companyId, workspaceId, agentId } = seedCompany(db);
const result = resolveTaskWorkspaceBinding(db, {
companyId,
requiresFilesystem: true,
creatorAgentId: agentId,
});
expect(result).toEqual({ ok: true, workspaceId, source: "creator" });
});
it("binds filesystem tasks to the only company workspace", () => {
const { companyId, workspaceId } = seedCompany(db, { agentWorkspace: false });
const result = resolveTaskWorkspaceBinding(db, {
companyId,
requiresFilesystem: true,
});
expect(result).toEqual({ ok: true, workspaceId, source: "single_company" });
});
it("rejects filesystem tasks when the workspace is ambiguous", () => {
const { companyId } = seedCompany(db, { agentWorkspace: false });
db.core.workspaces.saveWorkspace({
companyId,
name: "Second",
localPath: "/tmp/second",
repoUrl: "",
repoRef: "",
isPrimary: false,
});
const result = resolveTaskWorkspaceBinding(db, {
companyId,
requiresFilesystem: true,
});
expect(result.ok).toBe(false);
});
});
function seedCompany(db: AppDatabase, options: { agentWorkspace?: boolean } = {}) {
const companyId = db.core.company.saveCompany({
name: "Workspace Binding Co",
description: "",
status: "active",
});
db.core.connectors.saveConnector({
id: "openai-codex",
model: "gpt-5",
envBindingText: "",
notes: "",
});
const workspaceId = db.core.workspaces.saveWorkspace({
companyId,
name: "Default",
localPath: "/tmp/default",
repoUrl: "",
repoRef: "",
isPrimary: true,
});
const agentId = db.core.agents.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: "CEO",
status: "active",
reportsTo: null,
connectorId: "openai-codex",
workspaceId: options.agentWorkspace === false ? null : workspaceId,
model: null,
capabilities: "Leadership",
budgetMonthlyUsd: 100,
});
return { companyId, workspaceId, agentId };
}