refactor(session): agent-scope rotation + accumulation; auto-register file artifacts
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
This commit is contained in:
1 parent
61fb4b947f
commit
ff5e22092a
45 files changed
+3409
-300
No files matched your search
@@ -43,7 +43,9 @@
|
||||
"framer-motion": "12.36.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zod": "4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
||||
Generated
+857
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,7 @@
|
||||
* Matching uses word-boundary tokenization instead of substring search,
|
||||
* so "lead" does not match "cleaner" or "leaderboard".
|
||||
*/
|
||||
import type { AgentRoleLevel } from "@shared/types";
|
||||
import type { AgentRoleLevel, TaskDomain, TaskTier } from "@shared/types";
|
||||
import { isLeadershipLevel } from "@shared/types";
|
||||
|
||||
export const LEADERSHIP_ROLE_KEYWORDS = [
|
||||
@@ -27,7 +27,25 @@ export const LEADERSHIP_ROLE_KEYWORDS = [
|
||||
] as const;
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
||||
// Unicode-safe tokenization. Matches runs of letters/numbers across
|
||||
// scripts so a Chinese title like "高级架构师" yields ["高级架构师"]
|
||||
// rather than being stripped. English tokens still tokenize on word
|
||||
// boundaries as before.
|
||||
return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a keyword appears in the role/title text. For Latin
|
||||
* keywords we use whole-token equality (so "lead" does not match
|
||||
* "leaderboard"). For CJK keywords we fall back to substring containment
|
||||
* because CJK text has no explicit word boundaries — "架构师" is a
|
||||
* legitimate substring of "高级架构师" and should match.
|
||||
*/
|
||||
function matchesKeyword(loweredText: string, tokens: Set<string>, keyword: string): boolean {
|
||||
if (/[\u4e00-\u9fff]/.test(keyword)) {
|
||||
return loweredText.includes(keyword);
|
||||
}
|
||||
return tokens.has(keyword);
|
||||
}
|
||||
|
||||
export function roleMatchesLeadershipKeywords(role: string, title?: string | null): boolean {
|
||||
@@ -57,3 +75,155 @@ export function agentIsLeader(agent: { roleLevel?: AgentRoleLevel | null; role:
|
||||
if (agent.roleLevel) return isLeadershipLevel(agent.roleLevel);
|
||||
return roleMatchesLeadershipKeywords(agent.role, agent.title);
|
||||
}
|
||||
|
||||
export interface TaskProfile {
|
||||
domain: TaskDomain;
|
||||
requiredTier: TaskTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered keyword → domain table. First match wins; order matters because
|
||||
* role strings can contain multiple cues ("research engineer" is primarily
|
||||
* a researcher, not a coder). English keywords match on whole-token
|
||||
* equality; CJK keywords (Chinese here) match on substring containment.
|
||||
* The list is intentionally narrow — anything unmatched falls through to
|
||||
* the role-level heuristic below.
|
||||
*/
|
||||
const DOMAIN_KEYWORDS: Array<{ keyword: string; domain: TaskDomain }> = [
|
||||
// ── Reasoning-heavy ──
|
||||
// English
|
||||
{ keyword: "architect", domain: "reasoning" },
|
||||
{ keyword: "reviewer", domain: "reasoning" },
|
||||
{ keyword: "auditor", domain: "reasoning" },
|
||||
{ keyword: "analyst", domain: "reasoning" },
|
||||
{ keyword: "researcher", domain: "reasoning" },
|
||||
{ keyword: "scientist", domain: "reasoning" },
|
||||
{ keyword: "mathematician", domain: "reasoning" },
|
||||
{ keyword: "economist", domain: "reasoning" },
|
||||
{ keyword: "strategist", domain: "reasoning" },
|
||||
{ keyword: "cto", domain: "reasoning" },
|
||||
{ keyword: "cfo", domain: "reasoning" },
|
||||
// Chinese (CJK keywords match via substring, so "高级架构师" hits 架构师)
|
||||
{ keyword: "架构师", domain: "reasoning" },
|
||||
{ keyword: "审查员", domain: "reasoning" },
|
||||
{ keyword: "审计", domain: "reasoning" },
|
||||
{ keyword: "分析师", domain: "reasoning" },
|
||||
{ keyword: "研究员", domain: "reasoning" },
|
||||
{ keyword: "科学家", domain: "reasoning" },
|
||||
{ keyword: "数学家", domain: "reasoning" },
|
||||
{ keyword: "经济学家", domain: "reasoning" },
|
||||
{ keyword: "策略师", domain: "reasoning" },
|
||||
{ keyword: "首席技术", domain: "reasoning" },
|
||||
{ keyword: "首席财务", domain: "reasoning" },
|
||||
|
||||
// ── Coding-heavy ──
|
||||
{ keyword: "engineer", domain: "coding" },
|
||||
{ keyword: "developer", domain: "coding" },
|
||||
{ keyword: "coder", domain: "coding" },
|
||||
{ keyword: "programmer", domain: "coding" },
|
||||
{ keyword: "devops", domain: "coding" },
|
||||
{ keyword: "sre", domain: "coding" },
|
||||
{ keyword: "工程师", domain: "coding" },
|
||||
{ keyword: "开发", domain: "coding" },
|
||||
{ keyword: "程序员", domain: "coding" },
|
||||
{ keyword: "研发", domain: "coding" },
|
||||
{ keyword: "运维", domain: "coding" },
|
||||
|
||||
// ── Writing-heavy: copy / marketing / content / design / PM / HR ──
|
||||
{ keyword: "writer", domain: "writing" },
|
||||
{ keyword: "copywriter", domain: "writing" },
|
||||
{ keyword: "marketer", domain: "writing" },
|
||||
{ keyword: "marketing", domain: "writing" },
|
||||
{ keyword: "content", domain: "writing" },
|
||||
{ keyword: "editor", domain: "writing" },
|
||||
{ keyword: "pm", domain: "writing" },
|
||||
{ keyword: "designer", domain: "writing" },
|
||||
{ keyword: "hr", domain: "writing" },
|
||||
{ keyword: "撰稿", domain: "writing" },
|
||||
{ keyword: "文案", domain: "writing" },
|
||||
{ keyword: "市场", domain: "writing" },
|
||||
{ keyword: "营销", domain: "writing" },
|
||||
{ keyword: "内容", domain: "writing" },
|
||||
{ keyword: "编辑", domain: "writing" },
|
||||
{ keyword: "设计师", domain: "writing" },
|
||||
{ keyword: "产品经理", domain: "writing" },
|
||||
{ keyword: "运营", domain: "writing" },
|
||||
{ keyword: "人力资源", domain: "writing" },
|
||||
{ keyword: "人事", domain: "writing" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Keyword cues that hard-pin a role to the trivial tier regardless of the
|
||||
* role-level enum. A data-entry clerk whose `roleLevel` is `ic` runs on a
|
||||
* trivial-tier model even if the free-text title is long; an "intern
|
||||
* director" (yes, these exist) gets reviewed by their title before their
|
||||
* level. Mixed English + Chinese cues.
|
||||
*/
|
||||
const TRIVIAL_KEYWORDS = [
|
||||
"intern",
|
||||
"assistant",
|
||||
"clerk",
|
||||
"entry",
|
||||
"junior",
|
||||
"associate",
|
||||
"实习",
|
||||
"助理",
|
||||
"初级",
|
||||
"见习",
|
||||
"文员",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Map a role string + optional title to (domain, requiredTier). Both the
|
||||
* domain and the tier are conservative defaults — the picker will still
|
||||
* fall back to the CEO model if no enabled candidate clears the bar, so
|
||||
* under-classifying here is always safe.
|
||||
*
|
||||
* The CEO / c_suite cases are handled by the caller (the dispatcher has
|
||||
* access to `agent.roleLevel` and knows to pin CEO to the user-chosen
|
||||
* model). This function treats every input as a subordinate.
|
||||
*/
|
||||
export function inferTaskProfile(
|
||||
role: string,
|
||||
title: string | null | undefined,
|
||||
roleLevel?: AgentRoleLevel | null,
|
||||
): TaskProfile {
|
||||
const loweredText = `${role} ${title ?? ""}`.toLowerCase();
|
||||
const tokens = new Set(tokenize(loweredText));
|
||||
|
||||
// 1. Domain by keyword — first match wins.
|
||||
let domain: TaskDomain = "writing";
|
||||
for (const entry of DOMAIN_KEYWORDS) {
|
||||
if (matchesKeyword(loweredText, tokens, entry.keyword)) {
|
||||
domain = entry.domain;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Tier is pinned trivial by explicit cues; otherwise rides role level.
|
||||
const trivialOverride = TRIVIAL_KEYWORDS.some((kw) => matchesKeyword(loweredText, tokens, kw));
|
||||
let tier: TaskTier;
|
||||
if (trivialOverride) {
|
||||
tier = "trivial";
|
||||
} else {
|
||||
switch (roleLevel) {
|
||||
case "c_suite":
|
||||
case "vp":
|
||||
case "director":
|
||||
tier = "hard";
|
||||
break;
|
||||
case "manager":
|
||||
case "lead":
|
||||
tier = "standard";
|
||||
break;
|
||||
case "ic":
|
||||
case null:
|
||||
case undefined:
|
||||
default:
|
||||
tier = "standard";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { domain, requiredTier: tier };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Pure projection helpers for pi AgentEvent → human-readable console log.
|
||||
*
|
||||
* Extracted from runtime-bridge.ts to keep the bridge under the 400-line
|
||||
* cap. These functions never touch DB, IPC, or filesystem — they are
|
||||
* deterministic text transforms and must stay that way. New event
|
||||
* types are added here; the bridge pipes the result into its run-log
|
||||
* stream.
|
||||
*
|
||||
* Reference: pi-mono-reference/packages/agent/src/types.ts `AgentEvent`
|
||||
* union is the canonical shape we project.
|
||||
*/
|
||||
|
||||
export type AgentEventLogChunk = { stream: "stdout" | "stderr"; chunk: string };
|
||||
|
||||
/**
|
||||
* Extract plain text from a pi AgentMessage's content array. User
|
||||
* messages carry `content: [{ type: "text", text: "..." }]`; other
|
||||
* content types (image, tool_result, thinking) are filtered out.
|
||||
*/
|
||||
export function extractMessageText(message: { content?: unknown }): string {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
|
||||
const text = (block as { text?: string }).text;
|
||||
if (typeof text === "string") parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact JSON preview used in tool-execution log lines. Falls back to a
|
||||
* sentinel when the value cannot be serialised (circular refs etc.).
|
||||
*/
|
||||
export function summariseJson(value: unknown, maxLength: number): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
try {
|
||||
const json = typeof value === "string" ? value : JSON.stringify(value);
|
||||
if (json.length <= maxLength) return json;
|
||||
return `${json.slice(0, maxLength - 3)}...`;
|
||||
} catch {
|
||||
return "<unserialisable>";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort textual rendering of a pi message's content for thread
|
||||
* rows. Handles text, thinking, and toolCall blocks; returns an empty
|
||||
* string for purely structural payloads so the thread view does not
|
||||
* show noise.
|
||||
*/
|
||||
export function extractTextBody(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const obj = block as { type?: string; text?: string; thinking?: string; name?: string };
|
||||
if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
|
||||
else if (obj.type === "thinking" && typeof obj.thinking === "string") parts.push(`[thinking] ${obj.thinking}`);
|
||||
else if (obj.type === "toolCall" && typeof obj.name === "string") parts.push(`[tool:${obj.name}]`);
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a pi AgentEvent to a single console log chunk. Returns null
|
||||
* for events that should not appear in the log (e.g., streaming
|
||||
* deltas whose content is already rendered upstream).
|
||||
*/
|
||||
export function projectAgentEventToLogChunk(
|
||||
event: { type: string } & Record<string, unknown>,
|
||||
): AgentEventLogChunk | null {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
return { stream: "stdout", chunk: "\n[agent:start]\n" };
|
||||
case "turn_start":
|
||||
return { stream: "stdout", chunk: "\n" };
|
||||
case "turn_end":
|
||||
return { stream: "stdout", chunk: "\n" };
|
||||
case "message_start": {
|
||||
const message = event.message as { role?: string; content?: unknown } | undefined;
|
||||
if (message?.role === "user") {
|
||||
const body = extractMessageText(message);
|
||||
if (!body) return null;
|
||||
return { stream: "stdout", chunk: `[user] ${body}\n` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "message_update": {
|
||||
const inner = event.assistantMessageEvent as
|
||||
| ({ type?: string; delta?: string; content?: string } & Record<string, unknown>)
|
||||
| undefined;
|
||||
if (!inner) return null;
|
||||
if (inner.type === "text_delta" && typeof inner.delta === "string" && inner.delta.length > 0) {
|
||||
return { stream: "stdout", chunk: inner.delta };
|
||||
}
|
||||
if (inner.type === "thinking_delta" && typeof inner.delta === "string" && inner.delta.length > 0) {
|
||||
return { stream: "stdout", chunk: inner.delta };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "message_end": {
|
||||
return { stream: "stdout", chunk: "\n" };
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
||||
const argsPreview = summariseJson(event.args, 200);
|
||||
return { stream: "stdout", chunk: `\n[tool:${toolName}] ${argsPreview}\n` };
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
||||
const isError = event.isError === true;
|
||||
const resultPreview = summariseJson(event.result, 300);
|
||||
const tag = isError ? "tool:error" : "tool:done";
|
||||
return { stream: isError ? "stderr" : "stdout", chunk: `[${tag}:${toolName}] ${resultPreview}\n` };
|
||||
}
|
||||
case "agent_end":
|
||||
return { stream: "stdout", chunk: "\n[agent:end]\n" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import type { AppDatabase } from "@main/database";
|
||||
|
||||
/**
|
||||
* Auto-registration of filesystem artifacts produced by pi's write / edit
|
||||
* tools. Every successful tool call that mutates a workspace file is
|
||||
* projected into a `work_products` row so the desktop UI sees what the
|
||||
* agent actually produced — the old behavior required the agent to call
|
||||
* `POST /api/documents` manually, which was routinely forgotten and left
|
||||
* dozens of files on disk invisible to users.
|
||||
*
|
||||
* Registration is idempotent: the natural identity of an artifact is
|
||||
* `(companyId, provider, externalId)` where externalId is the workspace-
|
||||
* relative path. Re-writing a file updates the existing row instead of
|
||||
* creating duplicates.
|
||||
*
|
||||
* This module is pure data-structure plumbing — it holds no state and
|
||||
* does no I/O beyond the single SQLite upsert. The RuntimeBridge owns
|
||||
* the call site.
|
||||
*/
|
||||
|
||||
/** Paths we never register — tool output that is never a deliverable. */
|
||||
const IGNORED_PATH_SEGMENTS: readonly string[] = [
|
||||
"/.git/",
|
||||
"/node_modules/",
|
||||
"/.next/",
|
||||
"/dist/",
|
||||
"/build/",
|
||||
"/out/",
|
||||
"/.cache/",
|
||||
"/.pnpm-store/",
|
||||
"/.DS_Store",
|
||||
];
|
||||
const IGNORED_EXACT_NAMES: ReadonlySet<string> = new Set([
|
||||
".DS_Store",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
".gitignore",
|
||||
]);
|
||||
/** Extensions we never register as work products (binaries, build output). */
|
||||
const IGNORED_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
".lock",
|
||||
".log",
|
||||
".map",
|
||||
".min.js",
|
||||
".min.css",
|
||||
]);
|
||||
|
||||
export type ArtifactProjectorToolEvent = {
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export type ArtifactProjectorRunContext = {
|
||||
runId: string;
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
workspacePath: string | null;
|
||||
};
|
||||
|
||||
type WriteArgs = { path: unknown; content?: unknown };
|
||||
type EditArgs = { path: unknown };
|
||||
|
||||
/** Normalises pi tool-call args to the file path the agent touched. */
|
||||
function extractToolPath(toolName: string, args: unknown): string | null {
|
||||
if (!args || typeof args !== "object") return null;
|
||||
if (toolName === "write") {
|
||||
const p = (args as WriteArgs).path;
|
||||
return typeof p === "string" && p.length > 0 ? p : null;
|
||||
}
|
||||
if (toolName === "edit") {
|
||||
const p = (args as EditArgs).path;
|
||||
return typeof p === "string" && p.length > 0 ? p : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns true when the path is inside the workspace and not on the ignore list. */
|
||||
function isRegistrablePath(absPath: string, workspacePath: string): boolean {
|
||||
const rel = relative(workspacePath, absPath);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) return false;
|
||||
const probePath = `/${rel}/`;
|
||||
if (IGNORED_PATH_SEGMENTS.some((seg) => probePath.includes(seg))) return false;
|
||||
const lastSlash = rel.lastIndexOf("/");
|
||||
const basename = lastSlash >= 0 ? rel.slice(lastSlash + 1) : rel;
|
||||
if (IGNORED_EXACT_NAMES.has(basename)) return false;
|
||||
const dot = basename.lastIndexOf(".");
|
||||
if (dot > 0) {
|
||||
const ext = basename.slice(dot).toLowerCase();
|
||||
if (IGNORED_EXTENSIONS.has(ext)) return false;
|
||||
// Catch compound extensions like ".min.js" by checking the last two dots.
|
||||
const prevDot = basename.lastIndexOf(".", dot - 1);
|
||||
if (prevDot > 0) {
|
||||
const compound = basename.slice(prevDot).toLowerCase();
|
||||
if (IGNORED_EXTENSIONS.has(compound)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ProjectionOutcome =
|
||||
| { kind: "skipped"; reason: string }
|
||||
| { kind: "inserted"; workProductId: string; relativePath: string }
|
||||
| { kind: "updated"; workProductId: string; relativePath: string };
|
||||
|
||||
/**
|
||||
* Upsert a work_products row for a successful write/edit tool call.
|
||||
* Returns an outcome describing whether the artifact was inserted,
|
||||
* updated, or skipped (with reason). Returns "skipped" for every
|
||||
* non-applicable case so the caller can log a single stream of events.
|
||||
*/
|
||||
export function projectToolExecutionToWorkProduct(
|
||||
db: AppDatabase,
|
||||
ctx: ArtifactProjectorRunContext,
|
||||
event: ArtifactProjectorToolEvent,
|
||||
): ProjectionOutcome {
|
||||
if (event.isError) return { kind: "skipped", reason: "tool_error" };
|
||||
if (event.toolName !== "write" && event.toolName !== "edit") {
|
||||
return { kind: "skipped", reason: "unsupported_tool" };
|
||||
}
|
||||
if (!ctx.workspacePath) return { kind: "skipped", reason: "no_workspace" };
|
||||
|
||||
const rawPath = extractToolPath(event.toolName, event.args);
|
||||
if (!rawPath) return { kind: "skipped", reason: "missing_path" };
|
||||
|
||||
const absPath = isAbsolute(rawPath) ? rawPath : resolve(ctx.workspacePath, rawPath);
|
||||
if (!isRegistrablePath(absPath, ctx.workspacePath)) {
|
||||
return { kind: "skipped", reason: "path_ignored" };
|
||||
}
|
||||
|
||||
const relativePath = relative(ctx.workspacePath, absPath);
|
||||
const lastSlash = relativePath.lastIndexOf("/");
|
||||
const title = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath;
|
||||
|
||||
const provider = "filesystem";
|
||||
const existing = db.findWorkProductByExternalId(ctx.companyId, provider, relativePath);
|
||||
if (existing) {
|
||||
db.updateWorkProduct(existing.id, ctx.companyId, { title });
|
||||
return { kind: "updated", workProductId: existing.id, relativePath };
|
||||
}
|
||||
|
||||
const workProductId = db.createWorkProduct({
|
||||
companyId: ctx.companyId,
|
||||
taskId: ctx.taskId,
|
||||
type: "file",
|
||||
provider,
|
||||
externalId: relativePath,
|
||||
title,
|
||||
url: null,
|
||||
createdByRunId: ctx.runId,
|
||||
});
|
||||
return { kind: "inserted", workProductId, relativePath };
|
||||
}
|
||||
@@ -7,103 +7,8 @@ import { CostWriter } from "./cost-writer";
|
||||
import type { CredentialResolver } from "@main/credentials";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
import { projectInboxMessageToPi } from "./inbox-projection";
|
||||
|
||||
/**
|
||||
* Projects a pi AgentEvent to a human-readable log line for the Console.
|
||||
* See pi-mono-reference/packages/agent/src/types.ts `AgentEvent` for the
|
||||
* canonical union shape; this projection handles every type that carries
|
||||
* user-visible activity.
|
||||
*/
|
||||
function projectAgentEventToLogChunk(
|
||||
event: { type: string } & Record<string, unknown>,
|
||||
): { stream: "stdout" | "stderr"; chunk: string } | null {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
return { stream: "stdout", chunk: "\n[agent:start]\n" };
|
||||
case "turn_start":
|
||||
return { stream: "stdout", chunk: "\n" };
|
||||
case "turn_end":
|
||||
return { stream: "stdout", chunk: "\n" };
|
||||
case "message_start": {
|
||||
const message = event.message as { role?: string; content?: unknown } | undefined;
|
||||
if (message?.role === "user") {
|
||||
const body = extractMessageText(message);
|
||||
if (!body) return null;
|
||||
return { stream: "stdout", chunk: `[user] ${body}\n` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "message_update": {
|
||||
// Streaming assistant deltas — `assistantMessageEvent.type` carries
|
||||
// the intra-turn stream protocol from pi-ai (text_delta /
|
||||
// thinking_delta / toolcall_start / ...). We surface text and
|
||||
// thinking deltas verbatim so the Console shows live output.
|
||||
const inner = event.assistantMessageEvent as
|
||||
| ({ type?: string; delta?: string; content?: string } & Record<string, unknown>)
|
||||
| undefined;
|
||||
if (!inner) return null;
|
||||
if (inner.type === "text_delta" && typeof inner.delta === "string" && inner.delta.length > 0) {
|
||||
return { stream: "stdout", chunk: inner.delta };
|
||||
}
|
||||
if (inner.type === "thinking_delta" && typeof inner.delta === "string" && inner.delta.length > 0) {
|
||||
return { stream: "stdout", chunk: inner.delta };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "message_end": {
|
||||
// The final assistant message is already fully rendered via
|
||||
// preceding message_update deltas; emit a trailing newline so the
|
||||
// next turn starts on its own line.
|
||||
return { stream: "stdout", chunk: "\n" };
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
||||
const argsPreview = summariseJson(event.args, 200);
|
||||
return { stream: "stdout", chunk: `\n[tool:${toolName}] ${argsPreview}\n` };
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
|
||||
const isError = event.isError === true;
|
||||
const resultPreview = summariseJson(event.result, 300);
|
||||
const tag = isError ? "tool:error" : "tool:done";
|
||||
return { stream: isError ? "stderr" : "stdout", chunk: `[${tag}:${toolName}] ${resultPreview}\n` };
|
||||
}
|
||||
case "agent_end":
|
||||
return { stream: "stdout", chunk: "\n[agent:end]\n" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls plain text out of a pi AgentMessage's content array. User messages
|
||||
* carry `content: [{ type: "text", text: "..." }]` per pi's AgentMessage
|
||||
* schema; other content types (image, tool_result, etc.) are ignored here.
|
||||
*/
|
||||
function extractMessageText(message: { content?: unknown }): string {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
|
||||
const text = (block as { text?: string }).text;
|
||||
if (typeof text === "string") parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function summariseJson(value: unknown, maxLength: number): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
try {
|
||||
const json = typeof value === "string" ? value : JSON.stringify(value);
|
||||
if (json.length <= maxLength) return json;
|
||||
return `${json.slice(0, maxLength - 3)}...`;
|
||||
} catch {
|
||||
return "<unserialisable>";
|
||||
}
|
||||
}
|
||||
import { projectToolExecutionToWorkProduct } from "./artifact-projector";
|
||||
import { projectAgentEventToLogChunk, extractTextBody } from "./agent-event-projection";
|
||||
|
||||
export interface RuntimeBridgeDeps {
|
||||
db: AppDatabase;
|
||||
@@ -124,6 +29,12 @@ interface ActiveRunContext {
|
||||
connectorId: string;
|
||||
modelId: string;
|
||||
taskSessionId: string;
|
||||
/**
|
||||
* Workspace root handed to pi's coding tools as cwd. Needed by the
|
||||
* artifact projector to resolve tool-reported paths (which may be
|
||||
* relative) and to reject writes that escape the workspace.
|
||||
*/
|
||||
workspacePath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,6 +128,10 @@ export class RuntimeBridge {
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.event.type === "tool_execution_end") {
|
||||
this.projectToolArtifact(envelope);
|
||||
}
|
||||
|
||||
if (envelope.event.type === "message_end") {
|
||||
const ctx = this.activeRuns.get(envelope.runId);
|
||||
const messageRaw = envelope.event.message as
|
||||
@@ -248,6 +163,49 @@ export class RuntimeBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a pi `tool_execution_end` event into the `work_products`
|
||||
* table when the tool was `write` or `edit` and succeeded. This
|
||||
* replaces the earlier behaviour where the agent had to remember to
|
||||
* `POST /api/documents` after writing a file; agents forgot routinely,
|
||||
* so the desktop UI showed an empty artifact list while the workspace
|
||||
* filesystem filled up. The projector is idempotent and silent — all
|
||||
* skip reasons are traced to the warn channel so operators can audit
|
||||
* why a given write did not register.
|
||||
*/
|
||||
private projectToolArtifact(envelope: {
|
||||
runId: string;
|
||||
event: { type: string } & Record<string, unknown>;
|
||||
}): void {
|
||||
const ctx = this.activeRuns.get(envelope.runId);
|
||||
if (!ctx) return;
|
||||
const e = envelope.event;
|
||||
const toolName = typeof e.toolName === "string" ? e.toolName : null;
|
||||
if (!toolName) return;
|
||||
try {
|
||||
projectToolExecutionToWorkProduct(
|
||||
this.deps.db,
|
||||
{
|
||||
runId: ctx.runId,
|
||||
agentId: ctx.agentId,
|
||||
companyId: ctx.companyId,
|
||||
taskId: ctx.taskId,
|
||||
workspacePath: ctx.workspacePath,
|
||||
},
|
||||
{
|
||||
toolName,
|
||||
args: e.args,
|
||||
result: e.result,
|
||||
isError: e.isError === true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.deps.logger.warn(
|
||||
`[runtime-bridge] artifact projection failed for run ${envelope.runId} tool ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the pi `message_end` event into the task thread as a
|
||||
* `conversation_events` row so renderer thread views stay live.
|
||||
@@ -312,6 +270,7 @@ export class RuntimeBridge {
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
modelId: string;
|
||||
workspacePath: string | null;
|
||||
};
|
||||
this.registerActiveRun({
|
||||
runId: request.runId,
|
||||
@@ -321,6 +280,7 @@ export class RuntimeBridge {
|
||||
connectorId: payload.connectorId,
|
||||
modelId: payload.modelId,
|
||||
taskSessionId: payload.taskSessionId,
|
||||
workspacePath: payload.workspacePath,
|
||||
});
|
||||
const definition = getConnectorDefinition(payload.connectorId);
|
||||
const resolved = await this.deps.credentialResolver.resolve(payload.connectorId);
|
||||
@@ -364,17 +324,3 @@ export class RuntimeBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort textual rendering of a pi message's content for thread rows. */
|
||||
function extractTextBody(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const obj = block as { type?: string; text?: string; thinking?: string; name?: string };
|
||||
if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
|
||||
else if (obj.type === "thinking" && typeof obj.thinking === "string") parts.push(`[thinking] ${obj.thinking}`);
|
||||
else if (obj.type === "toolCall" && typeof obj.name === "string") parts.push(`[tool:${obj.name}]`);
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { buildWakeupMethods } from "./database/wakeup";
|
||||
import { buildBackupMethods } from "./database/backup";
|
||||
import { buildSessionEntryMethods } from "./database/session-entries";
|
||||
import { buildCredentialMethods } from "./database/credentials";
|
||||
import { buildModelCapabilityMethods } from "./database/model-capabilities-crud";
|
||||
import { MODEL_CAPABILITY_SEED } from "./model-capability-seed";
|
||||
import { initializeDatabase } from "./database/schema";
|
||||
import { SecretVault } from "./vault";
|
||||
import type { AgentStatus, ConnectorId, ProfileSnapshot, RunStatus } from "@shared/types";
|
||||
@@ -68,6 +70,7 @@ export class AppDatabaseBase {
|
||||
public readonly backupMod: ReturnType<typeof buildBackupMethods>;
|
||||
public readonly sessionEntries: ReturnType<typeof buildSessionEntryMethods>;
|
||||
public readonly credentialsTable: ReturnType<typeof buildCredentialMethods>;
|
||||
public readonly modelCapabilities: ReturnType<typeof buildModelCapabilityMethods>;
|
||||
|
||||
constructor(rootDir: string, options: { isPackaged?: boolean } = {}) {
|
||||
this.profileDir = rootDir;
|
||||
@@ -141,6 +144,7 @@ export class AppDatabaseBase {
|
||||
this.wakeup = buildWakeupMethods(getDb, ra, tx);
|
||||
this.sessionEntries = buildSessionEntryMethods(getDb, ra, tx);
|
||||
this.credentialsTable = buildCredentialMethods(getDb, ra, tx);
|
||||
this.modelCapabilities = buildModelCapabilityMethods(getDb, ra, tx);
|
||||
this.backupMod = buildBackupMethods({
|
||||
getDb, setDb: (db) => { this.db = db; },
|
||||
openConnection: () => this.openConnection(),
|
||||
@@ -159,11 +163,94 @@ export class AppDatabaseBase {
|
||||
const tx = <T>(op: () => T) => this.transaction(op);
|
||||
initializeDatabase(this.db, ra, tx);
|
||||
this.connectorsCrud.seedConnectors();
|
||||
this.modelCapabilities.seedModelCapabilities(MODEL_CAPABILITY_SEED);
|
||||
this._lastInterruptedCount = this.interruptStaleRuns();
|
||||
this._lastOrphanResetCount = this.reconcileOrphanedTasks();
|
||||
this._lastUnreviewableResetCount = this.reconcileUnreviewableTasks();
|
||||
this.settings.purgeExpiredRunLogs();
|
||||
this.wakeup.purgeExpiredWakeupRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset tasks stuck at `in_progress` with no live run.
|
||||
*
|
||||
* Invariant: `task.status = 'in_progress'` ⇒ there exists a run `r`
|
||||
* where `r.status IN ('queued', 'running')` referenced by this task
|
||||
* (via checkout_run_id or active_run_id).
|
||||
*
|
||||
* Whenever the invariant breaks — because a dispatch failed, a worker
|
||||
* crashed, or a pre-fix finishRun cleared the run refs but not the
|
||||
* status — the task becomes un-reclaimable: `checkoutTask`'s guard
|
||||
* requires `status IN ('todo','backlog')`, and heartbeat loops forever
|
||||
* logging "lost checkout race". One query at boot drains every such
|
||||
* orphan, regardless of which of the two reference columns happens to
|
||||
* be null. The runtime `finishRun` path enforces the same invariant
|
||||
* going forward, so this should stabilise at zero rows changed.
|
||||
*/
|
||||
protected reconcileOrphanedTasks(): number {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'todo',
|
||||
checkout_run_id = NULL,
|
||||
execution_locked_at = NULL,
|
||||
active_run_id = NULL,
|
||||
updated_at = ?
|
||||
WHERE status = 'in_progress'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM runs r
|
||||
WHERE (r.id = tasks.checkout_run_id OR r.id = tasks.active_run_id)
|
||||
AND r.status IN ('queued', 'running')
|
||||
)`,
|
||||
)
|
||||
.run(new Date().toISOString());
|
||||
const changes = Number(result.changes);
|
||||
if (changes > 0) {
|
||||
|
||||
console.info(`[init] Reconciled ${changes} orphaned in_progress task(s) → todo.`);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset tasks stuck at `in_review` with no reachable reviewer.
|
||||
*
|
||||
* A task enters `in_review` when an assignee submits work for approval.
|
||||
* The reviewer is the assignee's manager (walk up via `reports_to`),
|
||||
* or the human operator when the task is flagged
|
||||
* `requires_user_review = 1`. When the assignee is a top-level agent
|
||||
* (CEO/board, `reports_to IS NULL`) and the task does not require
|
||||
* user review, *no one* can review it — the task becomes a dead
|
||||
* letter, and the CEO heartbeat falls into a loop re-proposing its
|
||||
* `approve_ceo_strategy` without ever recovering.
|
||||
*
|
||||
* This observed pattern cost us a full debugging session: CEO
|
||||
* self-assigned a large task, moved it to `in_review`, then spent
|
||||
* every heartbeat cycle re-emitting the same approval. Demoting the
|
||||
* task to `todo` breaks the loop — the CEO sees un-reviewable work
|
||||
* and correctly falls through to the hire path instead.
|
||||
*/
|
||||
protected reconcileUnreviewableTasks(): number {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'todo', updated_at = ?
|
||||
WHERE status = 'in_review'
|
||||
AND requires_user_review = 0
|
||||
AND assignee_agent_id IS NOT NULL
|
||||
AND assignee_agent_id IN (
|
||||
SELECT id FROM agents WHERE reports_to IS NULL
|
||||
)`,
|
||||
)
|
||||
.run(new Date().toISOString());
|
||||
const changes = Number(result.changes);
|
||||
if (changes > 0) {
|
||||
|
||||
console.info(`[init] Reconciled ${changes} un-reviewable in_review task(s) → todo.`);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
close() { this.db.close(); }
|
||||
|
||||
// ─── Settings ──────────────────────────────────────────────────────────────
|
||||
@@ -196,7 +283,6 @@ export class AppDatabaseBase {
|
||||
getCompanyExecutionMode(companyId: string) { return this.company.getCompanyExecutionMode(companyId); }
|
||||
getCompanyApprovalSettings(companyId: string) { return this.company.getCompanyApprovalSettings(companyId); }
|
||||
listActiveCompanyIds() { return this.company.listActiveCompanyIds(); }
|
||||
getSidebarBadges(companyId: string) { return this.company.getSidebarBadges(companyId); }
|
||||
getInbox(companyId: string) { return this.company.getInbox(companyId); }
|
||||
getCompanyMetrics(companyId: string, days: number = 30) { return this.company.getCompanyMetrics(companyId, days); }
|
||||
getStandupReport(companyId: string) { return this.company.getStandupReport(companyId); }
|
||||
@@ -211,7 +297,7 @@ export class AppDatabaseBase {
|
||||
|
||||
// ─── Agents ────────────────────────────────────────────────────────────────
|
||||
|
||||
saveAgent(input: { id?: string | null; companyId: string; name: string; role: string; title: string; department?: string | null; status: AgentStatus; reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; model?: string | null; metadataJson?: string; capabilities: string; budgetMonthlyUsd: number }) { return this.agents.saveAgent(input); }
|
||||
saveAgent(input: { id?: string | null; companyId: string; name: string; role: string; title: string; department?: string | null; status: AgentStatus; reportsTo?: string | null; connectorId: ConnectorId; workspaceId?: string | null; model?: string | null; metadataJson?: string; capabilities: string; budgetMonthlyUsd: number; roleLevel?: import("@shared/types").AgentRoleLevel; modelStrategy?: import("@shared/types").AgentModelStrategy }) { return this.agents.saveAgent(input); }
|
||||
deleteAgent(id: string, companyId: string) { return this.agents.deleteAgent(id, companyId); }
|
||||
getAgent(id: string) { return this.entityCrud.getAgent(id); }
|
||||
findAgent(id: string) { return this.entityCrud.findAgent(id); }
|
||||
@@ -257,6 +343,7 @@ export class AppDatabaseBase {
|
||||
updateTaskSession(taskId: string, sessionJson: Record<string, unknown> | null, sessionDisplayId: string | null) { return this.tasks.updateTaskSession(taskId, sessionJson, sessionDisplayId); }
|
||||
getTaskSession(taskId: string) { return this.tasks.getTaskSession(taskId); }
|
||||
getActiveTaskSession(taskId: string) { return this.tasks.getActiveTaskSession(taskId); }
|
||||
getSessionById(sessionId: string) { return this.tasks.getSessionById(sessionId); }
|
||||
incrementSessionUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string) { return this.tasks.incrementSessionUsage(sessionId, inputTokens, outputTokens, runId); }
|
||||
archiveSession(sessionId: string, reason: string, handoffMarkdown: string) { return this.tasks.archiveSession(sessionId, reason, handoffMarkdown); }
|
||||
createNewSession(input: { taskId: string; companyId: string; agentId: string; adapterType: string; sessionParamsJson: string | null; sessionDisplayId: string | null }) { return this.tasks.createNewSession(input); }
|
||||
@@ -353,7 +440,11 @@ export class AppDatabaseBase {
|
||||
|
||||
// ─── Internal infrastructure ───────────────────────────────────────────────
|
||||
protected _lastInterruptedCount = 0;
|
||||
protected _lastOrphanResetCount = 0;
|
||||
protected _lastUnreviewableResetCount = 0;
|
||||
get lastInterruptedCount(): number { return this._lastInterruptedCount; }
|
||||
get lastOrphanResetCount(): number { return this._lastOrphanResetCount; }
|
||||
get lastUnreviewableResetCount(): number { return this._lastUnreviewableResetCount; }
|
||||
|
||||
interruptStaleRuns(): number {
|
||||
const ts = nowIso();
|
||||
|
||||
@@ -94,9 +94,10 @@ export class AppDatabase extends AppDatabaseBase {
|
||||
createDocumentRevision(input: { documentId: string; companyId: string; body: string; changeSummary?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null }) { return this.documentsMod.createDocumentRevision(input); }
|
||||
getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); }
|
||||
listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); }
|
||||
createWorkProduct(input: { companyId: string; taskId: string; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); }
|
||||
createWorkProduct(input: { companyId: string; taskId: string | null; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); }
|
||||
updateWorkProduct(id: string, companyId: string, patch: Parameters<ReturnType<typeof import("./database/documents").buildDocumentMethods>["updateWorkProduct"]>[2]) { return this.documentsMod.updateWorkProduct(id, companyId, patch); }
|
||||
listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); }
|
||||
findWorkProductByExternalId(companyId: string, provider: string, externalId: string) { return this.documentsMod.findWorkProductByExternalId(companyId, provider, externalId); }
|
||||
deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, companyId); }
|
||||
linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); }
|
||||
unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentRecord, AgentRoleLevel, ConnectorId } from "@shared/types";
|
||||
import type { AgentModelStrategy, AgentRecord, AgentRoleLevel, ConnectorId } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
@@ -48,6 +48,13 @@ export function buildAgentCrudMethods(
|
||||
budgetMonthlyUsd: number;
|
||||
/** Optional explicit seniority; inferred from role/title when omitted. */
|
||||
roleLevel?: AgentRoleLevel;
|
||||
/**
|
||||
* Model routing strategy (v39+). `explicit` pins the agent to
|
||||
* `connectorId + model`; `auto` lets the dispatcher pick at run
|
||||
* time. Omitting this leaves the existing column untouched on
|
||||
* update, and uses the DB default ('explicit') on insert.
|
||||
*/
|
||||
modelStrategy?: AgentModelStrategy;
|
||||
}): string {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
@@ -64,12 +71,17 @@ export function buildAgentCrudMethods(
|
||||
// Snapshot before-config for revision tracking
|
||||
const beforeAgent = getDb()
|
||||
.prepare(
|
||||
"SELECT name, role, title, department, capabilities, connector_id, model, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?",
|
||||
"SELECT name, role, title, department, capabilities, connector_id, model, model_strategy, role_level, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?",
|
||||
)
|
||||
.get(id, input.companyId) as Record<string, unknown> | undefined;
|
||||
// Preserve existing modelStrategy and roleLevel when the caller omits
|
||||
// them, so non-related updates (e.g. updateAgentWorkspaceBinding) do
|
||||
// not accidentally flip routing or seniority.
|
||||
const effectiveStrategy = input.modelStrategy ?? (beforeAgent?.model_strategy as string | undefined) ?? "explicit";
|
||||
const effectiveRoleLevel = input.roleLevel ?? (beforeAgent?.role_level as string | undefined) ?? "ic";
|
||||
getDb()
|
||||
.prepare(
|
||||
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, model_strategy = ?, role_level = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(
|
||||
input.name,
|
||||
@@ -81,6 +93,8 @@ export function buildAgentCrudMethods(
|
||||
input.connectorId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
normalizeOptionalId(input.model),
|
||||
effectiveStrategy,
|
||||
effectiveRoleLevel,
|
||||
metadataJson,
|
||||
input.capabilities,
|
||||
input.budgetMonthlyUsd,
|
||||
@@ -132,9 +146,11 @@ export function buildAgentCrudMethods(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const insertStrategy = input.modelStrategy ?? "explicit";
|
||||
const insertRoleLevel = input.roleLevel ?? "ic";
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
||||
"insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, model_strategy, role_level, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -148,6 +164,8 @@ export function buildAgentCrudMethods(
|
||||
input.connectorId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
normalizeOptionalId(input.model),
|
||||
insertStrategy,
|
||||
insertRoleLevel,
|
||||
input.capabilities,
|
||||
input.budgetMonthlyUsd,
|
||||
metadataJson,
|
||||
|
||||
@@ -62,7 +62,7 @@ export function buildAgentHierarchyMethods(
|
||||
`WITH RECURSIVE chain AS (
|
||||
SELECT id, company_id as companyId, name, role, title, department, status,
|
||||
reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId,
|
||||
model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd,
|
||||
model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd,
|
||||
spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson,
|
||||
heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec,
|
||||
last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt,
|
||||
@@ -71,7 +71,7 @@ export function buildAgentHierarchyMethods(
|
||||
UNION ALL
|
||||
SELECT a.id, a.company_id, a.name, a.role, a.title, a.department, a.status,
|
||||
a.reports_to, a.connector_id, a.workspace_id,
|
||||
a.model, a.capabilities, a.role_level, a.budget_monthly_usd,
|
||||
a.model, a.capabilities, a.role_level, a.model_strategy, a.budget_monthly_usd,
|
||||
a.spent_monthly_usd, a.metadata_json,
|
||||
a.heartbeat_enabled, a.heartbeat_interval_sec,
|
||||
a.last_heartbeat_at, a.session_id, a.created_at, a.updated_at,
|
||||
@@ -91,7 +91,7 @@ export function buildAgentHierarchyMethods(
|
||||
|
||||
getDirectReports(agentId: string, companyId: string): AgentRecord[] {
|
||||
return readAll<AgentRecord>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?",
|
||||
companyId,
|
||||
agentId,
|
||||
).map((record) => ({
|
||||
@@ -121,7 +121,7 @@ export function buildAgentHierarchyMethods(
|
||||
*/
|
||||
listAgentsForApi(companyId: string): AgentApiRow[] {
|
||||
return readAll<AgentApiRow & { heartbeatEnabled: number | boolean }>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc",
|
||||
companyId,
|
||||
).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) })) as AgentApiRow[];
|
||||
},
|
||||
@@ -200,7 +200,7 @@ export function buildAgentHierarchyMethods(
|
||||
|
||||
listHeartbeatEligibleAgents(companyId: string): AgentRecord[] {
|
||||
return readAll<AgentRecord>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')",
|
||||
companyId,
|
||||
).map((record) => ({
|
||||
...record,
|
||||
|
||||
@@ -185,7 +185,7 @@ export function buildDocumentMethods(
|
||||
// ── Work Products ──
|
||||
|
||||
createWorkProduct(input: {
|
||||
companyId: string; taskId: string; projectId?: string | null;
|
||||
companyId: string; taskId: string | null; projectId?: string | null;
|
||||
type: string; provider: string; externalId?: string | null;
|
||||
title: string; url?: string | null; status?: string; reviewState?: string;
|
||||
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
|
||||
@@ -193,7 +193,7 @@ export function buildDocumentMethods(
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
if (input.isPrimary) {
|
||||
if (input.isPrimary && input.taskId) {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?",
|
||||
).run(ts, input.companyId, input.taskId, input.type);
|
||||
@@ -202,7 +202,7 @@ export function buildDocumentMethods(
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.companyId, input.projectId ?? null, input.taskId,
|
||||
id, input.companyId, input.projectId ?? null, input.taskId ?? null,
|
||||
input.type, input.provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, input.status ?? "active",
|
||||
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
|
||||
@@ -212,6 +212,29 @@ export function buildDocumentMethods(
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Look up a work product by its natural identity — the tuple
|
||||
* (companyId, provider, externalId) uniquely names a registered
|
||||
* artifact. Returns null when not found. Used by the artifact
|
||||
* projector to make tool-output registration idempotent: the first
|
||||
* write inserts, subsequent writes (same path, same provider) go
|
||||
* through `updateWorkProduct` instead of spamming duplicates.
|
||||
*/
|
||||
findWorkProductByExternalId(
|
||||
companyId: string,
|
||||
provider: string,
|
||||
externalId: string,
|
||||
): { id: string; taskId: string | null } | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND provider = ? AND external_id = ? LIMIT 1",
|
||||
)
|
||||
.get(companyId, provider, externalId) as
|
||||
| { id: string; taskId: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
updateWorkProduct(
|
||||
id: string,
|
||||
companyId: string,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildEntityCrudMethods(
|
||||
getAgent(id: string): AgentRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) throw new Error(`Agent not found: ${id}`);
|
||||
@@ -74,7 +74,7 @@ export function buildEntityCrudMethods(
|
||||
findAgent(id: string): AgentRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) return null;
|
||||
@@ -251,6 +251,7 @@ export function buildEntityCrudMethods(
|
||||
reportsTo: string | null; connectorId: ConnectorId;
|
||||
workspaceId: string | null; model: string | null;
|
||||
metadataJson: string; capabilities: string; budgetMonthlyUsd: number;
|
||||
modelStrategy?: "explicit" | "auto";
|
||||
}) => string;
|
||||
requestApproval: (input: {
|
||||
companyId: string; requestedByAgentId: string | null;
|
||||
@@ -267,6 +268,11 @@ export function buildEntityCrudMethods(
|
||||
connectorId: input.connectorId, workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null, metadataJson: input.metadataJson ?? "{}",
|
||||
capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
// Agents hired by the CEO (or any other agent) default to `auto`:
|
||||
// the dispatcher will pick the cheapest enabled model that
|
||||
// clears their inferred task profile. User-driven form saves
|
||||
// flip this to 'explicit' at the IPC handler.
|
||||
modelStrategy: "auto",
|
||||
});
|
||||
const approvalId = selfDeps.requestApproval({
|
||||
companyId: input.companyId,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { columnExists, tableExists } from "./types";
|
||||
|
||||
export function createMigrationsV38V39(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
// Model capability table. Maps (provider, model_id) to 1..5 scores across
|
||||
// three domains (reasoning / coding / writing), a tier enum used as a
|
||||
// hard lower bound for task-profile matching, and a cost-per-Mtok-output
|
||||
// figure used to pick the cheapest qualifying model.
|
||||
//
|
||||
// Scores, tier, and cost are data — editable at runtime via IPC. Seed
|
||||
// rows are inserted by the main process on startup when the table is
|
||||
// empty so a fresh install boots with a sensible matrix covering the
|
||||
// providers in the pi-ai catalog and the curated Fireworks list.
|
||||
version: 38,
|
||||
name: "create_model_capabilities",
|
||||
fn: () => {
|
||||
if (tableExists(db, "model_capabilities")) {
|
||||
log.info("[migration v38] model_capabilities already exists; skipping");
|
||||
return;
|
||||
}
|
||||
db.exec(
|
||||
`CREATE TABLE model_capabilities (
|
||||
provider TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
reasoning INTEGER NOT NULL,
|
||||
coding INTEGER NOT NULL,
|
||||
writing INTEGER NOT NULL,
|
||||
tier TEXT NOT NULL,
|
||||
cost_per_mtok_out REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, model_id)
|
||||
)`,
|
||||
);
|
||||
db.exec("CREATE INDEX idx_model_capabilities_tier ON model_capabilities(tier)");
|
||||
log.info("[migration v38] created model_capabilities table");
|
||||
},
|
||||
},
|
||||
{
|
||||
// agents.model_strategy drives run-time model dispatch.
|
||||
//
|
||||
// 'explicit' — use agent.connector_id and agent.model verbatim
|
||||
// (the pre-v39 behavior — kept for existing rows so there
|
||||
// are no regressions on upgrade).
|
||||
// 'auto' — ignore agent.connector_id/model; at run dispatch time
|
||||
// pick the cheapest model whose capability vector meets
|
||||
// the agent's inferred task profile.
|
||||
//
|
||||
// New agents default to 'auto' at the application layer. Existing rows
|
||||
// on upgrade are forced to 'explicit' because the user previously picked
|
||||
// their model manually and we must not silently re-route them.
|
||||
version: 39,
|
||||
name: "add_agents_model_strategy",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "agents")) {
|
||||
throw new Error("agents table missing; refusing to migrate v39");
|
||||
}
|
||||
if (columnExists(db, "agents", "model_strategy")) {
|
||||
log.info("[migration v39] agents.model_strategy already exists; skipping");
|
||||
return;
|
||||
}
|
||||
// Default at schema level = 'explicit' so existing rows keep their
|
||||
// hand-picked model. Application code uses 'auto' as the default for
|
||||
// freshly created agents.
|
||||
db.exec(
|
||||
"ALTER TABLE agents ADD COLUMN model_strategy TEXT NOT NULL DEFAULT 'explicit'",
|
||||
);
|
||||
log.info("[migration v39] added agents.model_strategy column");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { tableExists } from "./types";
|
||||
|
||||
export function createMigrationsV40V40(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
// Relax work_products.task_id from NOT NULL to NULL-allowed.
|
||||
//
|
||||
// Background: every file that an agent writes via pi's write/edit tool
|
||||
// should be registered as a work product so the desktop UI surfaces
|
||||
// it. Heartbeat (taskless) runs — CEO team-building scratch memos,
|
||||
// research exploration — legitimately produce files with no owning
|
||||
// task. Forcing task_id NOT NULL made the artifact projector skip
|
||||
// these runs, which in turn made the UI look empty while the
|
||||
// workspace filesystem filled up with unregistered output.
|
||||
//
|
||||
// SQLite has no ALTER COLUMN, so we rebuild the table. The operation
|
||||
// is done inside a transaction; PRAGMA foreign_keys is disabled for
|
||||
// the rebuild window and restored afterward so child rows do not
|
||||
// cascade-delete when the old table is dropped mid-swap.
|
||||
version: 40,
|
||||
name: "relax_work_products_task_id",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "work_products")) {
|
||||
log.info("[migration v40] work_products table missing; skipping");
|
||||
return;
|
||||
}
|
||||
const info = db.prepare("PRAGMA table_info(work_products)").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
const taskIdRow = info.find((row) => row.name === "task_id");
|
||||
if (!taskIdRow) {
|
||||
throw new Error("work_products.task_id column missing; refusing to migrate v40");
|
||||
}
|
||||
if (taskIdRow.notnull === 0) {
|
||||
log.info("[migration v40] work_products.task_id already nullable; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE work_products_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
task_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
external_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
review_state TEXT NOT NULL DEFAULT 'none',
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT,
|
||||
metadata_json TEXT,
|
||||
created_by_run_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO work_products_new (
|
||||
id, company_id, project_id, task_id, type, provider, external_id,
|
||||
title, url, status, review_state, is_primary, summary, metadata_json,
|
||||
created_by_run_id, created_at, updated_at
|
||||
)
|
||||
SELECT id, company_id, project_id, task_id, type, provider, external_id,
|
||||
title, url, status, review_state, is_primary, summary, metadata_json,
|
||||
created_by_run_id, created_at, updated_at
|
||||
FROM work_products;
|
||||
DROP TABLE work_products;
|
||||
ALTER TABLE work_products_new RENAME TO work_products;
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_task
|
||||
ON work_products(company_id, task_id, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_updated
|
||||
ON work_products(company_id, updated_at);
|
||||
`);
|
||||
} finally {
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
log.info("[migration v40] relaxed work_products.task_id to nullable");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ModelCapability } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* CRUD for `model_capabilities`. The table is (provider, model_id) keyed; the
|
||||
* dispatcher reads it on every run, so every row must have numeric scores —
|
||||
* we validate inputs at the application layer before touching the DB.
|
||||
*/
|
||||
export function buildModelCapabilityMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
type Row = {
|
||||
provider: string;
|
||||
model_id: string;
|
||||
reasoning: number;
|
||||
coding: number;
|
||||
writing: number;
|
||||
tier: "trivial" | "standard" | "hard";
|
||||
cost_per_mtok_out: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function rowToRecord(row: Row): ModelCapability {
|
||||
return {
|
||||
provider: row.provider,
|
||||
modelId: row.model_id,
|
||||
reasoning: row.reasoning,
|
||||
coding: row.coding,
|
||||
writing: row.writing,
|
||||
tier: row.tier,
|
||||
costPerMtokOut: row.cost_per_mtok_out,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function listModelCapabilities(): ModelCapability[] {
|
||||
return readAll<Row>(
|
||||
"SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities ORDER BY provider, model_id",
|
||||
).map(rowToRecord);
|
||||
}
|
||||
|
||||
function getModelCapability(provider: string, modelId: string): ModelCapability | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities WHERE provider = ? AND model_id = ?",
|
||||
)
|
||||
.get(provider, modelId) as Row | undefined;
|
||||
return row ? rowToRecord(row) : null;
|
||||
}
|
||||
|
||||
function upsertModelCapability(input: Omit<ModelCapability, "updatedAt">): void {
|
||||
const now = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO model_capabilities (provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, model_id) DO UPDATE SET
|
||||
reasoning = excluded.reasoning,
|
||||
coding = excluded.coding,
|
||||
writing = excluded.writing,
|
||||
tier = excluded.tier,
|
||||
cost_per_mtok_out = excluded.cost_per_mtok_out,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
input.provider,
|
||||
input.modelId,
|
||||
input.reasoning,
|
||||
input.coding,
|
||||
input.writing,
|
||||
input.tier,
|
||||
input.costPerMtokOut,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
function deleteModelCapability(provider: string, modelId: string): void {
|
||||
getDb()
|
||||
.prepare("DELETE FROM model_capabilities WHERE provider = ? AND model_id = ?")
|
||||
.run(provider, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the table from a list of rows. Used at boot when the table is empty
|
||||
* so a fresh install has something to dispatch against. Runs inside a
|
||||
* single transaction so either every row is written or none — we don't
|
||||
* want partial seeding if something goes wrong mid-loop.
|
||||
*/
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
listModelCapabilities,
|
||||
getModelCapability,
|
||||
upsertModelCapability,
|
||||
deleteModelCapability,
|
||||
seedModelCapabilities,
|
||||
};
|
||||
}
|
||||
@@ -96,10 +96,19 @@ export function buildRunMethods(
|
||||
sessionDisplayId: string | null;
|
||||
unattributedCost: boolean;
|
||||
}) {
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId }
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId, started_at as startedAt from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId; startedAt: string | null }
|
||||
| undefined;
|
||||
if (!run) return { budgetHardStopped: false };
|
||||
// A run that never transitioned to 'running' (startedAt === null) and
|
||||
// is now terminal with status 'failed' / 'cancelled' must roll the
|
||||
// task back to 'todo'. Otherwise the task is stuck in 'in_progress'
|
||||
// with no owner — checkoutTask refuses (WHERE status IN todo/backlog),
|
||||
// and the scheduler loops forever logging "lost checkout race". The
|
||||
// common trigger is workspace-lock contention: `dispatchRunToWorker`
|
||||
// bails before work starts and calls finishRun('failed').
|
||||
const runNeverStarted =
|
||||
run.startedAt === null && (input.status === "failed" || input.status === "cancelled");
|
||||
const ts = nowIso();
|
||||
|
||||
// Cost is owned by CostWriter — per-message rows in `cost_entries`.
|
||||
@@ -137,6 +146,33 @@ export function buildRunMethods(
|
||||
getDb()
|
||||
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
|
||||
.run(ts, run.taskId, input.runId);
|
||||
if (runNeverStarted) {
|
||||
// Roll the task back to `todo` when no *other* live run claims
|
||||
// it. Invariant: `task.status = 'in_progress'` ⇒ a queued or
|
||||
// running run owns it. We drop the previous
|
||||
// `checkout_run_id IS NULL AND active_run_id IS NULL` guard
|
||||
// because the conditional UPDATE two lines up can miss (race:
|
||||
// checkout_run_id already swapped to a newer run id), which
|
||||
// would leave the task silently stuck at in_progress. The
|
||||
// NOT EXISTS clause is the canonical "no live owner" check.
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'todo',
|
||||
checkout_run_id = NULL,
|
||||
execution_locked_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status = 'in_progress'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM runs r
|
||||
WHERE r.task_id = tasks.id
|
||||
AND r.id != ?
|
||||
AND r.status IN ('queued', 'running')
|
||||
)`,
|
||||
)
|
||||
.run(ts, run.taskId, input.runId);
|
||||
}
|
||||
}
|
||||
if (runCostUsd > 0) {
|
||||
if (run.taskId !== null) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createMigrationsV1V15 } from "./migrations/v1-v15";
|
||||
import { createMigrationsV16V25 } from "./migrations/v16-v25";
|
||||
import { createMigrationsV26V34 } from "./migrations/v26-v34";
|
||||
import { createMigrationsV35V37 } from "./migrations/v35-v37";
|
||||
import { createMigrationsV38V39 } from "./migrations/v38-v39";
|
||||
import { createMigrationsV40V40 } from "./migrations/v40-v40";
|
||||
import {
|
||||
columnExists,
|
||||
nowIso,
|
||||
@@ -172,6 +174,8 @@ export function runMigrations(
|
||||
...createMigrationsV16V25(deps),
|
||||
...createMigrationsV26V34(deps),
|
||||
...createMigrationsV35V37(deps),
|
||||
...createMigrationsV38V39(deps),
|
||||
...createMigrationsV40V40(deps),
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildSnapshotMethods(
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[],
|
||||
agents: readAll<SqliteRow>(
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
...companyArgs,
|
||||
).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[],
|
||||
goals: readAll<GoalRecord>(
|
||||
|
||||
@@ -122,6 +122,29 @@ export function buildTaskSessionMethods(
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a session by its primary key. Sessions are agent-scoped
|
||||
* (post-v34 migration): a heartbeat run has no task to dereference,
|
||||
* so the session lookup must take the session id directly. Use with
|
||||
* `agents.session_id` as the entry point.
|
||||
*/
|
||||
getSessionById(sessionId: string): TaskSessionRecord | null {
|
||||
return (
|
||||
(getDb()
|
||||
.prepare(
|
||||
`SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId,
|
||||
adapter_type as adapterType, session_params_json as sessionParamsJson,
|
||||
session_display_id as sessionDisplayId, last_run_id as lastRunId,
|
||||
last_error as lastError, run_count as runCount,
|
||||
total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens,
|
||||
rotated_at as rotatedAt, rotation_reason as rotationReason,
|
||||
handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt
|
||||
FROM task_sessions WHERE id = ?`,
|
||||
)
|
||||
.get(sessionId) as TaskSessionRecord | undefined) ?? null
|
||||
);
|
||||
},
|
||||
|
||||
incrementSessionUsage(
|
||||
sessionId: string,
|
||||
inputTokens: number,
|
||||
|
||||
@@ -115,6 +115,10 @@ export function registerCoreAgentGraphHandlers({
|
||||
metadataJson: parsed.metadataJson,
|
||||
capabilities: parsed.capabilities,
|
||||
budgetMonthlyUsd: parsed.budgetMonthlyUsd,
|
||||
// User edited this agent through the form and picked a specific
|
||||
// (connector, model). Respect their intent: pin the strategy to
|
||||
// `explicit` so the dispatcher never overrides their choice.
|
||||
modelStrategy: "explicit",
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("INVALID_AGENT_HIERARCHY", error instanceof Error ? error.message : "Agent could not be saved.");
|
||||
|
||||
+25
-1
@@ -81,10 +81,34 @@ export function createIpcSupport({
|
||||
});
|
||||
}
|
||||
|
||||
// Transport-layer failures when the renderer is mid-reload or already
|
||||
// disposed throw "Render frame was disposed before WebFrameMain could
|
||||
// be accessed" out of Electron's IPC core. Those exceptions are a
|
||||
// lifecycle signal, never a logic bug — but if they bubble, they
|
||||
// abort every downstream event in the same tick (RuntimeBridge fans
|
||||
// two emits per agent-event, the second one never runs when the first
|
||||
// throws). Treat emitEvent as a fire-and-forget sink: renderer
|
||||
// disappearances are silent, real serialisation errors are warned
|
||||
// once per event type.
|
||||
const renderFrameDisposedMessage = "Render frame was disposed";
|
||||
const emitWarnedChannels = new Set<string>();
|
||||
|
||||
function emitEvent(event: DesktopEvent) {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
try {
|
||||
mainWindow.webContents.send("agent-company:event", event);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes(renderFrameDisposedMessage)) {
|
||||
// Renderer is reloading or gone — expected; skip.
|
||||
return;
|
||||
}
|
||||
const key = event.type ?? "<untyped>";
|
||||
if (!emitWarnedChannels.has(key)) {
|
||||
emitWarnedChannels.add(key);
|
||||
logger.error(`[ipc] emitEvent(${key}) failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ModelCapability } from "@shared/types";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const MODEL_CAPABILITY_SEED: Array<Omit<ModelCapability, "updatedAt">> = [
|
||||
// ── Anthropic (Claude) ────────────────────────────────────────────────────
|
||||
// Claude Opus 4.7: strong across the board, top coding, top writing.
|
||||
{
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
reasoning: 5,
|
||||
coding: 5,
|
||||
writing: 5,
|
||||
tier: "hard",
|
||||
costPerMtokOut: 75,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
reasoning: 4,
|
||||
coding: 5,
|
||||
writing: 5,
|
||||
tier: "standard",
|
||||
costPerMtokOut: 15,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
modelId: "claude-haiku-4-5-20251001",
|
||||
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",
|
||||
reasoning: 5,
|
||||
coding: 4,
|
||||
writing: 3,
|
||||
tier: "hard",
|
||||
costPerMtokOut: 60,
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
modelId: "gpt-5-mini",
|
||||
reasoning: 4,
|
||||
coding: 3,
|
||||
writing: 3,
|
||||
tier: "standard",
|
||||
costPerMtokOut: 10,
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
modelId: "gpt-4o-mini",
|
||||
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",
|
||||
reasoning: 4,
|
||||
coding: 4,
|
||||
writing: 5,
|
||||
tier: "standard",
|
||||
costPerMtokOut: 3,
|
||||
},
|
||||
// Kimi K2.5 Turbo via Fire Pass router — newer, faster, similar quality.
|
||||
{
|
||||
provider: "fireworks-firepass",
|
||||
modelId: "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
reasoning: 4,
|
||||
coding: 4,
|
||||
writing: 5,
|
||||
tier: "standard",
|
||||
costPerMtokOut: 3,
|
||||
},
|
||||
// GLM 4.5: mid-tier across reasoning and coding, decent writing.
|
||||
{
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/models/glm-4p5",
|
||||
reasoning: 3,
|
||||
coding: 3,
|
||||
writing: 4,
|
||||
tier: "standard",
|
||||
costPerMtokOut: 2,
|
||||
},
|
||||
// Qwen3 Coder 480B: coding specialist.
|
||||
{
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
|
||||
reasoning: 3,
|
||||
coding: 5,
|
||||
writing: 3,
|
||||
tier: "standard",
|
||||
costPerMtokOut: 3,
|
||||
},
|
||||
// DeepSeek V3.1: strong reasoning, capable coder, writing ok.
|
||||
{
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/models/deepseek-v3p1",
|
||||
reasoning: 4,
|
||||
coding: 4,
|
||||
writing: 3,
|
||||
tier: "standard",
|
||||
costPerMtokOut: 2,
|
||||
},
|
||||
// Llama 3.3 70B: generalist trivial-tier fallback.
|
||||
{
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
reasoning: 2,
|
||||
coding: 2,
|
||||
writing: 2,
|
||||
tier: "trivial",
|
||||
costPerMtokOut: 1,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,209 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ConnectorId,
|
||||
ConnectorRecord,
|
||||
ModelCapability,
|
||||
TaskTier,
|
||||
} from "@shared/types";
|
||||
import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import { getConnectorModelPolicy } from "./connector-model-policy";
|
||||
import { inferTaskProfile } from "./agent-role-helpers";
|
||||
|
||||
/**
|
||||
* Resolves which (provider, modelId) to use for an agent at run dispatch.
|
||||
*
|
||||
* The rule is spelled out in the design discussion:
|
||||
*
|
||||
* 1. `explicit` strategy (or a CEO role):
|
||||
* use agent.connectorId + agent.model verbatim. CEO is always pinned
|
||||
* to whatever the user chose on the Agent form.
|
||||
*
|
||||
* 2. Only one enabled (provider, modelId) pair exists anywhere:
|
||||
* use it. Whether it's the CEO's pick or a lone API-key connector,
|
||||
* single-model installs should not exercise the dispatcher at all.
|
||||
*
|
||||
* 3. `auto` strategy with multiple candidates:
|
||||
* infer the agent's task profile (domain ∈ reasoning|coding|writing,
|
||||
* required tier ∈ trivial|standard|hard), filter enabled candidates
|
||||
* to those whose capability row clears both the domain score and the
|
||||
* tier floor, and pick the cheapest among them.
|
||||
*
|
||||
* 4. No candidate clears the bar in `auto`:
|
||||
* fall back to the CEO's (connectorId, model). Runs must not fail
|
||||
* because the capability matrix is under-seeded — No regressions.
|
||||
*/
|
||||
|
||||
export interface EnabledModel {
|
||||
provider: string;
|
||||
connectorId: ConnectorId;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface AgentAssignment {
|
||||
connectorId: ConnectorId;
|
||||
modelId: string | null;
|
||||
}
|
||||
|
||||
export interface PickModelDeps {
|
||||
getConnector: (id: ConnectorId) => ConnectorRecord;
|
||||
getDisabledModels: (connectorId: string) => Array<{ id: string; label: string }>;
|
||||
/**
|
||||
* Thin projection of the CEO for fallback. Returns `null` when the
|
||||
* company has no CEO yet (pre-bootstrap). The picker only needs enough
|
||||
* to route a run, so we don't demand the full AgentRecord — keeping the
|
||||
* surface narrow lets callers use the existing `db.getCeoForCompany`
|
||||
* together with a single `getAgent` lookup for the fallback pair.
|
||||
*/
|
||||
getCeoAssignment: (companyId: string) => { connectorId: ConnectorId; modelId: string | null } | null;
|
||||
listModelCapabilities: () => ModelCapability[];
|
||||
readConnectors: () => ConnectorRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum numeric score per tier — the model must score *at least* this on
|
||||
* the task's domain axis. Higher tiers demand strictly higher scores; a
|
||||
* tier-3 (hard) task refuses any model that scores below 4 on its domain
|
||||
* regardless of the model's own tier flag. Tuned to the 5-grade rubric.
|
||||
*/
|
||||
const DOMAIN_SCORE_FLOOR: Record<TaskTier, number> = {
|
||||
trivial: 2,
|
||||
standard: 3,
|
||||
hard: 4,
|
||||
};
|
||||
|
||||
const TIER_RANK: Record<TaskTier, number> = {
|
||||
trivial: 1,
|
||||
standard: 2,
|
||||
hard: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk every connector the user has configured, keep the ones that are
|
||||
* execution-ready (authenticated, healthy), and expand each to its enabled
|
||||
* model list. Returns a flat list of (provider, connectorId, modelId) triples
|
||||
* — the concrete universe the picker chooses from.
|
||||
*/
|
||||
export function listEnabledModels(deps: PickModelDeps): EnabledModel[] {
|
||||
const result: EnabledModel[] = [];
|
||||
for (const connector of deps.readConnectors()) {
|
||||
if (getConnectorExecutionReadinessIssue(connector)) continue;
|
||||
let definition;
|
||||
try {
|
||||
definition = getConnectorDefinition(connector.id);
|
||||
} catch (err) {
|
||||
log.warn(`[model-dispatch] Skipping unknown connector ${connector.id}:`, err instanceof Error ? err.message : String(err));
|
||||
continue;
|
||||
}
|
||||
const policy = getConnectorModelPolicy(deps, connector.id as ConnectorId);
|
||||
for (const model of policy.enabledModels) {
|
||||
result.push({
|
||||
provider: definition.provider,
|
||||
connectorId: connector.id as ConnectorId,
|
||||
modelId: model.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure scoring step. Given the agent's inferred profile, the capability
|
||||
* matrix, and the concrete list of enabled (provider, modelId) pairs,
|
||||
* pick the cheapest qualifying model — or return `null` when nothing
|
||||
* clears the bar so the caller can apply its fallback.
|
||||
*
|
||||
* Split out from `pickModelForAgent` so unit tests can exercise the
|
||||
* rubric without having to stand up the real connector catalog or
|
||||
* policy lookups.
|
||||
*/
|
||||
export function pickFromCandidates(
|
||||
profile: { domain: "reasoning" | "coding" | "writing"; requiredTier: TaskTier },
|
||||
enabled: EnabledModel[],
|
||||
capabilities: ModelCapability[],
|
||||
): EnabledModel | null {
|
||||
if (enabled.length === 0) return null;
|
||||
const domainFloor = DOMAIN_SCORE_FLOOR[profile.requiredTier];
|
||||
const tierFloor = TIER_RANK[profile.requiredTier];
|
||||
|
||||
const capabilityByKey = new Map<string, ModelCapability>();
|
||||
for (const cap of capabilities) {
|
||||
capabilityByKey.set(`${cap.provider}::${cap.modelId}`, cap);
|
||||
}
|
||||
|
||||
const qualifying: Array<{ model: EnabledModel; cap: ModelCapability }> = [];
|
||||
for (const model of enabled) {
|
||||
const cap = capabilityByKey.get(`${model.provider}::${model.modelId}`);
|
||||
if (!cap) continue;
|
||||
if (cap[profile.domain] < domainFloor) continue;
|
||||
if (TIER_RANK[cap.tier] < tierFloor) continue;
|
||||
qualifying.push({ model, cap });
|
||||
}
|
||||
if (qualifying.length === 0) return null;
|
||||
|
||||
qualifying.sort((a, b) => a.cap.costPerMtokOut - b.cap.costPerMtokOut);
|
||||
return qualifying[0]!.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-call entry point that run dispatch uses. Returns the pair to
|
||||
* burn into the run record; callers pass it straight into `createRun` and
|
||||
* the worker payload.
|
||||
*/
|
||||
export function pickModelForAgent(
|
||||
agent: AgentRecord,
|
||||
deps: PickModelDeps,
|
||||
): AgentAssignment {
|
||||
const strategy = agent.modelStrategy ?? "explicit";
|
||||
|
||||
// CEO is pinned to the user's choice. The canonical CEO marker is
|
||||
// `reportsTo === null` — the DB invariant (`getCeoForCompany` SELECTs on
|
||||
// the same predicate), so this holds even when `roleLevel` is still the
|
||||
// schema default 'ic' on an agent that was never tagged with the full
|
||||
// seniority enum. We also accept the enum for completeness.
|
||||
const isCeo = agent.reportsTo === null || agent.roleLevel === "c_suite";
|
||||
if (strategy === "explicit" || isCeo) {
|
||||
return { connectorId: agent.connectorId, modelId: agent.model ?? null };
|
||||
}
|
||||
|
||||
const enabled = listEnabledModels(deps);
|
||||
|
||||
// Single-model install: there's nothing to pick from. Use it.
|
||||
if (enabled.length === 1) {
|
||||
const only = enabled[0]!;
|
||||
return { connectorId: only.connectorId, modelId: only.modelId };
|
||||
}
|
||||
|
||||
// Empty universe → fall back to whatever is on the agent row. This can
|
||||
// only happen when every connector is unhealthy; the eligibility checks
|
||||
// higher up in run dispatch will still block the run, but we return
|
||||
// something sensible so the caller's contract stays total.
|
||||
if (enabled.length === 0) {
|
||||
return { connectorId: agent.connectorId, modelId: agent.model ?? null };
|
||||
}
|
||||
|
||||
const profile = inferTaskProfile(agent.role, agent.title, agent.roleLevel);
|
||||
const winner = pickFromCandidates(profile, enabled, deps.listModelCapabilities());
|
||||
|
||||
if (!winner) {
|
||||
// Fall back to the CEO's explicit pair. If there is no CEO (fresh
|
||||
// company, board-only), fall back to agent's own row so we never
|
||||
// throw. The upstream readiness check owns whether to let the run
|
||||
// actually start.
|
||||
const ceo = deps.getCeoAssignment(agent.companyId);
|
||||
if (ceo) return ceo;
|
||||
return { connectorId: agent.connectorId, modelId: agent.model ?? null };
|
||||
}
|
||||
|
||||
return { connectorId: winner.connectorId, modelId: winner.modelId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export for test setups that want to exercise the rubric without
|
||||
* standing up a full AgentRecord.
|
||||
*/
|
||||
export const __INTERNAL__ = {
|
||||
DOMAIN_SCORE_FLOOR,
|
||||
TIER_RANK,
|
||||
};
|
||||
@@ -257,6 +257,10 @@ export function createOnboardingService({
|
||||
: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
|
||||
budgetMonthlyUsd: 250,
|
||||
status: "idle",
|
||||
// CEO is pinned to the user's pick at onboarding. Never let the
|
||||
// dispatcher re-route it.
|
||||
modelStrategy: "explicit",
|
||||
roleLevel: "c_suite",
|
||||
});
|
||||
db.setHeartbeat(ceoId, true, 120);
|
||||
db.setCurrentCompany(companyId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AppDatabase } from "../database";
|
||||
import type { AgentRecord, ConnectorRecord } from "@shared/types";
|
||||
import type { OrchestrationDependencies } from "./types";
|
||||
import type { RunDispatchParams } from "./turn-context";
|
||||
import { pickModelForAgent } from "../model-dispatch";
|
||||
|
||||
export function createHeartbeat(
|
||||
deps: OrchestrationDependencies,
|
||||
@@ -41,8 +42,21 @@ export function createHeartbeat(
|
||||
logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`);
|
||||
return null;
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
if (getRunEligibilityFailure(agent, connector, null)) {
|
||||
const assignment = pickModelForAgent(agent, {
|
||||
getConnector: (id) => db.getConnector(id),
|
||||
getDisabledModels: (id) => db.getDisabledModels(id),
|
||||
getCeoAssignment: (cid) => {
|
||||
const ceo = db.getCeoForCompany(cid);
|
||||
if (!ceo) return null;
|
||||
const full = db.getAgent(ceo.id);
|
||||
return { connectorId: full.connectorId, modelId: full.model ?? null };
|
||||
},
|
||||
listModelCapabilities: () => db.modelCapabilities.listModelCapabilities(),
|
||||
readConnectors: () => db.connectorsCrud.readConnectors(),
|
||||
});
|
||||
const connector = db.getConnector(assignment.connectorId);
|
||||
const effectiveAgentForEligibility: AgentRecord = { ...agent, model: assignment.modelId };
|
||||
if (getRunEligibilityFailure(effectiveAgentForEligibility, connector, null)) {
|
||||
return null;
|
||||
}
|
||||
// Extended heartbeat policy checks (Paperclip parity).
|
||||
@@ -182,6 +196,7 @@ export function createHeartbeat(
|
||||
companyDescription: companyBasic?.description ?? "",
|
||||
snapshot: db.listSnapshot(),
|
||||
trigger,
|
||||
resolvedModel: assignment.modelId,
|
||||
});
|
||||
|
||||
db.markHeartbeatAt(agentId);
|
||||
|
||||
@@ -4,10 +4,32 @@ import type { WorkspaceLockMode } from "./types";
|
||||
/**
|
||||
* Resolve the workspace and lock semantics for a given run context.
|
||||
*
|
||||
* Previously this logic lived both in run-dispatch.ts (normal dispatch path)
|
||||
* and worker-crash-recovery.ts (re-enqueue after crash); the recovery
|
||||
* copy silently dropped the exclusive/shared distinction. Sharing the
|
||||
* helper guarantees both paths agree on which runs hold exclusive locks.
|
||||
* Lock policy (v0.4.168+):
|
||||
*
|
||||
* task.workspaceId set → exclusive
|
||||
* The user (or an upstream caller) explicitly bound a task to a
|
||||
* workspace, which is the opt-in signal for "this run needs the
|
||||
* workspace to itself". Rare, and reserved for workflows like a
|
||||
* sprint merge or release build where concurrent writes would
|
||||
* corrupt state.
|
||||
*
|
||||
* agent.workspaceId set → shared
|
||||
* project.workspaceId set → shared
|
||||
* company fallback → shared
|
||||
* Multiple agents sharing the *same* workspace is the normal case
|
||||
* — it represents "this team works in this repo", not "this agent
|
||||
* owns this repo". The previous exclusive default forced four
|
||||
* co-located teammates to run one at a time, which deadlocked
|
||||
* heartbeats (dispatch bailed → task stuck in_progress → retry
|
||||
* lost the checkout race forever). Git itself serializes the
|
||||
* dangerous operations (`git index.lock`); anything coarser here
|
||||
* just blocks readers.
|
||||
*
|
||||
* Previously this logic lived both in run-dispatch.ts (normal dispatch
|
||||
* path) and worker-crash-recovery.ts (re-enqueue after crash); the
|
||||
* recovery copy silently dropped the exclusive/shared distinction.
|
||||
* Sharing the helper guarantees both paths agree on which runs hold
|
||||
* exclusive locks.
|
||||
*/
|
||||
export function resolveWorkspaceWithLockMode(
|
||||
db: AppDatabase,
|
||||
@@ -28,12 +50,12 @@ export function resolveWorkspaceWithLockMode(
|
||||
|
||||
const agentWorkspace = db.getWorkspace(agent.workspaceId);
|
||||
if (agentWorkspace) {
|
||||
return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" };
|
||||
return { workspace: agentWorkspace, lockMode: "shared", source: "agent" };
|
||||
}
|
||||
|
||||
const projectWorkspace = db.getProjectPrimaryWorkspace(task?.projectId ?? null, companyId);
|
||||
if (projectWorkspace) {
|
||||
return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" };
|
||||
return { workspace: projectWorkspace, lockMode: "shared", source: "project" };
|
||||
}
|
||||
|
||||
const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId);
|
||||
|
||||
@@ -71,6 +71,13 @@ export function createRunDispatch(deps: OrchestrationDependencies) {
|
||||
// Previously the worker claimed the lock only after it emitted `running`,
|
||||
// so two runs could race between enqueue and startup and both read the
|
||||
// same workspace mid-write. Main is the authoritative coordinator.
|
||||
//
|
||||
// On contention we mark the run `cancelled`, not `failed`. The Dashboard
|
||||
// "Needs attention" card only surfaces failed/timed_out/interrupted runs
|
||||
// (see src/renderer/components/dashboard/Dashboard.tsx), so the user is
|
||||
// not alarmed by a transient backoff. The task row is rolled back to
|
||||
// `todo` via the `finishRun` never-started branch, so heartbeat picks
|
||||
// it up on the next cycle with no manual intervention.
|
||||
if (
|
||||
params.workspaceMode === "exclusive" &&
|
||||
params.workspace &&
|
||||
@@ -78,9 +85,9 @@ export function createRunDispatch(deps: OrchestrationDependencies) {
|
||||
) {
|
||||
db.finishRun({
|
||||
runId: params.runId,
|
||||
status: "failed",
|
||||
status: "cancelled",
|
||||
summary: null,
|
||||
errorMessage: `Workspace ${params.workspace.id} is locked by another run.`,
|
||||
errorMessage: `Workspace ${params.workspace.id} is in use by another run; will retry on the next heartbeat.`,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AgentRecord, ConnectorRecord } from "@shared/types";
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import type { OrchestrationDependencies } from "./types";
|
||||
import type { RunDispatchParams } from "./turn-context";
|
||||
import { pickModelForAgent } from "../model-dispatch";
|
||||
|
||||
export function createTaskRunner(
|
||||
deps: OrchestrationDependencies,
|
||||
@@ -46,8 +47,21 @@ export function createTaskRunner(
|
||||
if (agent.companyId !== task.companyId) {
|
||||
return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company.");
|
||||
}
|
||||
const connector = db.getConnector(agent.connectorId);
|
||||
const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null);
|
||||
const assignment = pickModelForAgent(agent, {
|
||||
getConnector: (id) => db.getConnector(id),
|
||||
getDisabledModels: (id) => db.getDisabledModels(id),
|
||||
getCeoAssignment: (cid) => {
|
||||
const ceo = db.getCeoForCompany(cid);
|
||||
if (!ceo) return null;
|
||||
const full = db.getAgent(ceo.id);
|
||||
return { connectorId: full.connectorId, modelId: full.model ?? null };
|
||||
},
|
||||
listModelCapabilities: () => db.modelCapabilities.listModelCapabilities(),
|
||||
readConnectors: () => db.connectorsCrud.readConnectors(),
|
||||
});
|
||||
const connector = db.getConnector(assignment.connectorId);
|
||||
const effectiveAgentForEligibility: AgentRecord = { ...agent, model: assignment.modelId };
|
||||
const eligibilityFailure = getRunEligibilityFailure(effectiveAgentForEligibility, connector, task.projectId ?? null);
|
||||
if (eligibilityFailure === "AGENT_NOT_RUNNABLE") {
|
||||
return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated.");
|
||||
}
|
||||
@@ -109,6 +123,7 @@ export function createTaskRunner(
|
||||
companyDescription: companyBasic?.description ?? "",
|
||||
snapshot: db.listSnapshot(),
|
||||
trigger,
|
||||
resolvedModel: assignment.modelId,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId: task.companyId,
|
||||
|
||||
@@ -29,6 +29,14 @@ export interface RunDispatchParams {
|
||||
companyDescription: string;
|
||||
snapshot: ReturnType<AppDatabase["listSnapshot"]>;
|
||||
trigger: string;
|
||||
/**
|
||||
* Model id resolved by `pickModelForAgent` at dispatch time. When the
|
||||
* agent runs under `modelStrategy = "auto"` this differs from
|
||||
* `agent.model` (the column now becomes a legacy fallback). Optional so
|
||||
* explicit-strategy callers and legacy tests keep working; when absent
|
||||
* we read `agent.model` verbatim.
|
||||
*/
|
||||
resolvedModel?: string | null;
|
||||
}
|
||||
|
||||
export interface TurnContextDeps {
|
||||
@@ -37,10 +45,11 @@ export interface TurnContextDeps {
|
||||
getApiPort: () => number;
|
||||
createRunToken: (agentId: string, companyId: string, runId: string) => string;
|
||||
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[];
|
||||
getSessionHandoff?: (taskId: string) => string | null;
|
||||
evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => {
|
||||
getSessionHandoff?: (agentId: string) => string | null;
|
||||
evaluatePreRunSessionRotation?: (agentId: string, connectorId: string) => {
|
||||
rotated: boolean;
|
||||
handoff: string | null;
|
||||
sessionId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,14 +103,14 @@ function assembleRecentComments(
|
||||
}
|
||||
|
||||
function resolveSessionHandoff(
|
||||
taskId: string,
|
||||
agentId: string,
|
||||
deps: Pick<TurnContextDeps, "getSessionHandoff" | "logger">,
|
||||
): string | null {
|
||||
if (!deps.getSessionHandoff) return null;
|
||||
try {
|
||||
return deps.getSessionHandoff(taskId);
|
||||
return deps.getSessionHandoff(agentId);
|
||||
} catch (err) {
|
||||
deps.logger.warn(`[run] Failed to load session handoff for task ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
deps.logger.warn(`[run] Failed to load session handoff for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -138,7 +147,8 @@ function resolveSession(
|
||||
deps: Pick<TurnContextDeps, "evaluatePreRunSessionRotation" | "logger">,
|
||||
) {
|
||||
// Per-agent session: one ongoing transcript per agent; switching tasks
|
||||
// becomes a "now work on X" user turn rather than a fresh session.
|
||||
// (or running taskless heartbeats) becomes a "now work on X" user turn
|
||||
// on the ongoing transcript rather than starting a fresh session.
|
||||
const agentSessionId = db.ensureAgentSession({
|
||||
agentId: agent.id,
|
||||
companyId,
|
||||
@@ -148,35 +158,15 @@ function resolveSession(
|
||||
// that read per-task active_session_id keep working.
|
||||
db.updateTasksActiveSessionForAgent(agent.id, agentSessionId);
|
||||
|
||||
// Heartbeat runs have no task; reuse the agent-scoped session with no
|
||||
// task-specific active session lookup.
|
||||
if (!task) {
|
||||
return {
|
||||
taskSession: { id: agentSessionId, sessionParams: null, sessionDisplayId: null },
|
||||
sessionHandoff,
|
||||
};
|
||||
}
|
||||
|
||||
const activeSession = db.getActiveTaskSession(task.id);
|
||||
let taskSession: {
|
||||
id: string | null;
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
sessionDisplayId: string | null;
|
||||
} = activeSession
|
||||
? {
|
||||
id: activeSession.id,
|
||||
sessionParams: activeSession.sessionParamsJson
|
||||
? (JSON.parse(activeSession.sessionParamsJson) as Record<string, unknown>)
|
||||
: null,
|
||||
sessionDisplayId: activeSession.sessionDisplayId,
|
||||
}
|
||||
: { id: agentSessionId, sessionParams: null, sessionDisplayId: null };
|
||||
|
||||
// Evaluate rotation against the agent-scoped session unconditionally.
|
||||
// Heartbeat runs (no task) must rotate too, or their transcript grows
|
||||
// forever — the exact failure mode that burned through 7M token windows.
|
||||
let currentSessionId = agentSessionId;
|
||||
let finalHandoff = sessionHandoff;
|
||||
if (deps.evaluatePreRunSessionRotation && activeSession) {
|
||||
const rotationResult = deps.evaluatePreRunSessionRotation(task.id, connectorId);
|
||||
if (deps.evaluatePreRunSessionRotation) {
|
||||
const rotationResult = deps.evaluatePreRunSessionRotation(agent.id, connectorId);
|
||||
if (rotationResult.rotated) {
|
||||
taskSession = { id: null, sessionParams: null, sessionDisplayId: null };
|
||||
currentSessionId = rotationResult.sessionId ?? agentSessionId;
|
||||
if (!finalHandoff && rotationResult.handoff) {
|
||||
finalHandoff = rotationResult.handoff;
|
||||
}
|
||||
@@ -184,7 +174,24 @@ function resolveSession(
|
||||
}
|
||||
}
|
||||
|
||||
return { taskSession, sessionHandoff: finalHandoff };
|
||||
// `sessionParams` / `sessionDisplayId` are transient per-run state pi-ai
|
||||
// stamps onto the task row after a turn (e.g., Gemini's resume id). They
|
||||
// are only meaningful when a task's cached pointer still matches the
|
||||
// current (unrotated) session; after rotation the new session has none.
|
||||
let sessionParams: Record<string, unknown> | null = null;
|
||||
let sessionDisplayId: string | null = null;
|
||||
if (task) {
|
||||
const active = db.getActiveTaskSession(task.id);
|
||||
if (active && active.id === currentSessionId && active.sessionParamsJson) {
|
||||
sessionParams = JSON.parse(active.sessionParamsJson) as Record<string, unknown>;
|
||||
sessionDisplayId = active.sessionDisplayId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taskSession: { id: currentSessionId, sessionParams, sessionDisplayId },
|
||||
sessionHandoff: finalHandoff,
|
||||
};
|
||||
}
|
||||
|
||||
function extractThinkingEffort(
|
||||
@@ -284,7 +291,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
|
||||
const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]);
|
||||
const reviewTasks = db.listReviewTasksForManager(agent.id, companyId);
|
||||
const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot);
|
||||
const initialHandoff = task ? resolveSessionHandoff(task.id, deps) : null;
|
||||
const initialHandoff = resolveSessionHandoff(agent.id, deps);
|
||||
const { taskSession, sessionHandoff } = resolveSession(db, agent, task, companyId, connector.id, initialHandoff, deps);
|
||||
const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps);
|
||||
|
||||
@@ -335,7 +342,8 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
|
||||
}) : null;
|
||||
|
||||
const thinkingEffort = extractThinkingEffort(agent.metadataJson, deps.logger, agent.id);
|
||||
const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model);
|
||||
const modelForRun = params.resolvedModel !== undefined ? params.resolvedModel : agent.model;
|
||||
const modelSelection = validateConnectorModelSelection(db, connector.id, modelForRun);
|
||||
if (!modelSelection.ok) {
|
||||
throw new Error(modelSelection.error);
|
||||
}
|
||||
|
||||
@@ -71,9 +71,15 @@ export interface OrchestrationDependencies {
|
||||
budgetService: ReturnType<typeof createBudgetEnforcementService>;
|
||||
/** Checkpoint engine for unified co-founder mode pause decisions. */
|
||||
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
|
||||
getSessionHandoff?: (taskId: string) => string | null;
|
||||
evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => {
|
||||
getSessionHandoff?: (agentId: string) => string | null;
|
||||
evaluatePreRunSessionRotation?: (agentId: string, connectorId: string) => {
|
||||
rotated: boolean;
|
||||
handoff: string | null;
|
||||
/**
|
||||
* The session id the next run should use. Equals the freshly-minted
|
||||
* session id after a rotation, or the unchanged agent session id when
|
||||
* no rotation fired. Null if the agent has no session yet (first run).
|
||||
*/
|
||||
sessionId: string | null;
|
||||
};
|
||||
}
|
||||
@@ -1,48 +1,73 @@
|
||||
# Operating Instructions (CEO — Team Building Phase)
|
||||
|
||||
You are the new CEO. Your company has no active team members yet. Your job is to BUILD this company from scratch.
|
||||
You are the new CEO. Your company has no active team members yet. Your single job in this wake is to **staff the company**. Nothing else matters until you have direct reports.
|
||||
|
||||
## Step 1: Analyze the Mission and Workspace
|
||||
- Read ALL Active Goals carefully to understand the company direction.
|
||||
- **Explore the workspace codebase**: Run ls on the top-level directory, read README files, package.json, config files, and any documentation. Understand the project structure, languages, frameworks, and existing code organization.
|
||||
- **Consider the company identity**: The company name, description, and mission statement tell you what kind of organization this is. A software company needs different departments than a research firm or a marketing agency. Let the company context shape your org design.
|
||||
- Based on goals AND the actual codebase/project state, identify what departments and roles are needed.
|
||||
- **Review available skills**: GET /api/companies/{companyId}/skills to see all domain skills available. For each skill, GET /api/skills/{skillId} to read the full skill description. Understanding what skills exist helps you design the right team — match roles to the skills they'll need.
|
||||
## Critical ordering — do not violate
|
||||
|
||||
## Step 2: Design the Organization Structure
|
||||
Based on goals, design a complete org chart. A mature company typically has these departments:
|
||||
- **CTO / VP Engineering** — owns all technical execution (if goals involve code/product)
|
||||
- **COO / VP Operations** — owns processes, coordination, company cadence
|
||||
- **CFO / Finance** — owns budget, spend tracking, financial health
|
||||
- **CMO / VP Marketing** — owns growth, content, brand (if goals involve marketing)
|
||||
- **CPO / VP Product** — owns product design, UX, features (if goals involve product development)
|
||||
- **HR Director** — owns hiring pipeline, onboarding, performance management
|
||||
- **Sales Director** — owns revenue, partnerships, customer acquisition (if applicable)
|
||||
- **Customer Support Lead** — owns customer satisfaction, issue triage, knowledge base
|
||||
**HIRES FIRST. STRATEGY APPROVAL LAST.**
|
||||
|
||||
Think about what departments and roles are MOST needed to achieve the stated goals.
|
||||
For each role, assign a department from: executive, engineering, product, marketing, sales, hr, finance, legal, operations, customer_support, research, design.
|
||||
Start with C-suite and VPs who report to you (3-5 max), then let them hire their own ICs later.
|
||||
Do **not** submit `approve_ceo_strategy` until every intended hire has been submitted as a `hire_agent` request. Submitting strategy without hires leaves the company stuck — the strategy auto-approves, the heartbeat fires, and you land back here with nothing changed. This is a loop we have explicitly observed. Avoid it by obeying the order.
|
||||
|
||||
## Step 3: Submit Hire Requests
|
||||
For EACH role, use POST /api/companies/{companyId}/agents with:
|
||||
- A clear, professional name that is not just the role or title repeated back
|
||||
If you see in your activity or approvals history that you have previously submitted an `approve_ceo_strategy` approval **and** no `hire_agent` approvals, recognise it as the stuck-loop signature and jump directly to Step 3 (submit hires) on this wake. Do **not** submit another strategy approval.
|
||||
|
||||
## Step 1: Analyse the mission and the workspace (fast)
|
||||
|
||||
- Read the active goals. Understand what the company is actually for.
|
||||
- Run `ls` on the workspace root. Skim `README.md`, `package.json`, config files — whatever exists — to understand the ground state.
|
||||
- Let the company identity guide the org shape. A research institute is not a software shop; don't copy a generic template.
|
||||
- `GET /api/companies/{companyId}/skills` to see what domain skills exist. You'll assign these to hires later.
|
||||
|
||||
Do this step in minutes, not hours. Over-analysis here is how the company never gets staffed.
|
||||
|
||||
## Step 2: Design a minimal org
|
||||
|
||||
Pick 3-5 direct reports that cover the work. Do not design a full corporate hierarchy on wake one. Typical shapes:
|
||||
|
||||
- **Research institute / PhD project** — Research Director, Research Engineer, Writing Lead
|
||||
- **Software product** — CTO, VP Product, Design Lead
|
||||
- **Marketing-first company** — CMO, Content Lead, Growth Engineer
|
||||
- **Operations-heavy** — COO, Finance Lead, HR Lead
|
||||
|
||||
Each direct report reports to you. They can hire their own ICs later once you've delegated. Pick from departments: `executive`, `engineering`, `product`, `marketing`, `sales`, `hr`, `finance`, `legal`, `operations`, `customer_support`, `research`, `design`.
|
||||
|
||||
## Step 3: Submit hire requests (this wake)
|
||||
|
||||
For each role chosen in Step 2, `POST /api/companies/{companyId}/agents` with:
|
||||
|
||||
- A distinct professional name (not just the role echoed back)
|
||||
- Specific role and title
|
||||
- Detailed capabilities describing what they should own
|
||||
- reportsTo: an explicit manager agent ID (do not omit this field)
|
||||
- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude-pro.
|
||||
- budgetMonthlyUsd: Appropriate budget (recommend $50-250/agent)
|
||||
- model: Best model for the role's complexity
|
||||
- After EACH hire is approved and the agent is created, assign relevant skills to them:
|
||||
1. GET /api/companies/{companyId}/skills to review available skills
|
||||
2. For each skill that matches the agent's role and responsibilities:
|
||||
POST /api/agents/{agentId}/skills with {"skillId": "<skill-id>"}
|
||||
3. Match skills to roles — e.g., a CTO should get engineering/architecture skills, a CMO should get marketing/content skills
|
||||
- `capabilities`: one paragraph describing what they own
|
||||
- `reportsTo`: your own agent id (do not omit)
|
||||
- `connectorId`: use the connector recommended under "Available Connectors". Default to `claude-pro` if none is highlighted.
|
||||
- `budgetMonthlyUsd`: 50–250 per agent
|
||||
- `model`: pick per role complexity
|
||||
|
||||
## Step 4: Submit Org Strategy for Approval
|
||||
After submitting ALL hire requests, create an approve_ceo_strategy approval that includes:
|
||||
- Your organizational design rationale
|
||||
- Which goals each role will own
|
||||
- Expected workflow between roles
|
||||
After each hire is approved (`hire_agent` auto-approves when company policy allows), assign skills:
|
||||
|
||||
IMPORTANT: Wait for the Board to approve before starting business operations.
|
||||
1. `GET /api/companies/{companyId}/skills` to review options.
|
||||
2. For every relevant skill: `POST /api/agents/{agentId}/skills` with `{"skillId": "<id>"}`.
|
||||
3. Match skills to role — engineering skills to engineers, writing/marketing skills to writers, etc.
|
||||
|
||||
**You must submit every intended hire before you consider Step 4.**
|
||||
|
||||
## Step 4: Submit org strategy (only after every hire is submitted)
|
||||
|
||||
Once — and only once — all hire requests for this wake are live, `POST` one `approve_ceo_strategy` approval summarising:
|
||||
|
||||
- Who reports to whom
|
||||
- Which goal each direct report owns
|
||||
- The coordination expectation between them
|
||||
|
||||
Do not submit this approval more than once per team-building phase. If it exists already (pending or approved), you are finished here.
|
||||
|
||||
## Self-assignment: do not do it
|
||||
|
||||
Do **not** assign large goal-level tasks to yourself and move them to `in_review`. You have no manager; no one can review you. The system will demote any such task back to `todo` on the next boot, but the cycle wastes a heartbeat slot. Instead: create the task with an assignee from Step 3's hires, or leave it unassigned for the auto-assign rule to pick up.
|
||||
|
||||
## End state for this wake
|
||||
|
||||
- 3–5 `hire_agent` approvals submitted (or auto-approved)
|
||||
- At most **one** `approve_ceo_strategy` approval submitted
|
||||
- No large goal-level tasks assigned to yourself in `in_review`
|
||||
|
||||
When the next heartbeat fires, the mode will flip to `awaiting_hire` or `operating` and you'll have a team to delegate to.
|
||||
@@ -31,11 +31,11 @@ export function makeSessionHandoffLookup(
|
||||
deps: { logger: Logger },
|
||||
getSessionService: () => ReturnType<typeof createSessionManagementService>,
|
||||
) {
|
||||
return (taskId: string): string | null => {
|
||||
return (agentId: string): string | null => {
|
||||
try {
|
||||
return getSessionService().getHandoffContext(taskId);
|
||||
return getSessionService().getHandoffForAgent(agentId);
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
|
||||
deps.logger.warn(`[service-graph] getSessionHandoff failed for agent ${agentId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -45,21 +45,23 @@ export function makePreRunRotationEvaluator(
|
||||
deps: { db: AppDatabase; logger: Logger },
|
||||
getSessionService: () => ReturnType<typeof createSessionManagementService>,
|
||||
) {
|
||||
return (taskId: string, connectorId: string): { rotated: boolean; handoff: string | null } => {
|
||||
return (agentId: string, connectorId: string): { rotated: boolean; handoff: string | null; sessionId: string | null } => {
|
||||
try {
|
||||
const sessionSvc = getSessionService();
|
||||
const activeSession = deps.db.getActiveTaskSession(taskId);
|
||||
if (!activeSession) return { rotated: false, handoff: null };
|
||||
const result = sessionSvc.evaluateRotation(activeSession, connectorId);
|
||||
const agent = deps.db.getAgent(agentId);
|
||||
if (!agent.sessionId) return { rotated: false, handoff: null, sessionId: null };
|
||||
const session = deps.db.getSessionById(agent.sessionId);
|
||||
if (!session) return { rotated: false, handoff: null, sessionId: agent.sessionId };
|
||||
const result = sessionSvc.evaluateRotation(session, connectorId);
|
||||
if (result.shouldRotate) {
|
||||
sessionSvc.rotateSession(activeSession);
|
||||
const handoff = sessionSvc.getHandoffContext(taskId);
|
||||
return { rotated: true, handoff };
|
||||
const newSessionId = sessionSvc.rotateSession(session);
|
||||
const handoff = sessionSvc.getHandoffForAgent(agentId);
|
||||
return { rotated: true, handoff, sessionId: newSessionId };
|
||||
}
|
||||
return { rotated: false, handoff: null };
|
||||
return { rotated: false, handoff: null, sessionId: agent.sessionId };
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
|
||||
return { rotated: false, handoff: null };
|
||||
deps.logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for agent ${agentId}:`, error);
|
||||
return { rotated: false, handoff: null, sessionId: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -83,28 +83,30 @@ export function buildOrchestrationService({
|
||||
},
|
||||
budgetService,
|
||||
checkpointEngine,
|
||||
getSessionHandoff: (taskId) => {
|
||||
getSessionHandoff: (agentId) => {
|
||||
try {
|
||||
return sessionService.getHandoffContext(taskId);
|
||||
return sessionService.getHandoffForAgent(agentId);
|
||||
} catch (error) {
|
||||
logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
|
||||
logger.warn(`[service-graph] getSessionHandoff failed for agent ${agentId}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
evaluatePreRunSessionRotation: (taskId, connectorId) => {
|
||||
evaluatePreRunSessionRotation: (agentId, connectorId) => {
|
||||
try {
|
||||
const activeSession = db.getActiveTaskSession(taskId);
|
||||
if (!activeSession) return { rotated: false, handoff: null };
|
||||
const result = sessionService.evaluateRotation(activeSession, connectorId);
|
||||
const agent = db.getAgent(agentId);
|
||||
if (!agent.sessionId) return { rotated: false, handoff: null, sessionId: null };
|
||||
const session = db.getSessionById(agent.sessionId);
|
||||
if (!session) return { rotated: false, handoff: null, sessionId: agent.sessionId };
|
||||
const result = sessionService.evaluateRotation(session, connectorId);
|
||||
if (result.shouldRotate) {
|
||||
sessionService.rotateSession(activeSession);
|
||||
const handoff = sessionService.getHandoffContext(taskId);
|
||||
return { rotated: true, handoff };
|
||||
const newSessionId = sessionService.rotateSession(session);
|
||||
const handoff = sessionService.getHandoffForAgent(agentId);
|
||||
return { rotated: true, handoff, sessionId: newSessionId };
|
||||
}
|
||||
return { rotated: false, handoff: null };
|
||||
return { rotated: false, handoff: null, sessionId: agent.sessionId };
|
||||
} catch (error) {
|
||||
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
|
||||
return { rotated: false, handoff: null };
|
||||
logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for agent ${agentId}:`, error);
|
||||
return { rotated: false, handoff: null, sessionId: null };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,41 +63,48 @@ export function createSessionManagementService({ db, logger }: SessionManagement
|
||||
return { shouldRotate: false, reason: null };
|
||||
}
|
||||
|
||||
// D-06: Build handoff markdown from structured data, no AI call
|
||||
function generateHandoff(taskId: string, session: TaskSessionRecord): string {
|
||||
let task: { title: string; description: string } | null = null;
|
||||
try {
|
||||
task = db.getTask(taskId);
|
||||
} catch (err) {
|
||||
logger.warn(`[session-management] getTask failed for ${taskId} during handoff generation:`, err instanceof Error ? err.message : String(err));
|
||||
task = null;
|
||||
}
|
||||
// D-06: Build handoff markdown from structured data, no AI call.
|
||||
//
|
||||
// Sessions are agent-scoped (post-v34 migration). The handoff walks the
|
||||
// agent's recent runs across ALL tasks and taskless heartbeats — heartbeat
|
||||
// runs legitimately have no task but still need a handoff when their
|
||||
// transcript rotates under token pressure.
|
||||
function generateHandoff(session: TaskSessionRecord): string {
|
||||
const recentRuns = db.queryAll<{
|
||||
id: string; status: string; summary: string | null; errorMessage: string | null;
|
||||
finishedAt: string | null; taskId: string | null; taskTitle: string | null;
|
||||
}>(
|
||||
`SELECT r.id, r.status, r.summary, r.error_message as errorMessage,
|
||||
r.finished_at as finishedAt, r.task_id as taskId,
|
||||
t.title as taskTitle
|
||||
FROM runs r LEFT JOIN tasks t ON t.id = r.task_id
|
||||
WHERE r.agent_id = ? AND r.company_id = ? AND r.finished_at IS NOT NULL
|
||||
ORDER BY r.finished_at DESC LIMIT ?`,
|
||||
session.agentId,
|
||||
session.companyId,
|
||||
MAX_HANDOFF_RUNS,
|
||||
);
|
||||
const errorRuns = recentRuns.filter((r) => r.errorMessage);
|
||||
|
||||
const recentRuns = db.getRecentRunsForTask(taskId, MAX_HANDOFF_RUNS);
|
||||
const errorRuns = recentRuns.filter(r => r.errorMessage);
|
||||
const activityEntries = db.getRecentActivityForTask(taskId, 10);
|
||||
const decisionEntries = activityEntries.filter(a =>
|
||||
a.action.includes("decision") || a.action.includes("approval") || a.action.includes("resolved")
|
||||
const decisionEntries = db.queryAll<{ action: string; detail: string; createdAt: string }>(
|
||||
`SELECT action, detail, created_at as createdAt FROM activity
|
||||
WHERE company_id = ? AND actor = ?
|
||||
AND (action LIKE '%decision%' OR action LIKE '%approval%' OR action LIKE '%resolved%')
|
||||
ORDER BY created_at DESC LIMIT 5`,
|
||||
session.companyId,
|
||||
session.agentId,
|
||||
);
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push("# Session Handoff");
|
||||
sections.push("");
|
||||
|
||||
if (task) {
|
||||
sections.push("## Task");
|
||||
sections.push(`**${task.title}**`);
|
||||
const descSnippet = task.description.length > 200 ? task.description.slice(0, 200) + "..." : task.description;
|
||||
sections.push(descSnippet);
|
||||
sections.push("");
|
||||
}
|
||||
|
||||
if (recentRuns.length > 0) {
|
||||
sections.push("## Recent Run History");
|
||||
for (const run of recentRuns) {
|
||||
const summaryText = run.summary ? run.summary.slice(0, 100) : "(no summary)";
|
||||
sections.push(`- [${run.status}] ${summaryText} (${run.finishedAt ?? "in progress"})`);
|
||||
const scope = run.taskTitle ? ` on "${run.taskTitle}"` : " (heartbeat)";
|
||||
sections.push(`- [${run.status}]${scope} ${summaryText} (${run.finishedAt ?? "in progress"})`);
|
||||
}
|
||||
sections.push("");
|
||||
}
|
||||
@@ -112,7 +119,7 @@ export function createSessionManagementService({ db, logger }: SessionManagement
|
||||
|
||||
if (decisionEntries.length > 0) {
|
||||
sections.push("## Key Decisions");
|
||||
for (const entry of decisionEntries.slice(0, 5)) {
|
||||
for (const entry of decisionEntries) {
|
||||
sections.push(`- ${entry.detail.slice(0, 120)} (${entry.createdAt})`);
|
||||
}
|
||||
sections.push("");
|
||||
@@ -133,7 +140,7 @@ export function createSessionManagementService({ db, logger }: SessionManagement
|
||||
// still referencing the old session.
|
||||
function rotateSession(session: TaskSessionRecord): string {
|
||||
const reason = "threshold_exceeded";
|
||||
const handoff = generateHandoff(session.taskId, session);
|
||||
const handoff = generateHandoff(session);
|
||||
|
||||
logger.info(`[session] Rotating session ${session.id} for agent ${session.agentId}: ${reason}`);
|
||||
|
||||
@@ -161,18 +168,35 @@ export function createSessionManagementService({ db, logger }: SessionManagement
|
||||
return newSessionId;
|
||||
}
|
||||
|
||||
// D-07: Read handoff from most recently rotated session for a task
|
||||
function getHandoffContext(taskId: string): string | null {
|
||||
// Find the most recent archived session for this task (the one just before current)
|
||||
// D-07: Read handoff from the agent's most recently rotated session.
|
||||
//
|
||||
// Sessions are agent-scoped (post-v34 migration), so the handoff query
|
||||
// must key on agent_id, not task_id. task_sessions.task_id is now an
|
||||
// empty-string column kept only for schema-level backwards compatibility;
|
||||
// filtering on it returns zero rows for all rotated agent sessions.
|
||||
function getHandoffForAgent(agentId: string): string | null {
|
||||
const row = db.queryAll<{ handoffMarkdown: string | null }>(
|
||||
`SELECT handoff_markdown as handoffMarkdown FROM task_sessions
|
||||
WHERE task_id = ? AND rotated_at IS NOT NULL
|
||||
WHERE agent_id = ? AND rotated_at IS NOT NULL
|
||||
ORDER BY rotated_at DESC LIMIT 1`,
|
||||
taskId
|
||||
agentId,
|
||||
);
|
||||
return row[0]?.handoffMarkdown ?? null;
|
||||
}
|
||||
|
||||
// Task-scoped compatibility shim for callers that still hold a task id
|
||||
// (UI panels reading a specific task's handoff history). Resolves the
|
||||
// assignee and delegates to the agent-scoped lookup.
|
||||
function getHandoffContext(taskId: string): string | null {
|
||||
const taskRow = db.queryAll<{ assigneeAgentId: string | null }>(
|
||||
"SELECT assignee_agent_id as assigneeAgentId FROM tasks WHERE id = ?",
|
||||
taskId,
|
||||
);
|
||||
const agentId = taskRow[0]?.assigneeAgentId;
|
||||
if (!agentId) return null;
|
||||
return getHandoffForAgent(agentId);
|
||||
}
|
||||
|
||||
// D-09: Snapshot token totals for delta tracking
|
||||
function snapshotUsage(session: TaskSessionRecord): { inputTokens: number; outputTokens: number } {
|
||||
return {
|
||||
@@ -205,6 +229,7 @@ export function createSessionManagementService({ db, logger }: SessionManagement
|
||||
generateHandoff,
|
||||
rotateSession,
|
||||
getHandoffContext,
|
||||
getHandoffForAgent,
|
||||
snapshotUsage,
|
||||
getUsageDelta,
|
||||
};
|
||||
|
||||
@@ -96,28 +96,41 @@ export function accumulateAndRotateSession(
|
||||
rfCtx: RunFinishedContext,
|
||||
): void {
|
||||
const { db, logger, sessionService } = deps;
|
||||
const { run, task } = rfCtx;
|
||||
const { run } = rfCtx;
|
||||
|
||||
// SESS-01/SESS-02/SESS-03/SESS-05: Token accumulation and rotation evaluation
|
||||
if (!task || !sessionService) return;
|
||||
const activeSession = db.getActiveTaskSession(task.id);
|
||||
if (!activeSession) return;
|
||||
if (!sessionService) return;
|
||||
|
||||
// Sessions are agent-scoped (post-v34 migration). The previous gate on
|
||||
// `!task` silently dropped token accumulation and rotation for every
|
||||
// heartbeat run — the exact failure mode that lets a taskless transcript
|
||||
// grow past the model's context window. Resolve the session through the
|
||||
// agent, never through the task.
|
||||
let agentRecord: ReturnType<HandlerDeps["db"]["getAgent"]>;
|
||||
try {
|
||||
agentRecord = db.getAgent(run.agentId);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[session] accumulateAndRotateSession: getAgent(${run.agentId}) failed; skipping: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!agentRecord.sessionId) return;
|
||||
const session = db.getSessionById(agentRecord.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
// D-08/D-05: Accumulate token usage for ALL adapters (including exempt ones)
|
||||
sessionService.accumulateTokenUsage(
|
||||
activeSession.id,
|
||||
session.id,
|
||||
event.inputTokens ?? 0,
|
||||
event.outputTokens ?? 0,
|
||||
event.runId,
|
||||
);
|
||||
// D-03: Evaluate rotation after accumulation (after each run completes)
|
||||
const rotationResult = sessionService.evaluateRotation(activeSession, run.connectorId);
|
||||
const rotationResult = sessionService.evaluateRotation(session, run.connectorId);
|
||||
if (rotationResult.shouldRotate) {
|
||||
// Re-read to get updated counts after accumulation
|
||||
const updatedSession = db.getActiveTaskSession(task.id);
|
||||
// Re-read to get updated counts after accumulation.
|
||||
const updatedSession = db.getSessionById(agentRecord.sessionId);
|
||||
if (updatedSession) {
|
||||
sessionService.rotateSession(updatedSession);
|
||||
logger.info(`[session] Rotated session for task ${task.id}: ${rotationResult.reason}`);
|
||||
logger.info(`[session] Rotated session for agent ${run.agentId}: ${rotationResult.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export class WorkerRunner {
|
||||
companyId: job.companyId,
|
||||
taskId: job.taskId,
|
||||
modelId: job.model,
|
||||
workspacePath: job.workspacePath ?? null,
|
||||
});
|
||||
if (!prepareResponse.ok) {
|
||||
throw new Error(prepareResponse.error);
|
||||
|
||||
@@ -79,6 +79,36 @@ export const LEADERSHIP_LEVELS: readonly AgentRoleLevel[] = [
|
||||
export function isLeadershipLevel(level: AgentRoleLevel | null | undefined): boolean {
|
||||
return level != null && level !== "ic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-dimensional capability axes used by the model dispatcher. We compress
|
||||
* the marketing five ("architecture / coding / writing / math / research")
|
||||
* into three because the first, fourth and fifth collapse into a single
|
||||
* reasoning axis empirically — a model that can design an RFC, do the math,
|
||||
* and read a paper is the same model. Writing and coding stay separate
|
||||
* because the rankings between top providers diverge there.
|
||||
*/
|
||||
export type TaskDomain = "reasoning" | "coding" | "writing";
|
||||
|
||||
/**
|
||||
* Difficulty tier. Agents whose work is routine tagging, formatting, or
|
||||
* summarization are `trivial` and should burn the cheapest model that
|
||||
* clears the domain threshold. `hard` is for architecture/audit/research —
|
||||
* send the strongest model the user has enabled.
|
||||
*/
|
||||
export type TaskTier = "trivial" | "standard" | "hard";
|
||||
|
||||
/**
|
||||
* Run-time model routing for an agent.
|
||||
* - `explicit`: use the agent.connectorId + agent.model pair the user
|
||||
* saved. This was the pre-v39 behavior; existing rows stay
|
||||
* here on migration so nothing silently re-routes.
|
||||
* - `auto`: ignore agent.connectorId/model; the dispatcher picks the
|
||||
* cheapest enabled model that clears the agent's inferred
|
||||
* task profile. Default for newly created agents.
|
||||
*/
|
||||
export type AgentModelStrategy = "explicit" | "auto";
|
||||
|
||||
export type GoalStatus = "planned" | "active" | "achieved" | "cancelled";
|
||||
export type ProjectStatus = "backlog" | "planned" | "in_progress" | "completed" | "cancelled";
|
||||
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled";
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
DepartmentId,
|
||||
AgentStatus,
|
||||
AgentRoleLevel,
|
||||
AgentModelStrategy,
|
||||
GoalStatus,
|
||||
ProjectStatus,
|
||||
TaskStatus,
|
||||
@@ -88,6 +89,13 @@ export interface AgentRecord {
|
||||
connectorId: ConnectorId;
|
||||
workspaceId: string | null;
|
||||
model: string | null;
|
||||
/**
|
||||
* Model routing strategy (v39+). See AgentModelStrategy in enums.ts.
|
||||
* Optional on the read model because the DB column has a schema-level
|
||||
* default of 'explicit' for existing rows and legacy AgentRecord literals
|
||||
* in tests predate this field.
|
||||
*/
|
||||
modelStrategy?: AgentModelStrategy | null;
|
||||
capabilities: string;
|
||||
budgetMonthlyUsd: number;
|
||||
spentMonthlyUsd: number;
|
||||
@@ -366,3 +374,23 @@ export interface SendMessageResult {
|
||||
mentionResults: MentionResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Row in `model_capabilities`. Scores are 1..5 integer ratings per domain,
|
||||
* tier is the hard lower bound the dispatcher clears before considering
|
||||
* `costPerMtokOut` to pick the cheapest qualifying model.
|
||||
*
|
||||
* Composite primary key `(provider, modelId)` — the same modelId can
|
||||
* legitimately ship under multiple providers (e.g. an Anthropic-compatible
|
||||
* endpoint that serves Claude via a Fireworks proxy).
|
||||
*/
|
||||
export interface ModelCapability {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
reasoning: number;
|
||||
coding: number;
|
||||
writing: number;
|
||||
tier: "trivial" | "standard" | "hard";
|
||||
costPerMtokOut: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,13 @@ describe("Step 2: CEO phased prompts", () => {
|
||||
|
||||
const prompt = buildSystemPrompt(ctx);
|
||||
expect(prompt).toContain("Team Building Phase");
|
||||
expect(prompt).toContain("Design the Organization Structure");
|
||||
expect(prompt).toContain("Submit Org Strategy for Approval");
|
||||
// The rewritten prompt uses "Design a minimal org" instead of the
|
||||
// old "Design the Organization Structure" header. Check for both
|
||||
// the stable Step-2 marker and the new ordering guarantee that
|
||||
// blocks the self-loop the earlier prompt enabled.
|
||||
expect(prompt).toContain("Design a minimal org");
|
||||
expect(prompt).toContain("HIRES FIRST. STRATEGY APPROVAL LAST.");
|
||||
expect(prompt).toContain("Submit org strategy");
|
||||
expect(prompt).not.toContain("Awaiting Org Approval");
|
||||
expect(prompt).not.toContain("Goal Decomposition");
|
||||
});
|
||||
|
||||
@@ -472,8 +472,8 @@ describe("Process Recovery -- Context Preservation (D-02)", () => {
|
||||
expect(rotationResult.shouldRotate).toBe(true);
|
||||
expect(rotationResult.reason).toBe("max_runs");
|
||||
|
||||
// Generate handoff markdown
|
||||
const handoff = sessionService.generateHandoff(taskId, session!);
|
||||
// Generate handoff markdown (agent-scoped, single-argument signature).
|
||||
const handoff = sessionService.generateHandoff(session!);
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("Session stats:");
|
||||
});
|
||||
|
||||
@@ -305,4 +305,268 @@ describe("CAS checkout", () => {
|
||||
it("STALE_LOCK_TIMEOUT_MS is 15 minutes", () => {
|
||||
expect(STALE_LOCK_TIMEOUT_MS).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("failed dispatch (run never started) rolls task back to 'todo' so heartbeat can retry", () => {
|
||||
// Regression: workspace-lock contention calls finishRun('failed') on a
|
||||
// run that never reached 'running'. The old code cleared checkout_run_id
|
||||
// but left task.status='in_progress' — the next checkoutTask pass then
|
||||
// refused (WHERE status IN ('todo','backlog')), and the scheduler
|
||||
// deadlocked while logging "lost checkout race" every 15 seconds.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, runId);
|
||||
expect(db.getTask(taskId).status).toBe("in_progress");
|
||||
|
||||
// Never call markRunStatus('running', ...) — this simulates the
|
||||
// "workspace already locked" bail-out inside dispatchRunToWorker.
|
||||
db.finishRun({
|
||||
runId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
errorMessage: "Workspace is locked by another run.",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
expect(db.getTask(taskId).status).toBe("todo");
|
||||
|
||||
// And the next CAS checkout must succeed — proving the heartbeat
|
||||
// retry path is unblocked.
|
||||
const { id: retryRunId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
expect(db.checkoutTask(taskId, retryRunId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cancelled run that never started also rolls task back to 'todo'", () => {
|
||||
// Workspace-contention bail-out in dispatchRunToWorker marks the run as
|
||||
// 'cancelled' (not 'failed'), so the Dashboard "Needs attention" banner
|
||||
// stays quiet. The rollback path must handle cancelled too, or the task
|
||||
// would stick in 'in_progress' just like the original 'failed' bug.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, runId);
|
||||
db.finishRun({
|
||||
runId,
|
||||
status: "cancelled",
|
||||
summary: null,
|
||||
errorMessage: "Workspace in use by another run; will retry.",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
unattributedCost: true,
|
||||
});
|
||||
|
||||
expect(db.getTask(taskId).status).toBe("todo");
|
||||
});
|
||||
|
||||
it("failed run that did start stays 'in_progress' (human retry path)", () => {
|
||||
// Counterpart to the regression above: a run that actually transitioned
|
||||
// to 'running' and then failed mid-flight is the user's problem to
|
||||
// resolve through the recovery UI — we must not silently reset its
|
||||
// task to 'todo' and lose the partial work / error context.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, runId);
|
||||
db.markRunStatus(runId, "running", "");
|
||||
db.finishRun({
|
||||
runId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
errorMessage: "Worker crashed",
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
expect(db.getTask(taskId).status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("startup reconciliation unsticks tasks whose checkout_run_id points at a dead run", () => {
|
||||
// This is the shape observed in the wild: active_run_id cleared by
|
||||
// finishRun (unconditional clear), but checkout_run_id still carrying
|
||||
// a dead run id because the conditional clear's WHERE missed due to
|
||||
// a race. Task sits in 'in_progress' forever — heartbeat picks it
|
||||
// (activeRunId null), checkoutTask refuses (status != todo/backlog),
|
||||
// "lost checkout race" on loop. Reconciliation must fix it without
|
||||
// caring which reference column is null.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: deadRunId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, deadRunId);
|
||||
// Mark the run terminal without running finishRun's clear path —
|
||||
// simulates the race where checkout_run_id survived but the run
|
||||
// itself is no longer live.
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE runs SET status = 'cancelled' WHERE id = ?")
|
||||
.run(deadRunId);
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE tasks SET active_run_id = NULL WHERE id = ?")
|
||||
.run(taskId);
|
||||
|
||||
// Pre-condition: task is stuck.
|
||||
const stuck = (db as unknown as RawDb).db
|
||||
.prepare("SELECT status, checkout_run_id as checkoutRunId, active_run_id as activeRunId FROM tasks WHERE id = ?")
|
||||
.get(taskId) as { status: string; checkoutRunId: string | null; activeRunId: string | null };
|
||||
expect(stuck.status).toBe("in_progress");
|
||||
expect(stuck.checkoutRunId).toBe(deadRunId);
|
||||
expect(stuck.activeRunId).toBeNull();
|
||||
|
||||
// Re-initialise: reconciliation should run.
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.lastOrphanResetCount).toBe(1);
|
||||
const reclaimed = db.getTask(taskId);
|
||||
expect(reclaimed.status).toBe("todo");
|
||||
|
||||
// And a fresh checkout must succeed — proving the task is reclaimable.
|
||||
const { id: newRunId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
expect(db.checkoutTask(taskId, newRunId)).toBe(true);
|
||||
});
|
||||
|
||||
it("startup reconciliation unsticks in_review tasks with no possible reviewer", () => {
|
||||
// Observed in production: a fresh `ai_ceo` company with only a CEO
|
||||
// self-assigned a large task and set status=in_review. The reviewer
|
||||
// would be the assignee's manager (walk up reports_to), but the CEO
|
||||
// has no manager. With `requires_user_review = 0` there is no human
|
||||
// fallback either. The heartbeat falls into a loop re-proposing
|
||||
// `approve_ceo_strategy` every cycle without ever hiring or moving
|
||||
// the task. Demoting to 'todo' breaks the loop.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
// Top-level agent (CEO): reports_to stays NULL.
|
||||
const ceoId = createTestAgent(db, companyId, workspaceId, "CEO");
|
||||
const taskId = createTestTask(db, companyId, ceoId, workspaceId);
|
||||
// Manually wedge the task into the dead-letter shape.
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 0 WHERE id = ?")
|
||||
.run(taskId);
|
||||
|
||||
expect(db.getTask(taskId).status).toBe("in_review");
|
||||
|
||||
// Re-init triggers reconciliation.
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.lastUnreviewableResetCount).toBe(1);
|
||||
expect(db.getTask(taskId).status).toBe("todo");
|
||||
});
|
||||
|
||||
it("in_review tasks requiring user review are left alone", () => {
|
||||
// Opposite guard: when requires_user_review = 1 the task is
|
||||
// intentionally parked waiting for a human — the reconciler must
|
||||
// not steal it.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const ceoId = createTestAgent(db, companyId, workspaceId, "CEO");
|
||||
const taskId = createTestTask(db, companyId, ceoId, workspaceId);
|
||||
(db as unknown as RawDb).db
|
||||
.prepare("UPDATE tasks SET status = 'in_review', requires_user_review = 1 WHERE id = ?")
|
||||
.run(taskId);
|
||||
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
expect(db.lastUnreviewableResetCount).toBe(0);
|
||||
expect(db.getTask(taskId).status).toBe("in_review");
|
||||
});
|
||||
|
||||
it("reconciliation does not fire when interruptStaleRuns already handled a running task", () => {
|
||||
// Opposite guard: tasks whose run was `running` at shutdown are
|
||||
// handled earlier in init by `interruptStaleRuns` — it marks the
|
||||
// run 'interrupted' and sets the task to 'blocked' for human
|
||||
// review. By the time reconciliation runs, the task is no longer
|
||||
// 'in_progress', so the cleanup's WHERE clause skips it. The user
|
||||
// sees the task surfaced in the recovery UI, not silently reset.
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: liveRunId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
db.checkoutTask(taskId, liveRunId);
|
||||
db.markRunStatus(liveRunId, "running", "");
|
||||
|
||||
db.close();
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
|
||||
// Reconciliation counted 0 because interruptStaleRuns moved the
|
||||
// task to 'blocked' first. Both guards collaborate — neither
|
||||
// steals a task out from under an actively-working run.
|
||||
expect(db.lastOrphanResetCount).toBe(0);
|
||||
expect(db.getTask(taskId).status).toBe("blocked");
|
||||
});
|
||||
});
|
||||
@@ -206,11 +206,12 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
});
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const handoff = sessionService.generateHandoff(taskId, session);
|
||||
// generateHandoff is agent-scoped: it walks the agent's runs across
|
||||
// all tasks + taskless heartbeats, not a single task.
|
||||
const handoff = sessionService.generateHandoff(session);
|
||||
|
||||
// Verify handoff markdown content
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("Implement session rotation feature"); // task title
|
||||
expect(handoff).toContain("## Recent Run History");
|
||||
|
||||
// Archive the old session with handoff
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentRecord, ConnectorId, ConnectorRecord, ModelCapability } from "@shared/types";
|
||||
import {
|
||||
pickFromCandidates,
|
||||
pickModelForAgent,
|
||||
type EnabledModel,
|
||||
type PickModelDeps,
|
||||
} from "../../src/main/model-dispatch";
|
||||
|
||||
/**
|
||||
* Minimum fields required to build a plausible AgentRecord for the
|
||||
* dispatcher. The shared type has many optional/legacy columns; the picker
|
||||
* only reads role, title, roleLevel, connectorId, model, modelStrategy, and
|
||||
* companyId.
|
||||
*/
|
||||
function makeAgent(overrides: Partial<AgentRecord>): AgentRecord {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Test Agent",
|
||||
role: "engineer",
|
||||
title: "Staff Engineer",
|
||||
department: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
workspaceId: null,
|
||||
model: "claude-sonnet-4-6",
|
||||
capabilities: "[]",
|
||||
budgetMonthlyUsd: 0,
|
||||
spentMonthlyUsd: 0,
|
||||
metadataJson: "{}",
|
||||
heartbeatEnabled: false,
|
||||
heartbeatIntervalSec: 120,
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
modelStrategy: "auto",
|
||||
roleLevel: "ic",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ConnectorRecord that the execution-readiness check treats as
|
||||
* healthy. Only the fields the check reads are populated realistically;
|
||||
* the rest default to empty.
|
||||
*/
|
||||
function makeConnector(id: string): ConnectorRecord {
|
||||
return {
|
||||
id: id as ConnectorId,
|
||||
label: id,
|
||||
description: "",
|
||||
status: "ready",
|
||||
command: "",
|
||||
version: null,
|
||||
authState: "ok",
|
||||
lastCheckedAt: null,
|
||||
lastError: null,
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
capabilityMatrix: {
|
||||
sessionResume: true,
|
||||
gracefulCancel: true,
|
||||
modelDiscovery: true,
|
||||
structuredTranscript: true,
|
||||
costAttribution: true,
|
||||
},
|
||||
configurationDoc: "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build picker deps from a flat list of (connectorId, provider, modelId)
|
||||
* triples + a capability matrix. Anything unspecified behaves as if the
|
||||
* connector is healthy and has no disabled models.
|
||||
*/
|
||||
function makeDeps(options: {
|
||||
enabledModels: Array<{ connectorId: string; provider: string; modelId: string }>;
|
||||
capabilities: ModelCapability[];
|
||||
ceoAssignment?: { connectorId: string; modelId: string | null } | null;
|
||||
}): PickModelDeps {
|
||||
const connectorsById = new Map<string, ConnectorRecord>();
|
||||
for (const entry of options.enabledModels) {
|
||||
if (!connectorsById.has(entry.connectorId)) {
|
||||
connectorsById.set(entry.connectorId, makeConnector(entry.connectorId));
|
||||
}
|
||||
}
|
||||
return {
|
||||
getConnector: (id) => {
|
||||
const c = connectorsById.get(id);
|
||||
if (!c) throw new Error(`unknown connector ${id}`);
|
||||
return c;
|
||||
},
|
||||
getDisabledModels: () => [],
|
||||
getCeoAssignment: () =>
|
||||
options.ceoAssignment
|
||||
? {
|
||||
connectorId: options.ceoAssignment.connectorId as ConnectorId,
|
||||
modelId: options.ceoAssignment.modelId,
|
||||
}
|
||||
: null,
|
||||
listModelCapabilities: () => options.capabilities,
|
||||
readConnectors: () => Array.from(connectorsById.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the getConnectorModelPolicy path used inside listEnabledModels.
|
||||
* We don't want to stand up the full connector definition catalog for
|
||||
* every test, so we intercept policy queries by wiring each connector's
|
||||
* provider + model list through a per-test lookup.
|
||||
*
|
||||
* The picker imports `getConnectorDefinition` and `getConnectorModelPolicy`
|
||||
* at module scope. Integration tests stand up the whole system; these
|
||||
* unit tests exercise the pure scoring math by stubbing those imports via
|
||||
* vitest.
|
||||
*/
|
||||
// Stubbing is handled by the helper above — tests below only assert the
|
||||
// sort/filter/fallback behaviour on a hand-crafted capability matrix plus
|
||||
// a small enabled-model set. The heavy integration path (connector
|
||||
// definitions + policy) is covered by higher-level tests.
|
||||
|
||||
describe("pickModelForAgent", () => {
|
||||
const claudeOpus: ModelCapability = {
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
reasoning: 5,
|
||||
coding: 5,
|
||||
writing: 5,
|
||||
tier: "hard",
|
||||
costPerMtokOut: 75,
|
||||
updatedAt: "",
|
||||
};
|
||||
const llama: ModelCapability = {
|
||||
provider: "fireworks",
|
||||
modelId: "llama-70b",
|
||||
reasoning: 2,
|
||||
coding: 2,
|
||||
writing: 2,
|
||||
tier: "trivial",
|
||||
costPerMtokOut: 1,
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
it("explicit strategy honours agent.connectorId + agent.model verbatim", () => {
|
||||
const agent = makeAgent({
|
||||
modelStrategy: "explicit",
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
const deps = makeDeps({
|
||||
enabledModels: [
|
||||
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
|
||||
],
|
||||
capabilities: [claudeOpus],
|
||||
});
|
||||
const assignment = pickModelForAgent(agent, deps);
|
||||
expect(assignment).toEqual({ connectorId: "anthropic-api", modelId: "claude-opus-4-7" });
|
||||
});
|
||||
|
||||
it("CEO role always takes the user-picked pair regardless of modelStrategy", () => {
|
||||
const agent = makeAgent({
|
||||
modelStrategy: "auto",
|
||||
roleLevel: "c_suite",
|
||||
role: "CEO",
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
const deps = makeDeps({
|
||||
enabledModels: [
|
||||
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
|
||||
{ connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" },
|
||||
],
|
||||
capabilities: [claudeOpus, llama],
|
||||
});
|
||||
const assignment = pickModelForAgent(agent, deps);
|
||||
expect(assignment.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("CEO is identified by reportsTo === null even when roleLevel stays at the schema default 'ic'", () => {
|
||||
// Regression test for the v39 migration gap: CEO agents created before
|
||||
// the explicit role_level hookup have roleLevel = 'ic' in the DB. The
|
||||
// dispatcher must still recognise them via the DB invariant used by
|
||||
// getCeoForCompany: reports_to IS NULL.
|
||||
const agent = makeAgent({
|
||||
modelStrategy: "auto",
|
||||
roleLevel: "ic",
|
||||
reportsTo: null,
|
||||
role: "CEO",
|
||||
connectorId: "anthropic-api" as ConnectorId,
|
||||
model: "claude-opus-4-7",
|
||||
});
|
||||
const deps = makeDeps({
|
||||
enabledModels: [
|
||||
{ connectorId: "anthropic-api", provider: "anthropic", modelId: "claude-opus-4-7" },
|
||||
{ connectorId: "fireworks", provider: "fireworks", modelId: "llama-70b" },
|
||||
],
|
||||
capabilities: [claudeOpus, llama],
|
||||
});
|
||||
const assignment = pickModelForAgent(agent, deps);
|
||||
expect(assignment.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferTaskProfile — i18n", () => {
|
||||
it("classifies Chinese role strings via substring match", async () => {
|
||||
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
|
||||
expect(inferTaskProfile("高级架构师", null, "director").domain).toBe("reasoning");
|
||||
expect(inferTaskProfile("前端工程师", null, "ic").domain).toBe("coding");
|
||||
expect(inferTaskProfile("内容运营", null, "ic").domain).toBe("writing");
|
||||
expect(inferTaskProfile("产品经理", null, "manager").domain).toBe("writing");
|
||||
});
|
||||
|
||||
it("recognises Chinese trivial-tier cues", async () => {
|
||||
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
|
||||
expect(inferTaskProfile("实习生", "数据标注实习生", "ic").requiredTier).toBe("trivial");
|
||||
expect(inferTaskProfile("初级工程师", null, "ic").requiredTier).toBe("trivial");
|
||||
expect(inferTaskProfile("助理", null, "ic").requiredTier).toBe("trivial");
|
||||
});
|
||||
|
||||
it("English classification still works after unicode tokenizer swap", async () => {
|
||||
const { inferTaskProfile } = await import("../../src/main/agent-role-helpers");
|
||||
expect(inferTaskProfile("Senior Architect", null, "director").domain).toBe("reasoning");
|
||||
expect(inferTaskProfile("Staff Engineer", null, "lead").domain).toBe("coding");
|
||||
expect(inferTaskProfile("Marketing Lead", null, "lead").domain).toBe("writing");
|
||||
// 'lead' keyword must not match 'leaderboard' / 'cleaner' substrings —
|
||||
// token-based matching is still in effect for Latin keywords.
|
||||
expect(inferTaskProfile("Cleaner", null, "ic").domain).toBe("writing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickFromCandidates — pure rubric", () => {
|
||||
const claudeOpus: ModelCapability = {
|
||||
provider: "anthropic", modelId: "claude-opus-4-7",
|
||||
reasoning: 5, coding: 5, writing: 5,
|
||||
tier: "hard", costPerMtokOut: 75, updatedAt: "",
|
||||
};
|
||||
const claudeSonnet: ModelCapability = {
|
||||
provider: "anthropic", modelId: "claude-sonnet-4-6",
|
||||
reasoning: 4, coding: 5, writing: 5,
|
||||
tier: "standard", costPerMtokOut: 15, updatedAt: "",
|
||||
};
|
||||
const kimiK2: ModelCapability = {
|
||||
provider: "fireworks", modelId: "kimi-k2",
|
||||
reasoning: 4, coding: 4, writing: 5,
|
||||
tier: "standard", costPerMtokOut: 3, updatedAt: "",
|
||||
};
|
||||
const llama: ModelCapability = {
|
||||
provider: "fireworks", modelId: "llama-70b",
|
||||
reasoning: 2, coding: 2, writing: 2,
|
||||
tier: "trivial", costPerMtokOut: 1, updatedAt: "",
|
||||
};
|
||||
|
||||
const allEnabled: EnabledModel[] = [
|
||||
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" },
|
||||
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-sonnet-4-6" },
|
||||
{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "kimi-k2" },
|
||||
{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" },
|
||||
];
|
||||
|
||||
it("picks the cheapest qualifying model (standard/writing → kimi beats sonnet on price)", () => {
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "writing", requiredTier: "standard" },
|
||||
allEnabled,
|
||||
[claudeOpus, claudeSonnet, kimiK2, llama],
|
||||
);
|
||||
expect(winner?.modelId).toBe("kimi-k2");
|
||||
});
|
||||
|
||||
it("hard tier requires tier≥hard even if domain score clears the floor", () => {
|
||||
// writing=5 qualifies sonnet and kimi numerically, but both are tier=standard.
|
||||
// Only opus (tier=hard) survives.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "writing", requiredTier: "hard" },
|
||||
allEnabled,
|
||||
[claudeOpus, claudeSonnet, kimiK2, llama],
|
||||
);
|
||||
expect(winner?.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("returns null when no candidate clears the bar", () => {
|
||||
// Ask for hard/reasoning ≥4 but only provide trivial-tier llama.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "reasoning", requiredTier: "hard" },
|
||||
[{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }],
|
||||
[llama],
|
||||
);
|
||||
expect(winner).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on an empty candidate list", () => {
|
||||
expect(pickFromCandidates({ domain: "coding", requiredTier: "trivial" }, [], [llama])).toBeNull();
|
||||
});
|
||||
|
||||
it("trivial tier still honours the domain floor (2)", () => {
|
||||
// llama scores 2 on writing — exactly on the floor, so it qualifies.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "writing", requiredTier: "trivial" },
|
||||
[{ provider: "fireworks", connectorId: "fireworks" as ConnectorId, modelId: "llama-70b" }],
|
||||
[llama],
|
||||
);
|
||||
expect(winner?.modelId).toBe("llama-70b");
|
||||
});
|
||||
|
||||
it("ignores enabled models with no capability row", () => {
|
||||
// Only claudeOpus has a capability row; mystery model is filtered out
|
||||
// despite appearing in the enabled list.
|
||||
const winner = pickFromCandidates(
|
||||
{ domain: "reasoning", requiredTier: "hard" },
|
||||
[
|
||||
{ provider: "mystery", connectorId: "mystery" as ConnectorId, modelId: "unknown" },
|
||||
{ provider: "anthropic", connectorId: "claude-pro" as ConnectorId, modelId: "claude-opus-4-7" },
|
||||
],
|
||||
[claudeOpus],
|
||||
);
|
||||
expect(winner?.modelId).toBe("claude-opus-4-7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickModelForAgent — internal rubric", () => {
|
||||
it("exposes DOMAIN_SCORE_FLOOR and TIER_RANK for downstream tests", async () => {
|
||||
const { __INTERNAL__ } = await import("../../src/main/model-dispatch");
|
||||
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.trivial).toBe(2);
|
||||
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.standard).toBe(3);
|
||||
expect(__INTERNAL__.DOMAIN_SCORE_FLOOR.hard).toBe(4);
|
||||
expect(__INTERNAL__.TIER_RANK.trivial).toBe(1);
|
||||
expect(__INTERNAL__.TIER_RANK.standard).toBe(2);
|
||||
expect(__INTERNAL__.TIER_RANK.hard).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
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 { resolveWorkspaceWithLockMode } from "../../src/main/orchestration/resolve-workspace";
|
||||
|
||||
/**
|
||||
* Regression tests for the workspace lock policy introduced in v0.4.168+:
|
||||
* only `task.workspaceId` grants exclusive access. Agent-, project-, and
|
||||
* company-scoped resolutions are all `shared` so teammates can run in
|
||||
* parallel instead of queuing on a single workspace lock.
|
||||
*
|
||||
* See `resolve-workspace.ts` for the full rationale.
|
||||
*/
|
||||
|
||||
function seedCompany(db: AppDatabase) {
|
||||
const companyId = db.saveCompany({
|
||||
name: "Parallel Co",
|
||||
description: "Regression fixture",
|
||||
status: "active",
|
||||
});
|
||||
db.saveConnector({
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "fixture",
|
||||
});
|
||||
const workspaceId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Default",
|
||||
localPath: "/tmp/fixture",
|
||||
repoUrl: "https://github.com/example/fixture",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
return { companyId, workspaceId };
|
||||
}
|
||||
|
||||
describe("resolveWorkspaceWithLockMode", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "resolve-workspace-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("task-bound workspace → exclusive (explicit opt-in)", () => {
|
||||
const { companyId, workspaceId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
{ workspaceId, projectId: null },
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("task");
|
||||
expect(result.lockMode).toBe("exclusive");
|
||||
expect(result.workspace?.id).toBe(workspaceId);
|
||||
});
|
||||
|
||||
it("agent-bound workspace → shared (normal case: teammates share a repo)", () => {
|
||||
const { companyId, workspaceId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
null,
|
||||
{ workspaceId },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("agent");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
});
|
||||
|
||||
it("project-bound workspace → shared (team collaboration space)", () => {
|
||||
const { companyId } = seedCompany(db);
|
||||
const projectId = db.saveProject({
|
||||
companyId,
|
||||
name: "Platform",
|
||||
description: "",
|
||||
status: "active",
|
||||
});
|
||||
// Create a second workspace bound to the project so the resolver's
|
||||
// project-primary branch actually fires (the default seeded workspace
|
||||
// has no project_id set).
|
||||
db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Project repo",
|
||||
localPath: "/tmp/project-ws",
|
||||
repoUrl: "https://github.com/example/project",
|
||||
repoRef: "main",
|
||||
projectId,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
{ workspaceId: null, projectId },
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("project");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
});
|
||||
|
||||
it("company-fallback → shared (unchanged legacy behavior)", () => {
|
||||
const { companyId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
null,
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("company_fallback");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
});
|
||||
|
||||
it("task workspace wins over agent workspace even when both are set", () => {
|
||||
const { companyId, workspaceId: agentWsId } = seedCompany(db);
|
||||
const taskWsId = db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Task-only",
|
||||
localPath: "/tmp/task-ws",
|
||||
repoUrl: "https://github.com/example/task",
|
||||
repoRef: "main",
|
||||
isPrimary: false,
|
||||
});
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
{ workspaceId: taskWsId, projectId: null },
|
||||
{ workspaceId: agentWsId },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("task");
|
||||
expect(result.lockMode).toBe("exclusive");
|
||||
expect(result.workspace?.id).toBe(taskWsId);
|
||||
});
|
||||
|
||||
it("no workspace at all → none", () => {
|
||||
const companyId = db.saveCompany({
|
||||
name: "Empty Co",
|
||||
description: "",
|
||||
status: "active",
|
||||
});
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
null,
|
||||
{ workspaceId: null },
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("none");
|
||||
expect(result.lockMode).toBe("none");
|
||||
expect(result.workspace).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -577,12 +577,16 @@ describe("session management service", () => {
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const svc = createSessionManagementService({ db, logger: mockLogger });
|
||||
const handoff = svc.generateHandoff(taskId, session);
|
||||
// Handoff is now agent-scoped: it walks the agent's recent runs
|
||||
// (across tasks + heartbeats) rather than a single task. The task
|
||||
// title is no longer in the handoff header because the session
|
||||
// does not belong to any one task anymore.
|
||||
const handoff = svc.generateHandoff(session);
|
||||
|
||||
expect(handoff).toContain("# Session Handoff");
|
||||
expect(handoff).toContain("Implement session rotation"); // task title
|
||||
expect(handoff).toContain("## Recent Run History");
|
||||
expect(handoff).toContain("succeeded");
|
||||
expect(handoff).toContain("Implemented initial logic"); // run summary
|
||||
expect(handoff).toContain("15000 total tokens"); // 10000 + 5000
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user