Sessions were keyed by (task, agent). Every task switch started a fresh
transcript — the agent lost everything it had learned on the previous
task. That breaks the product premise: a CEO should remember its past
decisions, an engineer should carry context between bugs.
Move pi's AgentMessage transcript to be agent-scoped. One session per
agent (per company). Tasks enter the ongoing conversation as user turns;
rotation/compaction boundaries follow the agent's long-running session.
Additive schema only — no row rewrites on the live production DB.
- Migration v34: `ALTER TABLE agents ADD COLUMN session_id TEXT` +
`idx_agents_session_id`. `ensureOptionalColumns` mirror for fresh DBs.
- `AgentRecord.sessionId?: string | null` in the read model; included
in every full-agent SELECT (entity-crud, snapshot, agents-hierarchy
chain-of-command / direct-reports / heartbeat-eligible).
- `db.ensureAgentSession({ agentId, companyId, adapterType })` —
idempotent: returns existing `agents.session_id` if set, else inserts
a new `task_sessions` row with an empty `task_id` sentinel (agent-
scoped, task-id is now metadata) and stores the id on the agent.
- `db.setAgentSessionId(agentId, sessionId)` — used by rotation.
- `db.updateTasksActiveSessionForAgent(agentId, sessionId)` — mirrors
the agent's current session onto every task the agent owns, so
existing per-task readers (`tasks.active_session_id`) still work.
- `turn-context.resolveSession`: pulls the agent's session via
`ensureAgentSession` instead of `getActiveTaskSession(task.id)`,
then points the current task at it so UI continues to resolve.
- `session-management-service.rotateSession`: new session carries
`taskId:""`, then `setAgentSessionId` + `updateTasksActiveSessionForAgent`
so every task the agent owns follows the rotation boundary.
- `session-entries.listActive`: `ORDER BY created_at, sequence` — the
created_at timestamp is authoritative; sequence stays as same-ms
tiebreaker. Forward-safety for future merge scenarios.
Existing agents' first post-upgrade run creates a fresh agent-scoped
session. Their pre-migration per-task transcripts remain in the DB as
historical records but are not loaded for new runs. This is a one-time
transcript reset, accepted over a risky lossy merge on the live DB.
Documented in a code comment on `ensureAgentSession`.
488 lines
19 KiB
TypeScript
488 lines
19 KiB
TypeScript
import type { DatabaseSync } from "node:sqlite";
|
|
import type { AgentRecord } from "@shared/types";
|
|
import {
|
|
type ReadAll,
|
|
type Transaction,
|
|
nowIso,
|
|
parseJson,
|
|
toBoolean,
|
|
} from "./helpers";
|
|
|
|
/**
|
|
* Agent row shape returned by listAgentsForApi. Mirrors the historical
|
|
* inline SQL projection in routes-agents.ts so HTTP response bodies
|
|
* stay byte-stable. heartbeatEnabled is exposed as a boolean.
|
|
*/
|
|
export type AgentApiRow = {
|
|
id: string;
|
|
companyId: string;
|
|
name: string;
|
|
role: string;
|
|
title: string | null;
|
|
department: string | null;
|
|
status: string;
|
|
reportsTo: string | null;
|
|
connectorId: string;
|
|
workspaceId: string | null;
|
|
model: string | null;
|
|
capabilities: string | null;
|
|
budgetMonthlyUsd: number | null;
|
|
spentMonthlyUsd: number | null;
|
|
metadataJson: string;
|
|
heartbeatEnabled: boolean;
|
|
heartbeatIntervalSec: number | null;
|
|
lastHeartbeatAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
/**
|
|
* Slim agent projection used by the me/standup aggregation.
|
|
*/
|
|
export type AgentStandupRow = {
|
|
id: string;
|
|
companyId: string;
|
|
name: string;
|
|
role: string;
|
|
status: string;
|
|
reportsTo: string | null;
|
|
department: string | null;
|
|
};
|
|
|
|
export function buildAgentHierarchyMethods(
|
|
getDb: () => DatabaseSync,
|
|
readAll: ReadAll,
|
|
_transaction: Transaction,
|
|
_deps: {
|
|
getAgent: (id: string) => AgentRecord;
|
|
},
|
|
) {
|
|
return {
|
|
getChainOfCommand(agentId: string): AgentRecord[] {
|
|
const rows = readAll<AgentRecord & { depth: number }>(
|
|
`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,
|
|
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,
|
|
0 as depth
|
|
FROM agents WHERE id = ?
|
|
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.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,
|
|
c.depth + 1
|
|
FROM agents a JOIN chain c ON a.id = c.reportsTo
|
|
WHERE c.depth < 20
|
|
)
|
|
SELECT * FROM chain ORDER BY depth`,
|
|
agentId,
|
|
);
|
|
// Skip self (depth 0); return ancestors from direct manager upward.
|
|
return rows.slice(1).map((record) => ({
|
|
...record,
|
|
heartbeatEnabled: toBoolean(record.heartbeatEnabled),
|
|
})) as AgentRecord[];
|
|
},
|
|
|
|
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 = ?",
|
|
companyId,
|
|
agentId,
|
|
).map((record) => ({
|
|
...record,
|
|
heartbeatEnabled: toBoolean(record.heartbeatEnabled),
|
|
})) as AgentRecord[];
|
|
},
|
|
|
|
setHeartbeat(agentId: string, enabled: boolean, intervalSec: number): void {
|
|
getDb()
|
|
.prepare(
|
|
"update agents set heartbeat_enabled = ?, heartbeat_interval_sec = ?, updated_at = ? where id = ?",
|
|
)
|
|
.run(enabled ? 1 : 0, intervalSec, nowIso(), agentId);
|
|
},
|
|
|
|
markHeartbeatAt(agentId: string): void {
|
|
getDb()
|
|
.prepare("update agents set last_heartbeat_at = ? where id = ?")
|
|
.run(nowIso(), agentId);
|
|
},
|
|
|
|
/**
|
|
* Returns all agents for a company with the full API projection used
|
|
* by GET /api/companies/:companyId/agents. Mirrors the historical
|
|
* inline SQL and maps heartbeat_enabled into a boolean.
|
|
*/
|
|
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",
|
|
companyId,
|
|
).map((a) => ({ ...a, heartbeatEnabled: Boolean(a.heartbeatEnabled) })) as AgentApiRow[];
|
|
},
|
|
|
|
/**
|
|
* Returns the slim standup projection used by
|
|
* GET /api/agents/me/standup — only the fields that feed the
|
|
* aggregation (id, name, role, status, reportsTo, department).
|
|
*/
|
|
listAgentsForStandup(companyId: string): AgentStandupRow[] {
|
|
return readAll<AgentStandupRow>(
|
|
"select id, company_id as companyId, name, role, status, reports_to as reportsTo, department from agents where company_id = ? order by created_at asc",
|
|
companyId,
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns the slim direct-reports projection used by
|
|
* GET /api/agents/me/standup — only (id, name, role, status).
|
|
*/
|
|
listDirectReportsForStandup(
|
|
companyId: string,
|
|
managerId: string,
|
|
): Array<{ id: string; name: string; role: string; status: string }> {
|
|
return readAll(
|
|
"select id, name, role, status from agents where company_id = ? and reports_to = ? order by created_at asc",
|
|
companyId,
|
|
managerId,
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns per-report task-status counts for the standup aggregation.
|
|
* Joins agents with their tasks and aggregates statuses (in_progress,
|
|
* in_review, blocked, done).
|
|
*/
|
|
listReportStatusesForStandup(companyId: string, managerId: string): Array<{
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
status: string;
|
|
tasksInProgress: number;
|
|
tasksInReview: number;
|
|
tasksBlocked: number;
|
|
tasksDone: number;
|
|
}> {
|
|
return readAll(
|
|
`select agents.id as id, agents.name as name, agents.role as role, agents.status as status,
|
|
coalesce(sum(case when tasks.status = 'in_progress' then 1 else 0 end), 0) as tasksInProgress,
|
|
coalesce(sum(case when tasks.status = 'in_review' then 1 else 0 end), 0) as tasksInReview,
|
|
coalesce(sum(case when tasks.status = 'blocked' then 1 else 0 end), 0) as tasksBlocked,
|
|
coalesce(sum(case when tasks.status = 'done' then 1 else 0 end), 0) as tasksDone
|
|
from agents
|
|
left join tasks on tasks.assignee_agent_id = agents.id and tasks.company_id = agents.company_id
|
|
where agents.company_id = ? and agents.reports_to = ?
|
|
group by agents.id, agents.name, agents.role, agents.status
|
|
order by agents.created_at asc`,
|
|
companyId,
|
|
managerId,
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns the duplicate-detection projection used by
|
|
* POST /api/companies/:id/agents to reject hires with a name that
|
|
* already belongs to a non-terminated agent.
|
|
*/
|
|
listExistingAgentsForHireCheck(
|
|
companyId: string,
|
|
): Array<{ id: string; name: string; role: string }> {
|
|
return readAll<{ id: string; name: string; role: string }>(
|
|
"select id, name, role from agents where company_id = ? and status != 'terminated'",
|
|
companyId,
|
|
);
|
|
},
|
|
|
|
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')",
|
|
companyId,
|
|
).map((record) => ({
|
|
...record,
|
|
heartbeatEnabled: toBoolean(record.heartbeatEnabled),
|
|
})) as AgentRecord[];
|
|
},
|
|
|
|
getCeoForCompany(
|
|
companyId: string,
|
|
): { id: string; name: string; lastHeartbeatAt: string | null } | null {
|
|
const row = getDb()
|
|
.prepare(
|
|
"select id, name, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and reports_to is null and status != 'terminated' limit 1",
|
|
)
|
|
.get(companyId) as
|
|
| { id: string; name: string; lastHeartbeatAt: string | null }
|
|
| undefined;
|
|
return row ?? null;
|
|
},
|
|
|
|
listActiveAgentsForCompany(
|
|
companyId: string,
|
|
): Array<{
|
|
id: string;
|
|
name: string;
|
|
department: string | null;
|
|
lastHeartbeatAt: string | null;
|
|
}> {
|
|
return readAll<{
|
|
id: string;
|
|
name: string;
|
|
department: string | null;
|
|
lastHeartbeatAt: string | null;
|
|
}>(
|
|
"select id, name, department, last_heartbeat_at as lastHeartbeatAt from agents where company_id = ? and status not in ('terminated', 'paused', 'pending_approval')",
|
|
companyId,
|
|
);
|
|
},
|
|
|
|
getIdleAgentsForDepartment(
|
|
companyId: string,
|
|
department: string,
|
|
): Array<{ id: string; name: string }> {
|
|
return readAll(
|
|
"select id, name from agents where company_id = ? and department = ? and status in ('idle', 'active') order by spent_monthly_usd asc",
|
|
companyId,
|
|
department,
|
|
);
|
|
},
|
|
|
|
getManagerForAgent(
|
|
agentId: string,
|
|
): { id: string; name: string; companyId: string } | null {
|
|
const agent = getDb()
|
|
.prepare("select reports_to, company_id from agents where id = ?")
|
|
.get(agentId) as { reports_to: string | null; company_id: string } | undefined;
|
|
if (!agent || !agent.reports_to) return null;
|
|
const manager = getDb()
|
|
.prepare("select id, name from agents where id = ?")
|
|
.get(agent.reports_to) as { id: string; name: string } | undefined;
|
|
return manager ? { ...manager, companyId: agent.company_id } : null;
|
|
},
|
|
|
|
resetMonthlyBudgets(companyId: string): void {
|
|
getDb()
|
|
.prepare(
|
|
"update agents set spent_monthly_usd = 0, updated_at = ? where company_id = ? and spent_monthly_usd > 0",
|
|
)
|
|
.run(nowIso(), companyId);
|
|
},
|
|
|
|
getAgentPermissions(agentId: string): Record<string, boolean> {
|
|
const row = getDb()
|
|
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
|
.get(agentId) as { permissions_json: string } | undefined;
|
|
return parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
|
},
|
|
|
|
setAgentPermission(
|
|
agentId: string,
|
|
companyId: string,
|
|
permissionKey: string,
|
|
enabled: boolean,
|
|
): void {
|
|
const row = getDb()
|
|
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
|
.get(agentId) as { permissions_json: string } | undefined;
|
|
const current = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
|
if (enabled) {
|
|
current[permissionKey] = true;
|
|
} else {
|
|
delete current[permissionKey];
|
|
}
|
|
const ts = nowIso();
|
|
getDb()
|
|
.prepare(
|
|
"UPDATE agents SET permissions_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
|
)
|
|
.run(JSON.stringify(current), ts, agentId, companyId);
|
|
},
|
|
|
|
hasAgentPermission(agentId: string, permissionKey: string): boolean {
|
|
const row = getDb()
|
|
.prepare("SELECT permissions_json FROM agents WHERE id = ?")
|
|
.get(agentId) as { permissions_json: string } | undefined;
|
|
const perms = parseJson<Record<string, boolean>>(row?.permissions_json, {});
|
|
return perms[permissionKey] === true;
|
|
},
|
|
|
|
getRuntimeState(agentId: string): Record<string, unknown> {
|
|
const row = getDb()
|
|
.prepare("SELECT runtime_state_json FROM agents WHERE id = ?")
|
|
.get(agentId) as { runtime_state_json: string | null } | undefined;
|
|
return parseJson<Record<string, unknown>>(row?.runtime_state_json, {});
|
|
},
|
|
|
|
setRuntimeState(
|
|
agentId: string,
|
|
companyId: string,
|
|
state: Record<string, unknown>,
|
|
): void {
|
|
const ts = nowIso();
|
|
getDb()
|
|
.prepare(
|
|
"UPDATE agents SET runtime_state_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
|
)
|
|
.run(JSON.stringify(state), ts, agentId, companyId);
|
|
},
|
|
|
|
getHeartbeatPolicy(agentId: string): {
|
|
enabled: boolean;
|
|
intervalSec: number;
|
|
cooldownSec: number;
|
|
maxConcurrentRuns: number;
|
|
wakeOnDemand: boolean;
|
|
} {
|
|
const row = getDb()
|
|
.prepare(
|
|
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
|
)
|
|
.get(agentId) as
|
|
| {
|
|
heartbeat_enabled: number;
|
|
heartbeat_interval_sec: number;
|
|
heartbeat_policy_json: string | null;
|
|
}
|
|
| undefined;
|
|
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
|
const policy = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
|
return {
|
|
enabled: toBoolean(row.heartbeat_enabled),
|
|
intervalSec: row.heartbeat_interval_sec ?? 120,
|
|
cooldownSec: typeof policy.cooldownSec === "number" ? policy.cooldownSec : 10,
|
|
maxConcurrentRuns:
|
|
typeof policy.maxConcurrentRuns === "number" ? policy.maxConcurrentRuns : 1,
|
|
wakeOnDemand: policy.wakeOnDemand !== false,
|
|
};
|
|
},
|
|
|
|
setHeartbeatPolicy(
|
|
agentId: string,
|
|
companyId: string,
|
|
policy: {
|
|
enabled?: boolean;
|
|
intervalSec?: number;
|
|
cooldownSec?: number;
|
|
maxConcurrentRuns?: number;
|
|
wakeOnDemand?: boolean;
|
|
},
|
|
): void {
|
|
const row = getDb()
|
|
.prepare(
|
|
"SELECT heartbeat_enabled, heartbeat_interval_sec, heartbeat_policy_json FROM agents WHERE id = ?",
|
|
)
|
|
.get(agentId) as
|
|
| {
|
|
heartbeat_enabled: number;
|
|
heartbeat_interval_sec: number;
|
|
heartbeat_policy_json: string | null;
|
|
}
|
|
| undefined;
|
|
if (!row) throw new Error(`Agent ${agentId} not found.`);
|
|
const current = parseJson<Record<string, unknown>>(row.heartbeat_policy_json, {});
|
|
const merged = {
|
|
enabled: policy.enabled ?? toBoolean(row.heartbeat_enabled),
|
|
intervalSec: policy.intervalSec ?? row.heartbeat_interval_sec ?? 120,
|
|
cooldownSec:
|
|
policy.cooldownSec ??
|
|
(typeof current.cooldownSec === "number" ? current.cooldownSec : 10),
|
|
maxConcurrentRuns:
|
|
policy.maxConcurrentRuns ??
|
|
(typeof current.maxConcurrentRuns === "number" ? current.maxConcurrentRuns : 1),
|
|
wakeOnDemand: policy.wakeOnDemand ?? (current.wakeOnDemand !== false),
|
|
};
|
|
const policyJson = JSON.stringify({
|
|
cooldownSec: merged.cooldownSec,
|
|
maxConcurrentRuns: merged.maxConcurrentRuns,
|
|
wakeOnDemand: merged.wakeOnDemand,
|
|
});
|
|
const ts = nowIso();
|
|
getDb()
|
|
.prepare(
|
|
"UPDATE agents SET heartbeat_enabled = ?, heartbeat_interval_sec = ?, heartbeat_policy_json = ?, updated_at = ? WHERE id = ? AND company_id = ?",
|
|
)
|
|
.run(merged.enabled ? 1 : 0, merged.intervalSec, policyJson, ts, agentId, companyId);
|
|
},
|
|
|
|
/**
|
|
* Returns ids of non-terminated agents in a department. Used by
|
|
* `resolveMessageWakeTargets` to fan out "department" channel messages.
|
|
*/
|
|
listDepartmentAgentIds(companyId: string, department: string): string[] {
|
|
return readAll<{ id: string }>(
|
|
"select id from agents where company_id = ? and department = ? and status != 'terminated'",
|
|
companyId,
|
|
department,
|
|
).map((row) => row.id);
|
|
},
|
|
|
|
/**
|
|
* Returns ids of non-terminated agents that have no manager (top of the
|
|
* reporting tree). Used by `resolveMessageWakeTargets` for "company"
|
|
* channel broadcast.
|
|
*/
|
|
listTopLevelAgentIds(companyId: string): string[] {
|
|
return readAll<{ id: string }>(
|
|
"select id from agents where company_id = ? and reports_to is null and status != 'terminated'",
|
|
companyId,
|
|
).map((row) => row.id);
|
|
},
|
|
|
|
/**
|
|
* Returns ids of agents that can receive incident pings: everybody not
|
|
* terminated or still pending approval.
|
|
*/
|
|
listIncidentPingableAgentIds(companyId: string): string[] {
|
|
return readAll<{ id: string }>(
|
|
"select id from agents where company_id = ? and status not in ('terminated', 'pending_approval')",
|
|
companyId,
|
|
).map((row) => row.id);
|
|
},
|
|
|
|
/**
|
|
* Returns the agent's display name, or null if the agent no longer
|
|
* exists. Used for notification subject lines.
|
|
*/
|
|
findAgentName(agentId: string): string | null {
|
|
const row = getDb()
|
|
.prepare("select name from agents where id = ? limit 1")
|
|
.get(agentId) as { name: string } | undefined;
|
|
return row?.name ?? null;
|
|
},
|
|
|
|
/**
|
|
* Returns (id, name) pairs for the given ids, scoped to company. Used
|
|
* for batched name resolution when fanning out message wake targets.
|
|
*/
|
|
listAgentNamesByIds(companyId: string, agentIds: readonly string[]): Array<{ id: string; name: string }> {
|
|
if (agentIds.length === 0) return [];
|
|
const placeholders = agentIds.map(() => "?").join(",");
|
|
return readAll<{ id: string; name: string }>(
|
|
`select id, name from agents where id in (${placeholders}) and company_id = ?`,
|
|
...agentIds,
|
|
companyId,
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Returns ids of active agents with the given ids, filtered to the
|
|
* company scope. Used by project-channel wake targeting to drop
|
|
* terminated members before pinging.
|
|
*/
|
|
listActiveAgentIdsFromIds(companyId: string, agentIds: readonly string[]): string[] {
|
|
if (agentIds.length === 0) return [];
|
|
const placeholders = agentIds.map(() => "?").join(",");
|
|
return readAll<{ id: string }>(
|
|
`select id from agents where id in (${placeholders}) and company_id = ? and status != 'terminated'`,
|
|
...agentIds,
|
|
companyId,
|
|
).map((row) => row.id);
|
|
},
|
|
};
|
|
}
|