refactor(session): drop dead task-scoped session columns (v41)
Sessions have been agent-scoped since v34, but the schema kept four
dead task-scoped columns that every session code path had to pretend
were meaningful:
- tasks.active_session_id (mirrored agents.session_id)
- tasks.session_json (superseded by task_sessions.session_params_json)
- tasks.session_display_id (superseded by task_sessions.session_display_id)
- task_sessions.task_id (always empty-string on new rows)
v41 drops all four via SQLite table rebuild — the only safe path when
multiple columns need to go in one step and indexes / FK directions
need to be preserved. `PRAGMA foreign_keys = OFF` spans the swap
window so cascade-delete does not fire mid-rebuild. The migration is
idempotent and also backfills the optional task columns
(task_type / requires_user_review / retry_count / last_failure_at /
started_at / completed_at / cancelled_at / checkout_run_id /
execution_locked_at) before rebuilding so it works whether or not
`ensureOptionalColumns` has run yet on this install.
Along with the schema change, every code path that read or wrote
those columns has been rewired to the agent-scoped equivalents:
- task-sessions-crud.ts: deleted updateTaskSession,
getTaskSession, getActiveTaskSession, updateTasksActiveSessionForAgent;
added updateSessionParams(sessionId, ...) for the worker's
finalizer. ensureAgentSession and createNewSession no longer touch
task_id.
- database-base.ts: facade drops the now-unused pass-throughs.
- snapshot.ts / task-queries.ts / tasks-status.ts / entity-crud.ts:
SELECT projections no longer pull session_json / session_display_id
/ active_session_id from tasks.
- tasks-crud.ts (TaskRow) and shared/types/records-core.ts
(TaskRecord): sessionJson / sessionDisplayId fields removed.
- TaskSessionRecord: taskId field removed.
- turn-context.ts::resolveSession: no more
updateTasksActiveSessionForAgent mirror; sessionParams read directly
from the current session via getSessionById.
- run-finished-helpers.ts::finalizeRun: stamps sessionParams via
updateSessionParams on the agent's current session, resolved through
agents.session_id.
- session-management-service.ts: rotateSession stops calling
updateTasksActiveSessionForAgent.
Test suite updated to use the new session-id API. Existing tests used
`getActiveTaskSession(taskId)` and `createNewSession({ taskId, ...})`
as probes; they now use `getSessionById(sessionId)` and the taskId-less
createNewSession input.
Tests: 603/603 green. Typecheck clean. Migration tested on fresh DB
(runs v1→v41 cleanly) and idempotent re-apply (detects absent dead
columns, skips).
This commit is contained in:
1 parent
6abc1a823f
commit
2ace9183b0
17 files changed
+270
-243
No files matched your search
@@ -339,16 +339,13 @@ export class AppDatabaseBase {
|
||||
saveTask(input: { id?: string | null; companyId: string; projectId?: string | null; goalId?: string | null; parentId?: string | null; title: string; description: string; assigneeAgentId?: string | null; workspaceId?: string | null; priority: string; status: string }) { return this.tasks.saveTask(input); }
|
||||
getTask(id: string) { return this.entityCrud.getTask(id); }
|
||||
deleteTask(id: string, companyId: string) { return this.tasks.deleteTask(id, companyId); }
|
||||
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); }
|
||||
updateSessionParams(sessionId: string, sessionParams: Record<string, unknown> | null, sessionDisplayId: string | null) { return this.tasks.updateSessionParams(sessionId, sessionParams, sessionDisplayId); }
|
||||
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); }
|
||||
createNewSession(input: { companyId: string; agentId: string; adapterType: string; sessionParamsJson: string | null; sessionDisplayId: string | null }) { return this.tasks.createNewSession(input); }
|
||||
ensureAgentSession(input: { agentId: string; companyId: string; adapterType: string }) { return this.tasks.ensureAgentSession(input); }
|
||||
setAgentSessionId(agentId: string, sessionId: string) { return this.tasks.setAgentSessionId(agentId, sessionId); }
|
||||
updateTasksActiveSessionForAgent(agentId: string, sessionId: string) { return this.tasks.updateTasksActiveSessionForAgent(agentId, sessionId); }
|
||||
getRecentRunsForTask(taskId: string, limit: number) { return this.tasks.getRecentRunsForTask(taskId, limit); }
|
||||
getRecentActivityForTask(taskId: string, limit: number) { return this.tasks.getRecentActivityForTask(taskId, limit); }
|
||||
checkoutTask(taskId: string, runId: string) { return this.tasks.checkoutTask(taskId, runId); }
|
||||
|
||||
@@ -49,7 +49,7 @@ export function buildEntityCrudMethods(
|
||||
getTask(id: string): TaskRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
|
||||
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
|
||||
)
|
||||
.get(id) as TaskRecord | undefined;
|
||||
if (!row) throw new Error(`Task not found: ${id}`);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { columnExists, tableExists } from "./types";
|
||||
|
||||
/**
|
||||
* Drop dead columns left behind by the agent-scoped session refactor.
|
||||
*
|
||||
* Pre-v34, a session belonged to a task; the schema reflected that
|
||||
* with `tasks.active_session_id`, `tasks.session_json`,
|
||||
* `tasks.session_display_id`, and a `task_sessions.task_id` FK back to
|
||||
* tasks. Since v34, sessions are agent-scoped — `agents.session_id`
|
||||
* is the canonical pointer, and every runtime read goes through that
|
||||
* or through the session's primary key. The four columns have been
|
||||
* dead storage ever since, and every touch of session code had to
|
||||
* pretend they were still meaningful.
|
||||
*
|
||||
* This migration rebuilds both tables without those columns. SQLite
|
||||
* has no `ALTER TABLE DROP COLUMN` that drops multiple columns
|
||||
* atomically and preserves every constraint; the "copy to new table,
|
||||
* swap, reindex" pattern is the only safe route. `PRAGMA foreign_keys`
|
||||
* is disabled for the swap window and restored afterward so child
|
||||
* tables are not cascade-deleted mid-rebuild.
|
||||
*
|
||||
* The migration is idempotent: each table rebuild checks whether the
|
||||
* dead columns are still present and skips the work otherwise.
|
||||
*/
|
||||
export function createMigrationsV41V41(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
version: 41,
|
||||
name: "drop_dead_session_columns",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "tasks") || !tableExists(db, "task_sessions")) {
|
||||
log.info("[migration v41] tasks or task_sessions table missing; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const tasksHasDeadCols =
|
||||
columnExists(db, "tasks", "session_json") ||
|
||||
columnExists(db, "tasks", "session_display_id") ||
|
||||
columnExists(db, "tasks", "active_session_id");
|
||||
const sessionsHasDeadCol = columnExists(db, "task_sessions", "task_id");
|
||||
|
||||
if (!tasksHasDeadCols && !sessionsHasDeadCol) {
|
||||
log.info("[migration v41] dead columns already absent; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// The `ensureOptionalColumns` helper in schema.ts normally
|
||||
// backfills `task_type`, `requires_user_review`, `retry_count`,
|
||||
// `last_failure_at` on existing installs — but that runs AFTER
|
||||
// migrations, not before. Rebuild the table here with the same
|
||||
// shape as the post-ensureOptionalColumns schema so the old data
|
||||
// is preserved and the new table stays consistent whether
|
||||
// initializeDatabase has reached ensureOptionalColumns yet or not.
|
||||
const ensureTaskCol = (column: string, typeSql: string) => {
|
||||
if (!columnExists(db, "tasks", column)) {
|
||||
db.prepare(`ALTER TABLE tasks ADD COLUMN ${column} ${typeSql}`).run();
|
||||
}
|
||||
};
|
||||
ensureTaskCol("task_type", "TEXT NOT NULL DEFAULT 'general'");
|
||||
ensureTaskCol("requires_user_review", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureTaskCol("retry_count", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureTaskCol("last_failure_at", "TEXT");
|
||||
ensureTaskCol("checkout_run_id", "TEXT");
|
||||
ensureTaskCol("execution_locked_at", "TEXT");
|
||||
ensureTaskCol("started_at", "TEXT");
|
||||
ensureTaskCol("completed_at", "TEXT");
|
||||
ensureTaskCol("cancelled_at", "TEXT");
|
||||
|
||||
db.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
if (tasksHasDeadCols) {
|
||||
db.exec(`
|
||||
CREATE TABLE tasks_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
project_id TEXT,
|
||||
goal_id TEXT,
|
||||
parent_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
assignee_agent_id TEXT,
|
||||
workspace_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
priority TEXT NOT NULL,
|
||||
active_run_id TEXT,
|
||||
cost_usd REAL NOT NULL DEFAULT 0,
|
||||
task_type TEXT NOT NULL DEFAULT 'general',
|
||||
requires_user_review INTEGER NOT NULL DEFAULT 0,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_failure_at TEXT,
|
||||
checkout_run_id TEXT,
|
||||
execution_locked_at TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
cancelled_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO tasks_new (
|
||||
id, company_id, project_id, goal_id, parent_id, title, description,
|
||||
assignee_agent_id, workspace_id, status, priority, active_run_id,
|
||||
cost_usd, task_type, requires_user_review, retry_count, last_failure_at,
|
||||
checkout_run_id, execution_locked_at, started_at, completed_at,
|
||||
cancelled_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, company_id, project_id, goal_id, parent_id, title, description,
|
||||
assignee_agent_id, workspace_id, status, priority, active_run_id,
|
||||
cost_usd, task_type, requires_user_review, retry_count, last_failure_at,
|
||||
checkout_run_id, execution_locked_at, started_at, completed_at,
|
||||
cancelled_at, created_at, updated_at
|
||||
FROM tasks;
|
||||
DROP TABLE tasks;
|
||||
ALTER TABLE tasks_new RENAME TO tasks;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status);
|
||||
`);
|
||||
log.info("[migration v41] rebuilt tasks without session_json/session_display_id/active_session_id");
|
||||
}
|
||||
|
||||
if (sessionsHasDeadCol) {
|
||||
db.exec(`
|
||||
CREATE TABLE task_sessions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
adapter_type TEXT NOT NULL,
|
||||
session_params_json TEXT,
|
||||
session_display_id TEXT,
|
||||
last_run_id TEXT,
|
||||
last_error TEXT,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
rotated_at TEXT,
|
||||
rotation_reason TEXT,
|
||||
handoff_markdown TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO task_sessions_new (
|
||||
id, company_id, agent_id, adapter_type, session_params_json,
|
||||
session_display_id, last_run_id, last_error, run_count,
|
||||
total_input_tokens, total_output_tokens, rotated_at, rotation_reason,
|
||||
handoff_markdown, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, company_id, agent_id, adapter_type, session_params_json,
|
||||
session_display_id, last_run_id, last_error, run_count,
|
||||
total_input_tokens, total_output_tokens, rotated_at, rotation_reason,
|
||||
handoff_markdown, created_at, updated_at
|
||||
FROM task_sessions;
|
||||
DROP TABLE task_sessions;
|
||||
ALTER TABLE task_sessions_new RENAME TO task_sessions;
|
||||
CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent
|
||||
ON task_sessions(company_id, agent_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup
|
||||
ON task_sessions(company_id, agent_id, adapter_type);
|
||||
`);
|
||||
log.info("[migration v41] rebuilt task_sessions without task_id");
|
||||
}
|
||||
} finally {
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { createMigrationsV41V41 } from "./migrations/v41-v41";
|
||||
import {
|
||||
columnExists,
|
||||
nowIso,
|
||||
@@ -176,6 +177,7 @@ export function runMigrations(
|
||||
...createMigrationsV35V37(deps),
|
||||
...createMigrationsV38V39(deps),
|
||||
...createMigrationsV40V40(deps),
|
||||
...createMigrationsV41V41(deps),
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function buildSnapshotMethods(
|
||||
...companyArgs,
|
||||
),
|
||||
tasks: readAll<TaskRecord>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc limit 2000`,
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, cost_usd as costUsd, task_type as taskType, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc limit 2000`,
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview), startedAt: record.startedAt ?? null, completedAt: record.completedAt ?? null, cancelledAt: record.cancelledAt ?? null })) as TaskRecord[],
|
||||
approvals: readAll<ApprovalRecord>(
|
||||
|
||||
@@ -25,7 +25,7 @@ export function buildTaskQueryMethods(
|
||||
): TaskRecord[] {
|
||||
const placeholders = statuses.map(() => "?").join(",");
|
||||
return readAll<TaskRecord>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, cost_usd as costUsd, task_type as taskType, created_at as createdAt, updated_at as updatedAt
|
||||
from tasks
|
||||
where company_id = ? and assignee_agent_id = ? and status in (${placeholders})
|
||||
order by
|
||||
@@ -109,7 +109,7 @@ export function buildTaskQueryMethods(
|
||||
): TaskListByStatusesRow[] {
|
||||
const placeholders = statuses.map(() => "?").join(", ");
|
||||
return readAll<TaskListByStatusesRow & { requiresUserReview: number | boolean }>(
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders}) order by created_at desc`,
|
||||
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, cost_usd as costUsd, requires_user_review as requiresUserReview, started_at as startedAt, completed_at as completedAt, cancelled_at as cancelledAt, created_at as createdAt, updated_at as updatedAt from tasks where company_id = ? and status in (${placeholders}) order by created_at desc`,
|
||||
companyId,
|
||||
...statuses,
|
||||
).map((t) => ({
|
||||
|
||||
@@ -4,16 +4,12 @@ import type { TaskSessionRecord } from "@shared/types";
|
||||
import { type ReadAll, type Transaction, nowIso } from "./helpers";
|
||||
|
||||
/**
|
||||
* Task-session lifecycle helpers extracted from tasks-crud.ts.
|
||||
*
|
||||
* Covers the agent-scoped session lifecycle:
|
||||
* - updateTaskSession / getTaskSession / getActiveTaskSession
|
||||
* - incrementSessionUsage / archiveSession
|
||||
* - ensureAgentSession / setAgentSessionId / updateTasksActiveSessionForAgent
|
||||
* - createNewSession
|
||||
*
|
||||
* Every method is spread into the final `buildTaskMethods` result, so
|
||||
* call-sites on AppDatabase remain unchanged.
|
||||
* Agent-scoped session lifecycle. Sessions belong to agents, not
|
||||
* tasks — `agents.session_id` is the canonical pointer, and
|
||||
* `task_sessions` rows are looked up by their own primary key only.
|
||||
* The pre-migration `tasks.active_session_id` / `task_sessions.task_id`
|
||||
* columns were dropped by v41; any read that still needs a session
|
||||
* given a task id resolves it through the task's assignee agent.
|
||||
*/
|
||||
export function buildTaskSessionMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
@@ -21,118 +17,37 @@ export function buildTaskSessionMethods(
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
updateTaskSession(
|
||||
taskId: string,
|
||||
sessionJson: Record<string, unknown> | null,
|
||||
/**
|
||||
* Stamp pi-ai's transient per-turn state (session resume params,
|
||||
* display id) onto the given agent session row. Called by the
|
||||
* worker's run-finished handler with `event.taskSessionId`.
|
||||
*/
|
||||
updateSessionParams(
|
||||
sessionId: string,
|
||||
sessionParams: Record<string, unknown> | null,
|
||||
sessionDisplayId: string | null,
|
||||
): void {
|
||||
const ts = nowIso();
|
||||
const sessionParamsJson = sessionJson ? JSON.stringify(sessionJson) : null;
|
||||
const taskRow = getDb()
|
||||
.prepare(
|
||||
"select company_id as companyId, assignee_agent_id as assigneeAgentId, active_session_id as activeSessionId from tasks where id = ?",
|
||||
)
|
||||
.get(taskId) as
|
||||
| { companyId: string; assigneeAgentId: string | null; activeSessionId: string | null }
|
||||
| undefined;
|
||||
if (!taskRow) throw new Error(`Task not found: ${taskId}`);
|
||||
if (taskRow.activeSessionId) {
|
||||
getDb()
|
||||
.prepare(
|
||||
"update task_sessions set session_params_json = ?, session_display_id = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(sessionParamsJson, sessionDisplayId, ts, taskRow.activeSessionId);
|
||||
return;
|
||||
}
|
||||
if (sessionParamsJson === null && sessionDisplayId === null) return;
|
||||
const agentId = taskRow.assigneeAgentId ?? "unassigned";
|
||||
const agent = taskRow.assigneeAgentId
|
||||
? (getDb()
|
||||
.prepare("select connector_id as connectorId from agents where id = ?")
|
||||
.get(taskRow.assigneeAgentId) as { connectorId: string } | undefined)
|
||||
: undefined;
|
||||
const adapterType = agent?.connectorId ?? "unknown";
|
||||
const sessionId = randomUUID();
|
||||
const sessionParamsJson = sessionParams ? JSON.stringify(sessionParams) : null;
|
||||
getDb()
|
||||
.prepare(
|
||||
`insert into task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
"update task_sessions set session_params_json = ?, session_display_id = ?, updated_at = ? where id = ?",
|
||||
)
|
||||
.run(
|
||||
sessionId,
|
||||
taskRow.companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
adapterType,
|
||||
sessionParamsJson,
|
||||
sessionDisplayId,
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
getDb()
|
||||
.prepare("update tasks set active_session_id = ?, updated_at = ? where id = ?")
|
||||
.run(sessionId, ts, taskId);
|
||||
},
|
||||
|
||||
getTaskSession(taskId: string): {
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
sessionDisplayId: string | null;
|
||||
} {
|
||||
const taskRow = getDb()
|
||||
.prepare("select active_session_id as activeSessionId from tasks where id = ?")
|
||||
.get(taskId) as { activeSessionId: string | null } | undefined;
|
||||
if (!taskRow?.activeSessionId) {
|
||||
return { sessionParams: null, sessionDisplayId: null };
|
||||
}
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select session_params_json as sessionParamsJson, session_display_id as sessionDisplayId from task_sessions where id = ?",
|
||||
)
|
||||
.get(taskRow.activeSessionId) as
|
||||
| { sessionParamsJson: string | null; sessionDisplayId: string | null }
|
||||
| undefined;
|
||||
let sessionParams: Record<string, unknown> | null = null;
|
||||
if (row?.sessionParamsJson) {
|
||||
sessionParams = JSON.parse(row.sessionParamsJson) as Record<string, unknown>;
|
||||
}
|
||||
return {
|
||||
sessionParams,
|
||||
sessionDisplayId: row?.sessionDisplayId ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
getActiveTaskSession(taskId: string): TaskSessionRecord | null {
|
||||
const task = getDb()
|
||||
.prepare("SELECT active_session_id FROM tasks WHERE id = ?")
|
||||
.get(taskId) as { active_session_id: string | null } | undefined;
|
||||
if (!task?.active_session_id) return 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(task.active_session_id) as TaskSessionRecord | undefined) ?? null
|
||||
);
|
||||
.run(sessionParamsJson, sessionDisplayId, ts, sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Fetch a session by its primary key. This is the single lookup
|
||||
* path — the rotation evaluator, the worker's finalizer, and the
|
||||
* turn-context builder all know which session id they want because
|
||||
* they either receive it from the agent record or from the worker
|
||||
* event envelope.
|
||||
*/
|
||||
getSessionById(sessionId: string): TaskSessionRecord | null {
|
||||
return (
|
||||
(getDb()
|
||||
.prepare(
|
||||
`SELECT id, company_id as companyId, task_id as taskId, agent_id as agentId,
|
||||
`SELECT id, company_id as companyId, 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,
|
||||
@@ -175,17 +90,10 @@ export function buildTaskSessionMethods(
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the agent's active session id, creating one lazily on first
|
||||
* call. Session is scoped to the agent (not to any particular task);
|
||||
* switching tasks emits a user turn on the ongoing transcript rather
|
||||
* than spawning a new session.
|
||||
*
|
||||
* Existing agents with pre-migration per-task sessions start fresh
|
||||
* after this upgrade: their first run creates a new agent-scoped
|
||||
* session. The old task_sessions + session_entries rows 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 merge on a live
|
||||
* production DB.
|
||||
* Return the agent's active session id, creating one lazily on
|
||||
* first call. One session per agent — switching tasks emits a
|
||||
* "now work on X" user turn on the ongoing transcript rather than
|
||||
* spawning a new session.
|
||||
*/
|
||||
ensureAgentSession(input: {
|
||||
agentId: string;
|
||||
@@ -200,15 +108,12 @@ export function buildTaskSessionMethods(
|
||||
const sessionId = randomUUID();
|
||||
const ts = nowIso();
|
||||
transaction(() => {
|
||||
// task_id is left as empty string — the session is agent-scoped;
|
||||
// the column persists for backwards-compatible schema but no
|
||||
// longer keys the session to a particular task.
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type,
|
||||
`INSERT INTO task_sessions (id, company_id, agent_id, adapter_type,
|
||||
session_params_json, session_display_id, run_count, total_input_tokens,
|
||||
total_output_tokens, created_at, updated_at)
|
||||
VALUES (?, ?, '', ?, ?, NULL, NULL, 0, 0, 0, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, 0, 0, 0, ?, ?)`,
|
||||
)
|
||||
.run(sessionId, input.companyId, input.agentId, input.adapterType, ts, ts);
|
||||
getDb()
|
||||
@@ -226,21 +131,11 @@ export function buildTaskSessionMethods(
|
||||
},
|
||||
|
||||
/**
|
||||
* Point every task currently assigned to an agent at the agent's new
|
||||
* active session. Called after session rotation so UI paths reading
|
||||
* per-task active_session_id still see the agent's current session.
|
||||
* Create a replacement session row after rotation. Inherits the
|
||||
* agent / company / adapter type from the caller; session params
|
||||
* and display id carry forward so the next pi-ai turn can resume.
|
||||
*/
|
||||
updateTasksActiveSessionForAgent(agentId: string, sessionId: string): void {
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
"UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE assignee_agent_id = ?",
|
||||
)
|
||||
.run(sessionId, ts, agentId);
|
||||
},
|
||||
|
||||
createNewSession(input: {
|
||||
taskId: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
@@ -251,14 +146,13 @@ export function buildTaskSessionMethods(
|
||||
const ts = nowIso();
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type,
|
||||
`INSERT INTO task_sessions (id, company_id, agent_id, adapter_type,
|
||||
session_params_json, session_display_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
sessionId,
|
||||
input.companyId,
|
||||
input.taskId,
|
||||
input.agentId,
|
||||
input.adapterType,
|
||||
input.sessionParamsJson,
|
||||
@@ -266,9 +160,6 @@ export function buildTaskSessionMethods(
|
||||
ts,
|
||||
ts,
|
||||
);
|
||||
getDb()
|
||||
.prepare("UPDATE tasks SET active_session_id = ?, updated_at = ? WHERE id = ?")
|
||||
.run(sessionId, ts, input.taskId);
|
||||
return sessionId;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,8 +26,6 @@ export type TaskListByStatusesRow = {
|
||||
status: string;
|
||||
priority: string;
|
||||
activeRunId: string | null;
|
||||
sessionJson: string | null;
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number | null;
|
||||
requiresUserReview: boolean;
|
||||
startedAt: string | null;
|
||||
|
||||
@@ -107,7 +107,6 @@ export function buildTaskStatusMethods(
|
||||
t.parent_id as parentId, t.title, t.description,
|
||||
t.assignee_agent_id as assigneeAgentId, t.workspace_id as workspaceId,
|
||||
t.status, t.priority, t.active_run_id as activeRunId,
|
||||
t.session_json as sessionJson, t.session_display_id as sessionDisplayId,
|
||||
t.cost_usd as costUsd,
|
||||
t.created_at as createdAt, t.updated_at as updatedAt
|
||||
from tasks t
|
||||
|
||||
@@ -154,9 +154,6 @@ function resolveSession(
|
||||
companyId,
|
||||
adapterType: connectorId,
|
||||
});
|
||||
// Mirror the agent's active session onto the task pointer so UI paths
|
||||
// that read per-task active_session_id keep working.
|
||||
db.updateTasksActiveSessionForAgent(agent.id, agentSessionId);
|
||||
|
||||
// Evaluate rotation against the agent-scoped session unconditionally.
|
||||
// Heartbeat runs (no task) must rotate too, or their transcript grows
|
||||
@@ -175,18 +172,19 @@ function resolveSession(
|
||||
}
|
||||
|
||||
// `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.
|
||||
// stamps onto the session row after a turn (e.g., Gemini's resume id).
|
||||
// They live on the agent session; read directly by session id.
|
||||
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;
|
||||
}
|
||||
const currentSession = db.getSessionById(currentSessionId);
|
||||
if (currentSession?.sessionParamsJson) {
|
||||
sessionParams = JSON.parse(currentSession.sessionParamsJson) as Record<string, unknown>;
|
||||
sessionDisplayId = currentSession.sessionDisplayId;
|
||||
}
|
||||
// Suppress the unused-parameter warning while keeping the signature
|
||||
// stable — task identity is reported through other fields on the
|
||||
// payload, not through the session row anymore.
|
||||
void task;
|
||||
|
||||
return {
|
||||
taskSession: { id: currentSessionId, sessionParams, sessionDisplayId },
|
||||
|
||||
@@ -148,10 +148,8 @@ export function createSessionManagementService({ db, logger }: SessionManagement
|
||||
db.archiveSession(session.id, reason, handoff);
|
||||
|
||||
// 2. Create new session row with fresh counters, carry forward
|
||||
// session_params. task_id is empty because the session is scoped
|
||||
// to the agent, not to any particular task.
|
||||
// session_params so pi-ai can resume.
|
||||
const newSessionId = db.createNewSession({
|
||||
taskId: "",
|
||||
companyId: session.companyId,
|
||||
agentId: session.agentId,
|
||||
adapterType: session.adapterType,
|
||||
@@ -159,10 +157,8 @@ export function createSessionManagementService({ db, logger }: SessionManagement
|
||||
sessionDisplayId: session.sessionDisplayId,
|
||||
});
|
||||
|
||||
// 3. Point the agent and any tasks still holding the old session id
|
||||
// at the freshly-minted session.
|
||||
// 3. Point the agent at the freshly-minted session.
|
||||
db.setAgentSessionId(session.agentId, newSessionId);
|
||||
db.updateTasksActiveSessionForAgent(session.agentId, newSessionId);
|
||||
|
||||
logger.info(`[session] New session ${newSessionId} created for agent ${session.agentId}`);
|
||||
return newSessionId;
|
||||
|
||||
@@ -66,11 +66,19 @@ export function finalizeRun(
|
||||
}
|
||||
}
|
||||
|
||||
if (task) {
|
||||
// Pi-mono sessions are persisted through SqliteSessionStore via RuntimeBridge;
|
||||
// worker-reported sessionParams is an opaque JSON snapshot of whatever the
|
||||
// orchestrator wants to stamp onto the task_sessions row.
|
||||
db.updateTaskSession(task.id, event.sessionParams, event.sessionDisplayId ?? null);
|
||||
// Stamp pi-ai's transient per-turn state (session resume params,
|
||||
// display id) onto the agent's current session row. Sessions are
|
||||
// agent-scoped, so the session id comes from agents.session_id —
|
||||
// not from a task pointer (that indirection was dropped in v41).
|
||||
try {
|
||||
const agent = db.getAgent(run.agentId);
|
||||
if (agent.sessionId) {
|
||||
db.updateSessionParams(agent.sessionId, event.sessionParams, event.sessionDisplayId ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[finalizeRun] updateSessionParams failed for run ${event.runId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
leaseRenewalMap.delete(event.runId);
|
||||
|
||||
@@ -154,8 +154,6 @@ export interface TaskRecord {
|
||||
status: TaskStatus;
|
||||
priority: "critical" | "high" | "medium" | "low";
|
||||
activeRunId: string | null;
|
||||
sessionJson: string | null;
|
||||
sessionDisplayId: string | null;
|
||||
costUsd: number;
|
||||
taskType: string | null;
|
||||
requiresUserReview: boolean;
|
||||
@@ -276,7 +274,6 @@ export interface BudgetStatusSummary {
|
||||
export interface TaskSessionRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
sessionParamsJson: string | null;
|
||||
|
||||
@@ -224,7 +224,6 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
|
||||
|
||||
// 5. Session management: create session and track usage
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId: s.companyId,
|
||||
agentId: s.agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -240,7 +239,7 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
|
||||
const task = db.getTask(taskId);
|
||||
expect(task.costUsd).toBeGreaterThan(0);
|
||||
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.totalInputTokens).toBe(5000);
|
||||
expect(session!.totalOutputTokens).toBe(2000);
|
||||
@@ -382,7 +381,6 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
|
||||
|
||||
// Create session
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId: s.companyId,
|
||||
agentId: s.agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -411,7 +409,7 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
|
||||
sessionService.accumulateTokenUsage(sessionId, 3000, 1000, runId2);
|
||||
|
||||
// Verify accumulated session state
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.runCount).toBe(2);
|
||||
expect(session!.totalInputTokens).toBe(8000);
|
||||
|
||||
@@ -442,7 +442,6 @@ describe("Process Recovery -- Context Preservation (D-02)", () => {
|
||||
|
||||
// Create a task session (simulating what happens during normal execution)
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId: s.companyId,
|
||||
agentId: s.agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -461,7 +460,7 @@ describe("Process Recovery -- Context Preservation (D-02)", () => {
|
||||
|
||||
// Get the session and evaluate rotation with maxRuns:1 override
|
||||
// (so it triggers rotation since we did 1 run)
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
|
||||
const rotationResult = sessionService.evaluateRotation(
|
||||
|
||||
@@ -116,7 +116,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
|
||||
// Create a session
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -130,7 +129,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
}
|
||||
|
||||
// Get the updated session from DB
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.runCount).toBe(DEFAULT_THRESHOLDS.maxRuns);
|
||||
|
||||
@@ -145,7 +144,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -157,7 +155,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
// DEFAULT_THRESHOLDS.maxTokens = 500_000 (total input + output)
|
||||
db.incrementSessionUsage(sessionId, 300000, 250000, "big-run");
|
||||
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
const totalTokens = session!.totalInputTokens + session!.totalOutputTokens;
|
||||
expect(totalTokens).toBe(550000);
|
||||
@@ -172,7 +170,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -205,7 +202,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const session = db.getSessionById(sessionId)!;
|
||||
// generateHandoff is agent-scoped: it walks the agent's runs across
|
||||
// all tasks + taskless heartbeats, not a single task.
|
||||
const handoff = sessionService.generateHandoff(session);
|
||||
@@ -230,7 +227,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
|
||||
// Create initial session
|
||||
const oldSessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -246,7 +242,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
|
||||
// Create new session
|
||||
const newSessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -255,7 +250,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
});
|
||||
|
||||
// Get the new active session
|
||||
const newSession = db.getActiveTaskSession(taskId);
|
||||
const newSession = db.getSessionById(newSessionId);
|
||||
expect(newSession).not.toBeNull();
|
||||
expect(newSession!.id).toBe(newSessionId);
|
||||
expect(newSession!.id).not.toBe(oldSessionId);
|
||||
@@ -271,7 +266,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude-pro",
|
||||
@@ -284,7 +278,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
db.incrementSessionUsage(sessionId, 30000, 20000, `run-${i}`);
|
||||
}
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const session = db.getSessionById(sessionId)!;
|
||||
// Pi-mono delegates compaction to pi-agent-core; AgentCompany still applies rotation uniformly.
|
||||
const result = sessionService.evaluateRotation(session, "claude-pro");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
@@ -296,7 +290,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "openai-codex",
|
||||
@@ -309,7 +302,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
db.incrementSessionUsage(sessionId, 30000, 20000, `run-${i}`);
|
||||
}
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const session = db.getSessionById(sessionId)!;
|
||||
// Pi-mono delegates compaction to pi-agent-core; AgentCompany still applies rotation uniformly.
|
||||
const result = sessionService.evaluateRotation(session, "openai-codex");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
@@ -322,7 +315,6 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
|
||||
// Create session and add usage
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -352,7 +344,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
});
|
||||
|
||||
// Rotate the session (archives old, creates new)
|
||||
const oldSession = db.getActiveTaskSession(taskId)!;
|
||||
const oldSession = db.getSessionById(sessionId)!;
|
||||
sessionService.rotateSession(oldSession);
|
||||
|
||||
// After rotation, the handoff should be retrievable for the next run
|
||||
|
||||
@@ -95,16 +95,14 @@ describe("task_sessions CRUD", () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("getActiveTaskSession returns null when active_session_id is null", () => {
|
||||
const { taskId } = scaffold(db);
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
it("getSessionById returns null for an unknown id", () => {
|
||||
const session = db.getSessionById("nonexistent");
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it("getActiveTaskSession returns TaskSessionRecord when valid session exists", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
it("getSessionById returns TaskSessionRecord when valid session exists", () => {
|
||||
const { companyId, agentId } = scaffold(db);
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude-pro",
|
||||
@@ -112,10 +110,9 @@ describe("task_sessions CRUD", () => {
|
||||
sessionDisplayId: "claude-abc123",
|
||||
});
|
||||
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.id).toBe(sessionId);
|
||||
expect(session!.taskId).toBe(taskId);
|
||||
expect(session!.companyId).toBe(companyId);
|
||||
expect(session!.agentId).toBe(agentId);
|
||||
expect(session!.adapterType).toBe("claude-pro");
|
||||
@@ -130,9 +127,8 @@ describe("task_sessions CRUD", () => {
|
||||
});
|
||||
|
||||
it("incrementSessionUsage atomically updates counters", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
const { companyId, agentId } = scaffold(db);
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude-pro",
|
||||
@@ -142,7 +138,7 @@ describe("task_sessions CRUD", () => {
|
||||
|
||||
db.incrementSessionUsage(sessionId, 1000, 500, "run-001");
|
||||
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session!.runCount).toBe(1);
|
||||
expect(session!.totalInputTokens).toBe(1000);
|
||||
expect(session!.totalOutputTokens).toBe(500);
|
||||
@@ -150,7 +146,7 @@ describe("task_sessions CRUD", () => {
|
||||
|
||||
// Second increment accumulates
|
||||
db.incrementSessionUsage(sessionId, 2000, 800, "run-002");
|
||||
const session2 = db.getActiveTaskSession(taskId);
|
||||
const session2 = db.getSessionById(sessionId);
|
||||
expect(session2!.runCount).toBe(2);
|
||||
expect(session2!.totalInputTokens).toBe(3000);
|
||||
expect(session2!.totalOutputTokens).toBe(1300);
|
||||
@@ -158,9 +154,8 @@ describe("task_sessions CRUD", () => {
|
||||
});
|
||||
|
||||
it("archiveSession sets rotated_at, rotation_reason, handoff_markdown", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
const { companyId, agentId } = scaffold(db);
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude-pro",
|
||||
@@ -170,17 +165,16 @@ describe("task_sessions CRUD", () => {
|
||||
|
||||
db.archiveSession(sessionId, "max_runs", "# Handoff\nSession reached 20 runs.");
|
||||
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session!.rotatedAt).toBeTruthy();
|
||||
expect(session!.rotationReason).toBe("max_runs");
|
||||
expect(session!.handoffMarkdown).toBe("# Handoff\nSession reached 20 runs.");
|
||||
});
|
||||
|
||||
it("createNewSession creates session and updates tasks.active_session_id", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
it("createNewSession returns an id; adapter type persists", () => {
|
||||
const { companyId, agentId } = scaffold(db);
|
||||
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -191,8 +185,7 @@ describe("task_sessions CRUD", () => {
|
||||
expect(typeof sessionId).toBe("string");
|
||||
expect(sessionId.length).toBeGreaterThan(0);
|
||||
|
||||
// active_session_id should point to new session
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.id).toBe(sessionId);
|
||||
expect(session!.adapterType).toBe("gemini-cli");
|
||||
@@ -297,7 +290,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
@@ -323,7 +315,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
@@ -349,7 +340,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
@@ -376,7 +366,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
@@ -407,7 +396,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "claude-pro",
|
||||
sessionParamsJson: null,
|
||||
@@ -438,7 +426,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
@@ -464,7 +451,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
@@ -490,7 +476,6 @@ describe("session management service", () => {
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
@@ -524,7 +509,6 @@ describe("session management service", () => {
|
||||
it("accumulateTokenUsage calls db.incrementSessionUsage with correct args", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude-pro",
|
||||
@@ -535,7 +519,7 @@ describe("session management service", () => {
|
||||
|
||||
svc.accumulateTokenUsage(sessionId, 5000, 2000, "run-abc");
|
||||
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
const session = db.getSessionById(sessionId);
|
||||
expect(session!.runCount).toBe(1);
|
||||
expect(session!.totalInputTokens).toBe(5000);
|
||||
expect(session!.totalOutputTokens).toBe(2000);
|
||||
@@ -550,7 +534,6 @@ describe("session management service", () => {
|
||||
it("generateHandoff produces markdown with task title and run history", () => {
|
||||
const { taskId, companyId, agentId, workspaceId } = scaffold(db);
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -575,7 +558,7 @@ describe("session management service", () => {
|
||||
// Increment session usage to have stats
|
||||
db.incrementSessionUsage(sessionId, 10000, 5000, run.id);
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const session = db.getSessionById(sessionId)!;
|
||||
const svc = createSessionManagementService({ db, logger: mockLogger });
|
||||
// Handoff is now agent-scoped: it walks the agent's recent runs
|
||||
// (across tasks + heartbeats) rather than a single task. The task
|
||||
@@ -598,7 +581,6 @@ describe("session management service", () => {
|
||||
it("rotateSession archives old session, creates new, copies session_params", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
const oldSessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -609,7 +591,7 @@ describe("session management service", () => {
|
||||
// Add some usage
|
||||
db.incrementSessionUsage(oldSessionId, 300000, 200000, "run-10");
|
||||
|
||||
const oldSession = db.getActiveTaskSession(taskId)!;
|
||||
const oldSession = db.getSessionById(oldSessionId)!;
|
||||
const svc = createSessionManagementService({ db, logger: mockLogger });
|
||||
const newSessionId = svc.rotateSession(oldSession);
|
||||
|
||||
@@ -624,8 +606,8 @@ describe("session management service", () => {
|
||||
expect(archivedRows[0].rotationReason).toBe("threshold_exceeded");
|
||||
expect(archivedRows[0].handoffMarkdown).toContain("# Session Handoff");
|
||||
|
||||
// New session should be active, with fresh counters and copied params
|
||||
const newSession = db.getActiveTaskSession(taskId)!;
|
||||
// New session is a distinct row, with fresh counters and copied params
|
||||
const newSession = db.getSessionById(newSessionId)!;
|
||||
expect(newSession.id).toBe(newSessionId);
|
||||
expect(newSession.runCount).toBe(0);
|
||||
expect(newSession.totalInputTokens).toBe(0);
|
||||
@@ -645,7 +627,6 @@ describe("session management service", () => {
|
||||
|
||||
// Create and rotate a session
|
||||
const session1Id = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -653,7 +634,7 @@ describe("session management service", () => {
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
db.incrementSessionUsage(session1Id, 100000, 50000, "run-1");
|
||||
const session1 = db.getActiveTaskSession(taskId)!;
|
||||
const session1 = db.getSessionById(session1Id)!;
|
||||
svc.rotateSession(session1);
|
||||
|
||||
const handoff = svc.getHandoffContext(taskId);
|
||||
@@ -664,7 +645,6 @@ describe("session management service", () => {
|
||||
it("getHandoffContext returns null when no rotated sessions exist", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -685,7 +665,6 @@ describe("session management service", () => {
|
||||
it("getUsageDelta returns token difference between snapshot and current totals", () => {
|
||||
const { taskId, companyId, agentId } = scaffold(db);
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini-cli",
|
||||
@@ -696,7 +675,7 @@ describe("session management service", () => {
|
||||
// Pre-populate some usage
|
||||
db.incrementSessionUsage(sessionId, 1000, 500, "run-1");
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
const session = db.getSessionById(sessionId)!;
|
||||
const svc = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
// Snapshot current state
|
||||
|
||||
Reference in new issue
Block a user