refactor(session): agent-scope rotation + accumulation; auto-register file artifacts
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
This commit is contained in:
1 parent
61fb4b947f
commit
ff5e22092a
45 files changed
+3409
-300
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import log from "electron-log/main.js";
|
||||
import type { AgentRecord, AgentRoleLevel, ConnectorId } from "@shared/types";
|
||||
import type { AgentModelStrategy, AgentRecord, AgentRoleLevel, ConnectorId } from "@shared/types";
|
||||
import {
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
@@ -48,6 +48,13 @@ export function buildAgentCrudMethods(
|
||||
budgetMonthlyUsd: number;
|
||||
/** Optional explicit seniority; inferred from role/title when omitted. */
|
||||
roleLevel?: AgentRoleLevel;
|
||||
/**
|
||||
* Model routing strategy (v39+). `explicit` pins the agent to
|
||||
* `connectorId + model`; `auto` lets the dispatcher pick at run
|
||||
* time. Omitting this leaves the existing column untouched on
|
||||
* update, and uses the DB default ('explicit') on insert.
|
||||
*/
|
||||
modelStrategy?: AgentModelStrategy;
|
||||
}): string {
|
||||
const id = ensureId(input.id ?? null);
|
||||
const ts = nowIso();
|
||||
@@ -64,12 +71,17 @@ export function buildAgentCrudMethods(
|
||||
// Snapshot before-config for revision tracking
|
||||
const beforeAgent = getDb()
|
||||
.prepare(
|
||||
"SELECT name, role, title, department, capabilities, connector_id, model, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?",
|
||||
"SELECT name, role, title, department, capabilities, connector_id, model, model_strategy, role_level, budget_monthly_usd, metadata_json FROM agents WHERE id = ? AND company_id = ?",
|
||||
)
|
||||
.get(id, input.companyId) as Record<string, unknown> | undefined;
|
||||
// Preserve existing modelStrategy and roleLevel when the caller omits
|
||||
// them, so non-related updates (e.g. updateAgentWorkspaceBinding) do
|
||||
// not accidentally flip routing or seniority.
|
||||
const effectiveStrategy = input.modelStrategy ?? (beforeAgent?.model_strategy as string | undefined) ?? "explicit";
|
||||
const effectiveRoleLevel = input.roleLevel ?? (beforeAgent?.role_level as string | undefined) ?? "ic";
|
||||
getDb()
|
||||
.prepare(
|
||||
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
"update agents set name = ?, role = ?, title = ?, department = ?, status = ?, reports_to = ?, connector_id = ?, workspace_id = ?, model = ?, model_strategy = ?, role_level = ?, metadata_json = ?, capabilities = ?, budget_monthly_usd = ?, updated_at = ? where id = ? and company_id = ?",
|
||||
)
|
||||
.run(
|
||||
input.name,
|
||||
@@ -81,6 +93,8 @@ export function buildAgentCrudMethods(
|
||||
input.connectorId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
normalizeOptionalId(input.model),
|
||||
effectiveStrategy,
|
||||
effectiveRoleLevel,
|
||||
metadataJson,
|
||||
input.capabilities,
|
||||
input.budgetMonthlyUsd,
|
||||
@@ -132,9 +146,11 @@ export function buildAgentCrudMethods(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const insertStrategy = input.modelStrategy ?? "explicit";
|
||||
const insertRoleLevel = input.roleLevel ?? "ic";
|
||||
getDb()
|
||||
.prepare(
|
||||
"insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
||||
"insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, model_strategy, role_level, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -148,6 +164,8 @@ export function buildAgentCrudMethods(
|
||||
input.connectorId,
|
||||
normalizeOptionalId(input.workspaceId),
|
||||
normalizeOptionalId(input.model),
|
||||
insertStrategy,
|
||||
insertRoleLevel,
|
||||
input.capabilities,
|
||||
input.budgetMonthlyUsd,
|
||||
metadataJson,
|
||||
|
||||
@@ -62,7 +62,7 @@ export function buildAgentHierarchyMethods(
|
||||
`WITH RECURSIVE chain AS (
|
||||
SELECT id, company_id as companyId, name, role, title, department, status,
|
||||
reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId,
|
||||
model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd,
|
||||
model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd,
|
||||
spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson,
|
||||
heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec,
|
||||
last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt,
|
||||
@@ -71,7 +71,7 @@ export function buildAgentHierarchyMethods(
|
||||
UNION ALL
|
||||
SELECT a.id, a.company_id, a.name, a.role, a.title, a.department, a.status,
|
||||
a.reports_to, a.connector_id, a.workspace_id,
|
||||
a.model, a.capabilities, a.role_level, a.budget_monthly_usd,
|
||||
a.model, a.capabilities, a.role_level, a.model_strategy, a.budget_monthly_usd,
|
||||
a.spent_monthly_usd, a.metadata_json,
|
||||
a.heartbeat_enabled, a.heartbeat_interval_sec,
|
||||
a.last_heartbeat_at, a.session_id, a.created_at, a.updated_at,
|
||||
@@ -91,7 +91,7 @@ export function buildAgentHierarchyMethods(
|
||||
|
||||
getDirectReports(agentId: string, companyId: string): AgentRecord[] {
|
||||
return readAll<AgentRecord>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and reports_to = ?",
|
||||
companyId,
|
||||
agentId,
|
||||
).map((record) => ({
|
||||
@@ -121,7 +121,7 @@ export function buildAgentHierarchyMethods(
|
||||
*/
|
||||
listAgentsForApi(companyId: string): AgentApiRow[] {
|
||||
return readAll<AgentApiRow & { heartbeatEnabled: number | boolean }>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? order by created_at asc",
|
||||
companyId,
|
||||
).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) })) as AgentApiRow[];
|
||||
},
|
||||
@@ -200,7 +200,7 @@ export function buildAgentHierarchyMethods(
|
||||
|
||||
listHeartbeatEligibleAgents(companyId: string): AgentRecord[] {
|
||||
return readAll<AgentRecord>(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')",
|
||||
companyId,
|
||||
).map((record) => ({
|
||||
...record,
|
||||
|
||||
@@ -185,7 +185,7 @@ export function buildDocumentMethods(
|
||||
// ── Work Products ──
|
||||
|
||||
createWorkProduct(input: {
|
||||
companyId: string; taskId: string; projectId?: string | null;
|
||||
companyId: string; taskId: string | null; projectId?: string | null;
|
||||
type: string; provider: string; externalId?: string | null;
|
||||
title: string; url?: string | null; status?: string; reviewState?: string;
|
||||
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
|
||||
@@ -193,7 +193,7 @@ export function buildDocumentMethods(
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
if (input.isPrimary) {
|
||||
if (input.isPrimary && input.taskId) {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?",
|
||||
).run(ts, input.companyId, input.taskId, input.type);
|
||||
@@ -202,7 +202,7 @@ export function buildDocumentMethods(
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.companyId, input.projectId ?? null, input.taskId,
|
||||
id, input.companyId, input.projectId ?? null, input.taskId ?? null,
|
||||
input.type, input.provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, input.status ?? "active",
|
||||
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
|
||||
@@ -212,6 +212,29 @@ export function buildDocumentMethods(
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Look up a work product by its natural identity — the tuple
|
||||
* (companyId, provider, externalId) uniquely names a registered
|
||||
* artifact. Returns null when not found. Used by the artifact
|
||||
* projector to make tool-output registration idempotent: the first
|
||||
* write inserts, subsequent writes (same path, same provider) go
|
||||
* through `updateWorkProduct` instead of spamming duplicates.
|
||||
*/
|
||||
findWorkProductByExternalId(
|
||||
companyId: string,
|
||||
provider: string,
|
||||
externalId: string,
|
||||
): { id: string; taskId: string | null } | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT id, task_id as taskId FROM work_products WHERE company_id = ? AND provider = ? AND external_id = ? LIMIT 1",
|
||||
)
|
||||
.get(companyId, provider, externalId) as
|
||||
| { id: string; taskId: string | null }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
updateWorkProduct(
|
||||
id: string,
|
||||
companyId: string,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildEntityCrudMethods(
|
||||
getAgent(id: string): AgentRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) throw new Error(`Agent not found: ${id}`);
|
||||
@@ -74,7 +74,7 @@ export function buildEntityCrudMethods(
|
||||
findAgent(id: string): AgentRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) return null;
|
||||
@@ -251,6 +251,7 @@ export function buildEntityCrudMethods(
|
||||
reportsTo: string | null; connectorId: ConnectorId;
|
||||
workspaceId: string | null; model: string | null;
|
||||
metadataJson: string; capabilities: string; budgetMonthlyUsd: number;
|
||||
modelStrategy?: "explicit" | "auto";
|
||||
}) => string;
|
||||
requestApproval: (input: {
|
||||
companyId: string; requestedByAgentId: string | null;
|
||||
@@ -267,6 +268,11 @@ export function buildEntityCrudMethods(
|
||||
connectorId: input.connectorId, workspaceId: input.workspaceId ?? null,
|
||||
model: input.model ?? null, metadataJson: input.metadataJson ?? "{}",
|
||||
capabilities: input.capabilities, budgetMonthlyUsd: input.budgetMonthlyUsd,
|
||||
// Agents hired by the CEO (or any other agent) default to `auto`:
|
||||
// the dispatcher will pick the cheapest enabled model that
|
||||
// clears their inferred task profile. User-driven form saves
|
||||
// flip this to 'explicit' at the IPC handler.
|
||||
modelStrategy: "auto",
|
||||
});
|
||||
const approvalId = selfDeps.requestApproval({
|
||||
companyId: input.companyId,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { columnExists, tableExists } from "./types";
|
||||
|
||||
export function createMigrationsV38V39(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
// Model capability table. Maps (provider, model_id) to 1..5 scores across
|
||||
// three domains (reasoning / coding / writing), a tier enum used as a
|
||||
// hard lower bound for task-profile matching, and a cost-per-Mtok-output
|
||||
// figure used to pick the cheapest qualifying model.
|
||||
//
|
||||
// Scores, tier, and cost are data — editable at runtime via IPC. Seed
|
||||
// rows are inserted by the main process on startup when the table is
|
||||
// empty so a fresh install boots with a sensible matrix covering the
|
||||
// providers in the pi-ai catalog and the curated Fireworks list.
|
||||
version: 38,
|
||||
name: "create_model_capabilities",
|
||||
fn: () => {
|
||||
if (tableExists(db, "model_capabilities")) {
|
||||
log.info("[migration v38] model_capabilities already exists; skipping");
|
||||
return;
|
||||
}
|
||||
db.exec(
|
||||
`CREATE TABLE model_capabilities (
|
||||
provider TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
reasoning INTEGER NOT NULL,
|
||||
coding INTEGER NOT NULL,
|
||||
writing INTEGER NOT NULL,
|
||||
tier TEXT NOT NULL,
|
||||
cost_per_mtok_out REAL NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (provider, model_id)
|
||||
)`,
|
||||
);
|
||||
db.exec("CREATE INDEX idx_model_capabilities_tier ON model_capabilities(tier)");
|
||||
log.info("[migration v38] created model_capabilities table");
|
||||
},
|
||||
},
|
||||
{
|
||||
// agents.model_strategy drives run-time model dispatch.
|
||||
//
|
||||
// 'explicit' — use agent.connector_id and agent.model verbatim
|
||||
// (the pre-v39 behavior — kept for existing rows so there
|
||||
// are no regressions on upgrade).
|
||||
// 'auto' — ignore agent.connector_id/model; at run dispatch time
|
||||
// pick the cheapest model whose capability vector meets
|
||||
// the agent's inferred task profile.
|
||||
//
|
||||
// New agents default to 'auto' at the application layer. Existing rows
|
||||
// on upgrade are forced to 'explicit' because the user previously picked
|
||||
// their model manually and we must not silently re-route them.
|
||||
version: 39,
|
||||
name: "add_agents_model_strategy",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "agents")) {
|
||||
throw new Error("agents table missing; refusing to migrate v39");
|
||||
}
|
||||
if (columnExists(db, "agents", "model_strategy")) {
|
||||
log.info("[migration v39] agents.model_strategy already exists; skipping");
|
||||
return;
|
||||
}
|
||||
// Default at schema level = 'explicit' so existing rows keep their
|
||||
// hand-picked model. Application code uses 'auto' as the default for
|
||||
// freshly created agents.
|
||||
db.exec(
|
||||
"ALTER TABLE agents ADD COLUMN model_strategy TEXT NOT NULL DEFAULT 'explicit'",
|
||||
);
|
||||
log.info("[migration v39] added agents.model_strategy column");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { tableExists } from "./types";
|
||||
|
||||
export function createMigrationsV40V40(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
// Relax work_products.task_id from NOT NULL to NULL-allowed.
|
||||
//
|
||||
// Background: every file that an agent writes via pi's write/edit tool
|
||||
// should be registered as a work product so the desktop UI surfaces
|
||||
// it. Heartbeat (taskless) runs — CEO team-building scratch memos,
|
||||
// research exploration — legitimately produce files with no owning
|
||||
// task. Forcing task_id NOT NULL made the artifact projector skip
|
||||
// these runs, which in turn made the UI look empty while the
|
||||
// workspace filesystem filled up with unregistered output.
|
||||
//
|
||||
// SQLite has no ALTER COLUMN, so we rebuild the table. The operation
|
||||
// is done inside a transaction; PRAGMA foreign_keys is disabled for
|
||||
// the rebuild window and restored afterward so child rows do not
|
||||
// cascade-delete when the old table is dropped mid-swap.
|
||||
version: 40,
|
||||
name: "relax_work_products_task_id",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "work_products")) {
|
||||
log.info("[migration v40] work_products table missing; skipping");
|
||||
return;
|
||||
}
|
||||
const info = db.prepare("PRAGMA table_info(work_products)").all() as Array<{
|
||||
name: string;
|
||||
notnull: number;
|
||||
}>;
|
||||
const taskIdRow = info.find((row) => row.name === "task_id");
|
||||
if (!taskIdRow) {
|
||||
throw new Error("work_products.task_id column missing; refusing to migrate v40");
|
||||
}
|
||||
if (taskIdRow.notnull === 0) {
|
||||
log.info("[migration v40] work_products.task_id already nullable; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE work_products_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
task_id TEXT,
|
||||
type TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
external_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
review_state TEXT NOT NULL DEFAULT 'none',
|
||||
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT,
|
||||
metadata_json TEXT,
|
||||
created_by_run_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO work_products_new (
|
||||
id, company_id, project_id, task_id, type, provider, external_id,
|
||||
title, url, status, review_state, is_primary, summary, metadata_json,
|
||||
created_by_run_id, created_at, updated_at
|
||||
)
|
||||
SELECT id, company_id, project_id, task_id, type, provider, external_id,
|
||||
title, url, status, review_state, is_primary, summary, metadata_json,
|
||||
created_by_run_id, created_at, updated_at
|
||||
FROM work_products;
|
||||
DROP TABLE work_products;
|
||||
ALTER TABLE work_products_new RENAME TO work_products;
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_task
|
||||
ON work_products(company_id, task_id, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_work_products_updated
|
||||
ON work_products(company_id, updated_at);
|
||||
`);
|
||||
} finally {
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
log.info("[migration v40] relaxed work_products.task_id to nullable");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ModelCapability } from "@shared/types";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* CRUD for `model_capabilities`. The table is (provider, model_id) keyed; the
|
||||
* dispatcher reads it on every run, so every row must have numeric scores —
|
||||
* we validate inputs at the application layer before touching the DB.
|
||||
*/
|
||||
export function buildModelCapabilityMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
type Row = {
|
||||
provider: string;
|
||||
model_id: string;
|
||||
reasoning: number;
|
||||
coding: number;
|
||||
writing: number;
|
||||
tier: "trivial" | "standard" | "hard";
|
||||
cost_per_mtok_out: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function rowToRecord(row: Row): ModelCapability {
|
||||
return {
|
||||
provider: row.provider,
|
||||
modelId: row.model_id,
|
||||
reasoning: row.reasoning,
|
||||
coding: row.coding,
|
||||
writing: row.writing,
|
||||
tier: row.tier,
|
||||
costPerMtokOut: row.cost_per_mtok_out,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function listModelCapabilities(): ModelCapability[] {
|
||||
return readAll<Row>(
|
||||
"SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities ORDER BY provider, model_id",
|
||||
).map(rowToRecord);
|
||||
}
|
||||
|
||||
function getModelCapability(provider: string, modelId: string): ModelCapability | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"SELECT provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at FROM model_capabilities WHERE provider = ? AND model_id = ?",
|
||||
)
|
||||
.get(provider, modelId) as Row | undefined;
|
||||
return row ? rowToRecord(row) : null;
|
||||
}
|
||||
|
||||
function upsertModelCapability(input: Omit<ModelCapability, "updatedAt">): void {
|
||||
const now = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO model_capabilities (provider, model_id, reasoning, coding, writing, tier, cost_per_mtok_out, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, model_id) DO UPDATE SET
|
||||
reasoning = excluded.reasoning,
|
||||
coding = excluded.coding,
|
||||
writing = excluded.writing,
|
||||
tier = excluded.tier,
|
||||
cost_per_mtok_out = excluded.cost_per_mtok_out,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(
|
||||
input.provider,
|
||||
input.modelId,
|
||||
input.reasoning,
|
||||
input.coding,
|
||||
input.writing,
|
||||
input.tier,
|
||||
input.costPerMtokOut,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
function deleteModelCapability(provider: string, modelId: string): void {
|
||||
getDb()
|
||||
.prepare("DELETE FROM model_capabilities WHERE provider = ? AND model_id = ?")
|
||||
.run(provider, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the table from a list of rows. Used at boot when the table is empty
|
||||
* so a fresh install has something to dispatch against. Runs inside a
|
||||
* single transaction so either every row is written or none — we don't
|
||||
* want partial seeding if something goes wrong mid-loop.
|
||||
*/
|
||||
function seedModelCapabilities(rows: Array<Omit<ModelCapability, "updatedAt">>): number {
|
||||
return transaction(() => {
|
||||
const existing = readAll<{ cnt: number }>(
|
||||
"SELECT COUNT(*) AS cnt FROM model_capabilities",
|
||||
)[0]?.cnt ?? 0;
|
||||
if (existing > 0) return 0;
|
||||
for (const row of rows) upsertModelCapability(row);
|
||||
return rows.length;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
listModelCapabilities,
|
||||
getModelCapability,
|
||||
upsertModelCapability,
|
||||
deleteModelCapability,
|
||||
seedModelCapabilities,
|
||||
};
|
||||
}
|
||||
@@ -96,10 +96,19 @@ export function buildRunMethods(
|
||||
sessionDisplayId: string | null;
|
||||
unattributedCost: boolean;
|
||||
}) {
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId }
|
||||
const run = getDb().prepare("select task_id as taskId, agent_id as agentId, company_id as companyId, connector_id as connectorId, started_at as startedAt from runs where id = ?").get(input.runId) as
|
||||
| { taskId: string | null; agentId: string; companyId: string; connectorId: ConnectorId; startedAt: string | null }
|
||||
| undefined;
|
||||
if (!run) return { budgetHardStopped: false };
|
||||
// A run that never transitioned to 'running' (startedAt === null) and
|
||||
// is now terminal with status 'failed' / 'cancelled' must roll the
|
||||
// task back to 'todo'. Otherwise the task is stuck in 'in_progress'
|
||||
// with no owner — checkoutTask refuses (WHERE status IN todo/backlog),
|
||||
// and the scheduler loops forever logging "lost checkout race". The
|
||||
// common trigger is workspace-lock contention: `dispatchRunToWorker`
|
||||
// bails before work starts and calls finishRun('failed').
|
||||
const runNeverStarted =
|
||||
run.startedAt === null && (input.status === "failed" || input.status === "cancelled");
|
||||
const ts = nowIso();
|
||||
|
||||
// Cost is owned by CostWriter — per-message rows in `cost_entries`.
|
||||
@@ -137,6 +146,33 @@ export function buildRunMethods(
|
||||
getDb()
|
||||
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
|
||||
.run(ts, run.taskId, input.runId);
|
||||
if (runNeverStarted) {
|
||||
// Roll the task back to `todo` when no *other* live run claims
|
||||
// it. Invariant: `task.status = 'in_progress'` ⇒ a queued or
|
||||
// running run owns it. We drop the previous
|
||||
// `checkout_run_id IS NULL AND active_run_id IS NULL` guard
|
||||
// because the conditional UPDATE two lines up can miss (race:
|
||||
// checkout_run_id already swapped to a newer run id), which
|
||||
// would leave the task silently stuck at in_progress. The
|
||||
// NOT EXISTS clause is the canonical "no live owner" check.
|
||||
getDb()
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = 'todo',
|
||||
checkout_run_id = NULL,
|
||||
execution_locked_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status = 'in_progress'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM runs r
|
||||
WHERE r.task_id = tasks.id
|
||||
AND r.id != ?
|
||||
AND r.status IN ('queued', 'running')
|
||||
)`,
|
||||
)
|
||||
.run(ts, run.taskId, input.runId);
|
||||
}
|
||||
}
|
||||
if (runCostUsd > 0) {
|
||||
if (run.taskId !== null) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createMigrationsV1V15 } from "./migrations/v1-v15";
|
||||
import { createMigrationsV16V25 } from "./migrations/v16-v25";
|
||||
import { createMigrationsV26V34 } from "./migrations/v26-v34";
|
||||
import { createMigrationsV35V37 } from "./migrations/v35-v37";
|
||||
import { createMigrationsV38V39 } from "./migrations/v38-v39";
|
||||
import { createMigrationsV40V40 } from "./migrations/v40-v40";
|
||||
import {
|
||||
columnExists,
|
||||
nowIso,
|
||||
@@ -172,6 +174,8 @@ export function runMigrations(
|
||||
...createMigrationsV16V25(deps),
|
||||
...createMigrationsV26V34(deps),
|
||||
...createMigrationsV35V37(deps),
|
||||
...createMigrationsV38V39(deps),
|
||||
...createMigrationsV40V40(deps),
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildSnapshotMethods(
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[],
|
||||
agents: readAll<SqliteRow>(
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, model_strategy as modelStrategy, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, session_id as sessionId, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
...companyArgs,
|
||||
).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[],
|
||||
goals: readAll<GoalRecord>(
|
||||
|
||||
@@ -122,6 +122,29 @@ export function buildTaskSessionMethods(
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a session by its primary key. Sessions are agent-scoped
|
||||
* (post-v34 migration): a heartbeat run has no task to dereference,
|
||||
* so the session lookup must take the session id directly. Use with
|
||||
* `agents.session_id` as the entry point.
|
||||
*/
|
||||
getSessionById(sessionId: string): TaskSessionRecord | null {
|
||||
return (
|
||||
(getDb()
|
||||
.prepare(
|
||||
`SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId,
|
||||
adapter_type as adapterType, session_params_json as sessionParamsJson,
|
||||
session_display_id as sessionDisplayId, last_run_id as lastRunId,
|
||||
last_error as lastError, run_count as runCount,
|
||||
total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens,
|
||||
rotated_at as rotatedAt, rotation_reason as rotationReason,
|
||||
handoff_markdown as handoffMarkdown, created_at as createdAt, updated_at as updatedAt
|
||||
FROM task_sessions WHERE id = ?`,
|
||||
)
|
||||
.get(sessionId) as TaskSessionRecord | undefined) ?? null
|
||||
);
|
||||
},
|
||||
|
||||
incrementSessionUsage(
|
||||
sessionId: string,
|
||||
inputTokens: number,
|
||||
|
||||
Reference in new issue
Block a user