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:
1 parent
61fb4b947f
commit
ff5e22092a
45 files changed
+3409
-300
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user