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:
ZacharyZhang-NY committed 2026-04-18 21:37:56 -04:00
1 parent 61fb4b947f
commit ff5e22092a
45 files changed
+3409 -300

No files matched your search

+2
View File
@@ -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"
},
+857
View File
File diff suppressed because it is too large. Load diff
+172 -2
View File
@@ -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 };
}
+57 -111
View File
@@ -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();
}
+93 -2
View File
@@ -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();
+2 -1
View File
@@ -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); }
+22 -4
View File
@@ -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,
+5 -5
View File
@@ -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,
+26 -3
View File
@@ -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,
+8 -2
View File
@@ -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,
+75
View File
@@ -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");
},
},
];
}
+89
View File
@@ -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,
};
}
+38 -2
View File
@@ -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) {
+4
View File
@@ -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) {
+1 -1
View File
@@ -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>(
+23
View File
@@ -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
View File
@@ -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}`);
}
}
}
+156
View File
@@ -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,
},
];
+209
View File
@@ -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,
};
+4
View File
@@ -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);
+17 -2
View File
@@ -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);
+28 -6
View File
@@ -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);
+9 -2
View File
@@ -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,
+17 -2
View File
@@ -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,
+44 -36
View File
@@ -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);
}
+8 -2
View File
@@ -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.
+15 -13
View File
@@ -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 };
}
},
});
+56 -31
View File
@@ -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}`);
}
}
}
+1
View File
@@ -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);
+30
View File
@@ -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";
+28
View File
@@ -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");
});
+2 -2
View File
@@ -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:");
});
+264
View File
@@ -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");
});
});
+3 -2
View File
@@ -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
+332
View File
@@ -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);
});
});
+171
View File
@@ -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();
});
});
+6 -2
View File
@@ -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
});
});