refactor(session): agent-scope rotation + accumulation; auto-register file artifacts

Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.

- DB adds getSessionById(sessionId) — direct lookup bypassing
  tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
  walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
  is the canonical lookup, getHandoffContext(taskId) is a thin shim for
  callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
  rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
  gate: tokens accumulate and rotation fires for every run, keyed on the
  agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
  connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
  signature removed

IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.

Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.

File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.

Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-18 21:37:56 -04:00
1 parent 61fb4b947f
commit ff5e22092a
45 files changed
+3409 -300

No files matched your search

+44 -36
View File
@@ -29,6 +29,14 @@ export interface RunDispatchParams {
companyDescription: string;
snapshot: ReturnType<AppDatabase["listSnapshot"]>;
trigger: string;
/**
* Model id resolved by `pickModelForAgent` at dispatch time. When the
* agent runs under `modelStrategy = "auto"` this differs from
* `agent.model` (the column now becomes a legacy fallback). Optional so
* explicit-strategy callers and legacy tests keep working; when absent
* we read `agent.model` verbatim.
*/
resolvedModel?: string | null;
}
export interface TurnContextDeps {
@@ -37,10 +45,11 @@ export interface TurnContextDeps {
getApiPort: () => number;
createRunToken: (agentId: string, companyId: string, runId: string) => string;
listRuntimeSkillEntries: (companyId: string, agentId: string, connectorId: string) => ProjectedSkill[];
getSessionHandoff?: (taskId: string) => string | null;
evaluatePreRunSessionRotation?: (taskId: string, connectorId: string) => {
getSessionHandoff?: (agentId: string) => string | null;
evaluatePreRunSessionRotation?: (agentId: string, connectorId: string) => {
rotated: boolean;
handoff: string | null;
sessionId: string | null;
};
}
@@ -94,14 +103,14 @@ function assembleRecentComments(
}
function resolveSessionHandoff(
taskId: string,
agentId: string,
deps: Pick<TurnContextDeps, "getSessionHandoff" | "logger">,
): string | null {
if (!deps.getSessionHandoff) return null;
try {
return deps.getSessionHandoff(taskId);
return deps.getSessionHandoff(agentId);
} catch (err) {
deps.logger.warn(`[run] Failed to load session handoff for task ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
deps.logger.warn(`[run] Failed to load session handoff for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
@@ -138,7 +147,8 @@ function resolveSession(
deps: Pick<TurnContextDeps, "evaluatePreRunSessionRotation" | "logger">,
) {
// Per-agent session: one ongoing transcript per agent; switching tasks
// becomes a "now work on X" user turn rather than a fresh session.
// (or running taskless heartbeats) becomes a "now work on X" user turn
// on the ongoing transcript rather than starting a fresh session.
const agentSessionId = db.ensureAgentSession({
agentId: agent.id,
companyId,
@@ -148,35 +158,15 @@ function resolveSession(
// that read per-task active_session_id keep working.
db.updateTasksActiveSessionForAgent(agent.id, agentSessionId);
// Heartbeat runs have no task; reuse the agent-scoped session with no
// task-specific active session lookup.
if (!task) {
return {
taskSession: { id: agentSessionId, sessionParams: null, sessionDisplayId: null },
sessionHandoff,
};
}
const activeSession = db.getActiveTaskSession(task.id);
let taskSession: {
id: string | null;
sessionParams: Record<string, unknown> | null;
sessionDisplayId: string | null;
} = activeSession
? {
id: activeSession.id,
sessionParams: activeSession.sessionParamsJson
? (JSON.parse(activeSession.sessionParamsJson) as Record<string, unknown>)
: null,
sessionDisplayId: activeSession.sessionDisplayId,
}
: { id: agentSessionId, sessionParams: null, sessionDisplayId: null };
// Evaluate rotation against the agent-scoped session unconditionally.
// Heartbeat runs (no task) must rotate too, or their transcript grows
// forever — the exact failure mode that burned through 7M token windows.
let currentSessionId = agentSessionId;
let finalHandoff = sessionHandoff;
if (deps.evaluatePreRunSessionRotation && activeSession) {
const rotationResult = deps.evaluatePreRunSessionRotation(task.id, connectorId);
if (deps.evaluatePreRunSessionRotation) {
const rotationResult = deps.evaluatePreRunSessionRotation(agent.id, connectorId);
if (rotationResult.rotated) {
taskSession = { id: null, sessionParams: null, sessionDisplayId: null };
currentSessionId = rotationResult.sessionId ?? agentSessionId;
if (!finalHandoff && rotationResult.handoff) {
finalHandoff = rotationResult.handoff;
}
@@ -184,7 +174,24 @@ function resolveSession(
}
}
return { taskSession, sessionHandoff: finalHandoff };
// `sessionParams` / `sessionDisplayId` are transient per-run state pi-ai
// stamps onto the task row after a turn (e.g., Gemini's resume id). They
// are only meaningful when a task's cached pointer still matches the
// current (unrotated) session; after rotation the new session has none.
let sessionParams: Record<string, unknown> | null = null;
let sessionDisplayId: string | null = null;
if (task) {
const active = db.getActiveTaskSession(task.id);
if (active && active.id === currentSessionId && active.sessionParamsJson) {
sessionParams = JSON.parse(active.sessionParamsJson) as Record<string, unknown>;
sessionDisplayId = active.sessionDisplayId;
}
}
return {
taskSession: { id: currentSessionId, sessionParams, sessionDisplayId },
sessionHandoff: finalHandoff,
};
}
function extractThinkingEffort(
@@ -284,7 +291,7 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
const assignedTasks = db.listAgentTasks(agent.id, companyId, ["todo", "in_progress", "blocked"]);
const reviewTasks = db.listReviewTasksForManager(agent.id, companyId);
const recentComments = assembleRecentComments(assignedTasks, reviewTasks, snapshot);
const initialHandoff = task ? resolveSessionHandoff(task.id, deps) : null;
const initialHandoff = resolveSessionHandoff(agent.id, deps);
const { taskSession, sessionHandoff } = resolveSession(db, agent, task, companyId, connector.id, initialHandoff, deps);
const skills = resolveSkills(companyId, agent.id, connector.id, agent.name, deps);
@@ -335,7 +342,8 @@ export function buildRunPayload(params: RunDispatchParams, deps: TurnContextDeps
}) : null;
const thinkingEffort = extractThinkingEffort(agent.metadataJson, deps.logger, agent.id);
const modelSelection = validateConnectorModelSelection(db, connector.id, agent.model);
const modelForRun = params.resolvedModel !== undefined ? params.resolvedModel : agent.model;
const modelSelection = validateConnectorModelSelection(db, connector.id, modelForRun);
if (!modelSelection.ok) {
throw new Error(modelSelection.error);
}