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

+17 -2
View File
@@ -3,6 +3,7 @@ import type { AppDatabase } from "../database";
import type { AgentRecord, ConnectorRecord } from "@shared/types";
import type { OrchestrationDependencies } from "./types";
import type { RunDispatchParams } from "./turn-context";
import { pickModelForAgent } from "../model-dispatch";
export function createHeartbeat(
deps: OrchestrationDependencies,
@@ -41,8 +42,21 @@ export function createHeartbeat(
logger.warn(`[heartbeat] Refusing cross-company run for agent ${agentId}: ${agent.companyId} !== ${companyId}`);
return null;
}
const connector = db.getConnector(agent.connectorId);
if (getRunEligibilityFailure(agent, connector, null)) {
const assignment = pickModelForAgent(agent, {
getConnector: (id) => db.getConnector(id),
getDisabledModels: (id) => db.getDisabledModels(id),
getCeoAssignment: (cid) => {
const ceo = db.getCeoForCompany(cid);
if (!ceo) return null;
const full = db.getAgent(ceo.id);
return { connectorId: full.connectorId, modelId: full.model ?? null };
},
listModelCapabilities: () => db.modelCapabilities.listModelCapabilities(),
readConnectors: () => db.connectorsCrud.readConnectors(),
});
const connector = db.getConnector(assignment.connectorId);
const effectiveAgentForEligibility: AgentRecord = { ...agent, model: assignment.modelId };
if (getRunEligibilityFailure(effectiveAgentForEligibility, connector, null)) {
return null;
}
// Extended heartbeat policy checks (Paperclip parity).
@@ -182,6 +196,7 @@ export function createHeartbeat(
companyDescription: companyBasic?.description ?? "",
snapshot: db.listSnapshot(),
trigger,
resolvedModel: assignment.modelId,
});
db.markHeartbeatAt(agentId);
+28 -6
View File
@@ -4,10 +4,32 @@ import type { WorkspaceLockMode } from "./types";
/**
* Resolve the workspace and lock semantics for a given run context.
*
* Previously this logic lived both in run-dispatch.ts (normal dispatch path)
* and worker-crash-recovery.ts (re-enqueue after crash); the recovery
* copy silently dropped the exclusive/shared distinction. Sharing the
* helper guarantees both paths agree on which runs hold exclusive locks.
* Lock policy (v0.4.168+):
*
* task.workspaceId set → exclusive
* The user (or an upstream caller) explicitly bound a task to a
* workspace, which is the opt-in signal for "this run needs the
* workspace to itself". Rare, and reserved for workflows like a
* sprint merge or release build where concurrent writes would
* corrupt state.
*
* agent.workspaceId set → shared
* project.workspaceId set → shared
* company fallback → shared
* Multiple agents sharing the *same* workspace is the normal case
* — it represents "this team works in this repo", not "this agent
* owns this repo". The previous exclusive default forced four
* co-located teammates to run one at a time, which deadlocked
* heartbeats (dispatch bailed → task stuck in_progress → retry
* lost the checkout race forever). Git itself serializes the
* dangerous operations (`git index.lock`); anything coarser here
* just blocks readers.
*
* Previously this logic lived both in run-dispatch.ts (normal dispatch
* path) and worker-crash-recovery.ts (re-enqueue after crash); the
* recovery copy silently dropped the exclusive/shared distinction.
* Sharing the helper guarantees both paths agree on which runs hold
* exclusive locks.
*/
export function resolveWorkspaceWithLockMode(
db: AppDatabase,
@@ -28,12 +50,12 @@ export function resolveWorkspaceWithLockMode(
const agentWorkspace = db.getWorkspace(agent.workspaceId);
if (agentWorkspace) {
return { workspace: agentWorkspace, lockMode: "exclusive", source: "agent" };
return { workspace: agentWorkspace, lockMode: "shared", source: "agent" };
}
const projectWorkspace = db.getProjectPrimaryWorkspace(task?.projectId ?? null, companyId);
if (projectWorkspace) {
return { workspace: projectWorkspace, lockMode: "exclusive", source: "project" };
return { workspace: projectWorkspace, lockMode: "shared", source: "project" };
}
const fallbackWorkspace = db.getAnyCompanyWorkspace(companyId);
+9 -2
View File
@@ -71,6 +71,13 @@ export function createRunDispatch(deps: OrchestrationDependencies) {
// Previously the worker claimed the lock only after it emitted `running`,
// so two runs could race between enqueue and startup and both read the
// same workspace mid-write. Main is the authoritative coordinator.
//
// On contention we mark the run `cancelled`, not `failed`. The Dashboard
// "Needs attention" card only surfaces failed/timed_out/interrupted runs
// (see src/renderer/components/dashboard/Dashboard.tsx), so the user is
// not alarmed by a transient backoff. The task row is rolled back to
// `todo` via the `finishRun` never-started branch, so heartbeat picks
// it up on the next cycle with no manual intervention.
if (
params.workspaceMode === "exclusive" &&
params.workspace &&
@@ -78,9 +85,9 @@ export function createRunDispatch(deps: OrchestrationDependencies) {
) {
db.finishRun({
runId: params.runId,
status: "failed",
status: "cancelled",
summary: null,
errorMessage: `Workspace ${params.workspace.id} is locked by another run.`,
errorMessage: `Workspace ${params.workspace.id} is in use by another run; will retry on the next heartbeat.`,
exitCode: null,
signal: null,
model: null,
+17 -2
View File
@@ -4,6 +4,7 @@ import type { AgentRecord, ConnectorRecord } from "@shared/types";
import type { DesktopResult } from "@shared/contracts";
import type { OrchestrationDependencies } from "./types";
import type { RunDispatchParams } from "./turn-context";
import { pickModelForAgent } from "../model-dispatch";
export function createTaskRunner(
deps: OrchestrationDependencies,
@@ -46,8 +47,21 @@ export function createTaskRunner(
if (agent.companyId !== task.companyId) {
return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company.");
}
const connector = db.getConnector(agent.connectorId);
const eligibilityFailure = getRunEligibilityFailure(agent, connector, task.projectId ?? null);
const assignment = pickModelForAgent(agent, {
getConnector: (id) => db.getConnector(id),
getDisabledModels: (id) => db.getDisabledModels(id),
getCeoAssignment: (cid) => {
const ceo = db.getCeoForCompany(cid);
if (!ceo) return null;
const full = db.getAgent(ceo.id);
return { connectorId: full.connectorId, modelId: full.model ?? null };
},
listModelCapabilities: () => db.modelCapabilities.listModelCapabilities(),
readConnectors: () => db.connectorsCrud.readConnectors(),
});
const connector = db.getConnector(assignment.connectorId);
const effectiveAgentForEligibility: AgentRecord = { ...agent, model: assignment.modelId };
const eligibilityFailure = getRunEligibilityFailure(effectiveAgentForEligibility, connector, task.projectId ?? null);
if (eligibilityFailure === "AGENT_NOT_RUNNABLE") {
return fail("AGENT_NOT_RUNNABLE", "The assigned agent is paused, pending approval, or terminated.");
}
@@ -109,6 +123,7 @@ export function createTaskRunner(
companyDescription: companyBasic?.description ?? "",
snapshot: db.listSnapshot(),
trigger,
resolvedModel: assignment.modelId,
});
db.addActivity({
companyId: task.companyId,
+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);
}
+8 -2
View File
@@ -71,9 +71,15 @@ export interface OrchestrationDependencies {
budgetService: ReturnType<typeof createBudgetEnforcementService>;
/** Checkpoint engine for unified co-founder mode pause decisions. */
checkpointEngine?: { shouldPauseHeartbeat: (companyId: string, agentId: string) => boolean };
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;
/**
* The session id the next run should use. Equals the freshly-minted
* session id after a rotation, or the unchanged agent session id when
* no rotation fired. Null if the agent has no session yet (first run).
*/
sessionId: string | null;
};
}