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`.
154 lines
4.3 KiB
TypeScript
154 lines
4.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import type { ReadAll, Transaction } from "./helpers";
|
|
import { nowIso } from "./helpers";
|
|
|
|
export type SessionEntryKind = "message" | "compaction" | "summary";
|
|
|
|
export interface SessionEntryRow {
|
|
id: string;
|
|
taskSessionId: string;
|
|
sequence: number;
|
|
kind: SessionEntryKind;
|
|
role: string | null;
|
|
payloadJson: string;
|
|
isSuperseded: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface AppendInput {
|
|
taskSessionId: string;
|
|
kind: SessionEntryKind;
|
|
role?: string | null;
|
|
payloadJson: string;
|
|
}
|
|
|
|
export interface ReplaceInput {
|
|
taskSessionId: string;
|
|
entries: Array<{ kind: SessionEntryKind; role?: string | null; payloadJson: string }>;
|
|
}
|
|
|
|
export function buildSessionEntryMethods(
|
|
getDb: () => DatabaseSync,
|
|
readAll: ReadAll,
|
|
transaction: Transaction,
|
|
) {
|
|
function nextSequence(taskSessionId: string): number {
|
|
const rows = readAll<{ maxSeq: number | null }>(
|
|
"select max(sequence) as maxSeq from session_entries where task_session_id = ?",
|
|
taskSessionId,
|
|
);
|
|
const max = rows[0]?.maxSeq;
|
|
return (max ?? 0) + 1;
|
|
}
|
|
|
|
function append(input: AppendInput): SessionEntryRow {
|
|
const id = randomUUID();
|
|
const sequence = nextSequence(input.taskSessionId);
|
|
const created = nowIso();
|
|
getDb()
|
|
.prepare(
|
|
"insert into session_entries (id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at) values (?, ?, ?, ?, ?, ?, 0, ?)",
|
|
)
|
|
.run(
|
|
id,
|
|
input.taskSessionId,
|
|
sequence,
|
|
input.kind,
|
|
input.role ?? null,
|
|
input.payloadJson,
|
|
created,
|
|
);
|
|
return {
|
|
id,
|
|
taskSessionId: input.taskSessionId,
|
|
sequence,
|
|
kind: input.kind,
|
|
role: input.role ?? null,
|
|
payloadJson: input.payloadJson,
|
|
isSuperseded: false,
|
|
createdAt: created,
|
|
};
|
|
}
|
|
|
|
function listActive(taskSessionId: string): SessionEntryRow[] {
|
|
const rows = readAll<{
|
|
id: string;
|
|
task_session_id: string;
|
|
sequence: number;
|
|
kind: SessionEntryKind;
|
|
role: string | null;
|
|
payload_json: string;
|
|
is_superseded: number;
|
|
created_at: string;
|
|
}>(
|
|
"select id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at from session_entries where task_session_id = ? and is_superseded = 0 order by created_at asc, sequence asc",
|
|
taskSessionId,
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
taskSessionId: row.task_session_id,
|
|
sequence: row.sequence,
|
|
kind: row.kind,
|
|
role: row.role,
|
|
payloadJson: row.payload_json,
|
|
isSuperseded: false,
|
|
createdAt: row.created_at,
|
|
}));
|
|
}
|
|
|
|
function listAll(taskSessionId: string): SessionEntryRow[] {
|
|
const rows = readAll<{
|
|
id: string;
|
|
task_session_id: string;
|
|
sequence: number;
|
|
kind: SessionEntryKind;
|
|
role: string | null;
|
|
payload_json: string;
|
|
is_superseded: number;
|
|
created_at: string;
|
|
}>(
|
|
"select id, task_session_id, sequence, kind, role, payload_json, is_superseded, created_at from session_entries where task_session_id = ? order by sequence asc",
|
|
taskSessionId,
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
taskSessionId: row.task_session_id,
|
|
sequence: row.sequence,
|
|
kind: row.kind,
|
|
role: row.role,
|
|
payloadJson: row.payload_json,
|
|
isSuperseded: row.is_superseded === 1,
|
|
createdAt: row.created_at,
|
|
}));
|
|
}
|
|
|
|
function replace(input: ReplaceInput): SessionEntryRow[] {
|
|
return transaction(() => {
|
|
getDb()
|
|
.prepare("update session_entries set is_superseded = 1 where task_session_id = ? and is_superseded = 0")
|
|
.run(input.taskSessionId);
|
|
const inserted: SessionEntryRow[] = [];
|
|
for (const entry of input.entries) {
|
|
inserted.push(
|
|
append({
|
|
taskSessionId: input.taskSessionId,
|
|
kind: entry.kind,
|
|
role: entry.role ?? null,
|
|
payloadJson: entry.payloadJson,
|
|
}),
|
|
);
|
|
}
|
|
return inserted;
|
|
});
|
|
}
|
|
|
|
function deleteForSession(taskSessionId: string): void {
|
|
getDb()
|
|
.prepare("delete from session_entries where task_session_id = ?")
|
|
.run(taskSessionId);
|
|
}
|
|
|
|
return { append, listActive, listAll, replace, deleteForSession };
|
|
}
|