These columns and the messages that plumbed them existed for the CLI-
adapter era — Gemini's resume id and provider-specific session params
rode the run-finished envelope back onto the session row so the next
turn could resume. Pi Mono dispatches every provider through pi-ai and
emits neither value. The worker has been posting literal null on every
finish, finalizeRun has been writing null into task_sessions, and
rotateSession has been carrying null forward.
Remove the wire, the writer, the carry-forward, and the columns:
- v43 migration rebuilds task_sessions without session_params_json /
session_display_id, and runs without session_display_id. Follows
the idempotent "create new, copy, swap, reindex" pattern used by
v41. context_snapshot_json is preserved (also unreferenced today
but outside this cleanup's scope).
- RunJob / EnqueueRunPayload / ChildMessage / worker-types zod
schema no longer carry the fields. finishRun() / createNewSession()
signatures tighten in lockstep.
- rotateSession starts the new session with fresh counters and no
carry-forward.
- turn-context resolveSession returns just the session id.
- TaskSessionRecord / RunRecord lose the dead fields.
498/498 unit tests pass. typecheck + build clean.
235 lines
9.5 KiB
TypeScript
235 lines
9.5 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { AppDatabase } from "../database";
|
|
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,
|
|
resolveWorkspace: (task: { workspaceId: string | null; projectId?: string | null } | null, agent: { workspaceId: string | null }, companyId: string) => { workspace: ReturnType<AppDatabase["getWorkspace"]>; lockMode: "exclusive" | "shared" | "none"; source: string },
|
|
getRunEligibilityFailure: (agent: AgentRecord, connector: ConnectorRecord, projectId?: string | null) => string | null,
|
|
dispatchRunToWorker: (params: RunDispatchParams) => void,
|
|
hasActiveRun: (filter: { taskId?: string; agentId?: string; companyId: string }) => boolean,
|
|
) {
|
|
const {
|
|
db,
|
|
logger,
|
|
emitEvent,
|
|
hasWorkerReady,
|
|
isQuitting,
|
|
} = deps;
|
|
|
|
function ok<T>(data: T): DesktopResult<T> {
|
|
return { ok: true, data };
|
|
}
|
|
|
|
function fail(code: string, message: string): DesktopResult<never> {
|
|
return { ok: false, error: { code, message } };
|
|
}
|
|
|
|
function queueTaskRun(taskId: string, trigger: string, actor: string): DesktopResult<string> {
|
|
if (isQuitting()) {
|
|
return fail("APP_SHUTTING_DOWN", "Application is shutting down.");
|
|
}
|
|
if (!hasWorkerReady()) {
|
|
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
|
|
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
|
|
}
|
|
const task = db.getTask(taskId);
|
|
const companyStatus = db.getCompanyStatus(task.companyId);
|
|
if (companyStatus !== "active") {
|
|
return fail("COMPANY_NOT_ACTIVE", "Runs can only start while the company is active.");
|
|
}
|
|
if (!task.assigneeAgentId) return fail("TASK_UNASSIGNED", "Assign an agent before starting a run.");
|
|
const agent = db.getAgent(task.assigneeAgentId);
|
|
if (agent.companyId !== task.companyId) {
|
|
return fail("AGENT_COMPANY_MISMATCH", "The assigned agent does not belong to the task company.");
|
|
}
|
|
const assignment = pickModelForAgent(agent, {
|
|
getConnector: (id) => db.getConnector(id),
|
|
getDisabledModels: (id) => db.getDisabledModels(agent.companyId, 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.");
|
|
}
|
|
if (eligibilityFailure === "BUDGET_POLICY_BLOCKED") {
|
|
return fail("BUDGET_POLICY_BLOCKED", "A budget policy blocks this run. Review budget settings.");
|
|
}
|
|
if (eligibilityFailure === "CONNECTOR_NOT_READY") {
|
|
return fail("CONNECTOR_NOT_READY", "The assigned agent connector is not ready.");
|
|
}
|
|
if (eligibilityFailure === "CONNECTOR_MODEL_NOT_ALLOWED") {
|
|
return fail("CONNECTOR_MODEL_NOT_ALLOWED", "The assigned agent model is disabled or invalid for its connector.");
|
|
}
|
|
if (hasActiveRun({ agentId: agent.id, companyId: task.companyId })) {
|
|
return fail("AGENT_ALREADY_RUNNING", "The assigned agent already has an active run.");
|
|
}
|
|
if (db.isAgentPendingApproval(agent.id)) {
|
|
return fail("AGENT_PENDING_APPROVAL", "This agent is pending hire approval and cannot run tasks.");
|
|
}
|
|
const blockingApproval = db.getBlockingApproval(task.companyId, agent.id, task.id);
|
|
if (blockingApproval) {
|
|
return fail(
|
|
"APPROVAL_GATE_BLOCKED",
|
|
`Run blocked by pending approval: ${blockingApproval.type} — "${blockingApproval.payloadSummary}". Approve or reject before starting.`,
|
|
);
|
|
}
|
|
|
|
// CAS checkout — atomic claim (EXEC-01, EXEC-02)
|
|
const runId = randomUUID();
|
|
const checkoutSuccess = db.checkoutTask(task.id, runId);
|
|
if (!checkoutSuccess) {
|
|
return fail("CHECKOUT_RACE_LOST", "Another run claimed this task. Refresh and try again.");
|
|
}
|
|
|
|
const workspaceResolution = resolveWorkspace(task, agent, task.companyId);
|
|
const workspace = workspaceResolution.workspace;
|
|
if (workspace && workspace.companyId !== task.companyId) {
|
|
return fail("WORKSPACE_COMPANY_MISMATCH", "The selected workspace does not belong to the task company.");
|
|
}
|
|
|
|
const run = db.createRun({
|
|
id: runId,
|
|
companyId: task.companyId,
|
|
taskId: task.id,
|
|
agentId: agent.id,
|
|
workspaceId: workspaceResolution.lockMode === "exclusive" ? (workspace?.id ?? null) : null,
|
|
connectorId: connector.id,
|
|
});
|
|
|
|
const companyBasic = db.getCompanyBasic(task.companyId);
|
|
dispatchRunToWorker({
|
|
runId: run.id,
|
|
companyId: task.companyId,
|
|
agent,
|
|
task,
|
|
connector,
|
|
workspace,
|
|
workspaceMode: workspaceResolution.lockMode,
|
|
companyName: companyBasic?.name ?? "Unknown",
|
|
companyDescription: companyBasic?.description ?? "",
|
|
snapshot: db.listSnapshot(),
|
|
trigger,
|
|
resolvedModel: assignment.modelId,
|
|
});
|
|
db.addActivity({
|
|
companyId: task.companyId,
|
|
actor,
|
|
action: "run.queued",
|
|
entityType: "run",
|
|
entityId: run.id,
|
|
detail: task.title,
|
|
});
|
|
emitEvent({ type: "run-updated", runId: run.id, action: "created" });
|
|
return ok(run.id);
|
|
}
|
|
|
|
function resumeQueuedRuns() {
|
|
// D-02: Stale lock recovery at app startup (EXEC-03)
|
|
try {
|
|
const recovered = db.recoverStaleLocks();
|
|
for (const task of recovered) {
|
|
db.addActivity({
|
|
companyId: task.companyId,
|
|
actor: "system",
|
|
action: "execution.lock_recovered",
|
|
entityType: "task",
|
|
entityId: task.taskId,
|
|
detail: `Recovered stale lock on task "${task.title}" after 15min timeout (startup sweep)`,
|
|
});
|
|
logger.info(`[init] Recovered stale lock on task ${task.taskId}: "${task.title}"`);
|
|
}
|
|
if (recovered.length > 0) {
|
|
logger.info(`[init] Recovered ${recovered.length} stale lock(s) at startup`);
|
|
}
|
|
} catch (err) {
|
|
logger.error(`[init] Stale lock recovery at startup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
if (isQuitting() || !hasWorkerReady()) {
|
|
return;
|
|
}
|
|
const snapshot = db.listSnapshot();
|
|
const companiesById = new Map(snapshot.companies.map((company) => [company.id, company]));
|
|
|
|
for (const run of db.listQueuedRuns()) {
|
|
const company = companiesById.get(run.companyId);
|
|
if (!company || company.status !== "active") {
|
|
continue;
|
|
}
|
|
|
|
const hasBlockingSiblingRun = snapshot.runs.some((entry) =>
|
|
entry.id !== run.id &&
|
|
entry.companyId === run.companyId &&
|
|
["queued", "running"].includes(entry.status) &&
|
|
(entry.taskId === run.taskId || entry.agentId === run.agentId),
|
|
);
|
|
if (hasBlockingSiblingRun) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const task = run.taskId ? db.getTask(run.taskId) : null;
|
|
const agent = db.getAgent(run.agentId);
|
|
const connector = db.getConnector(run.connectorId);
|
|
const eligibilityFailure = getRunEligibilityFailure(agent, connector, task?.projectId ?? null);
|
|
if (eligibilityFailure) {
|
|
if (eligibilityFailure === "CONNECTOR_MODEL_NOT_ALLOWED") {
|
|
db.finishRun({
|
|
runId: run.id,
|
|
status: "failed",
|
|
summary: "Queued run rejected during recovery because the agent model is disabled or invalid for its connector.",
|
|
errorMessage: "Connector model is disabled or invalid for this agent.",
|
|
exitCode: null,
|
|
signal: null,
|
|
model: null,
|
|
unattributedCost: true,
|
|
});
|
|
db.addActivity({
|
|
companyId: run.companyId,
|
|
actor: "system",
|
|
action: "run.recovery_blocked",
|
|
entityType: "run",
|
|
entityId: run.id,
|
|
detail: `Recovered queued run blocked by connector/model policy for task "${task?.title ?? "(heartbeat)"}"`,
|
|
});
|
|
emitEvent({ type: "run-updated", runId: run.id, action: "completed" });
|
|
}
|
|
continue;
|
|
}
|
|
const workspaceResolution = resolveWorkspace(task ?? null, agent, run.companyId);
|
|
dispatchRunToWorker({
|
|
runId: run.id,
|
|
companyId: run.companyId,
|
|
agent,
|
|
task,
|
|
connector,
|
|
workspace: workspaceResolution.workspace,
|
|
workspaceMode: workspaceResolution.lockMode,
|
|
companyName: company.name,
|
|
companyDescription: company.description,
|
|
snapshot,
|
|
trigger: "recovery",
|
|
});
|
|
} catch (error) {
|
|
logger.warn(`[run] Skipped queued run recovery for ${run.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { queueTaskRun, resumeQueuedRuns };
|
|
}
|