Implement task-scoped sessions, queued run chaining, and session reset API

Heartbeat service now resolves session state per-task using agentTaskSessions,
with resolveNextSessionState handling codec-based serialization and fallback
to legacy sessionId. Queued runs are chained — when a run finishes or is reaped,
the next queued run for the same agent starts automatically. Queued runs for
an agent with an already-running run wait instead of failing.

Add task-sessions list endpoint and extend reset-session to accept optional
taskKey for targeted session clearing. Block pending_approval agents from
API key auth. Update agent/company delete cascades to include task sessions.
Update spec docs with task-session architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 14:02:17 -06:00
parent d56618e9fe
commit 2acf28a51a
6 changed files with 467 additions and 46 deletions

View File

@@ -423,7 +423,7 @@ This separation keeps adapter config runtime-agnostic while allowing the heartbe
## 9.1 New table: `agent_runtime_state` ## 9.1 New table: `agent_runtime_state`
One row per agent for resumable adapter state. One row per agent for aggregate runtime counters and legacy compatibility.
- `agent_id` uuid pk fk `agents.id` - `agent_id` uuid pk fk `agents.id`
- `company_id` uuid fk not null - `company_id` uuid fk not null
@@ -441,6 +441,24 @@ One row per agent for resumable adapter state.
Invariant: exactly one runtime state row per agent. Invariant: exactly one runtime state row per agent.
## 9.1.1 New table: `agent_task_sessions`
One row per `(company_id, agent_id, adapter_type, task_key)` for resumable session state.
- `id` uuid pk
- `company_id` uuid fk not null
- `agent_id` uuid fk not null
- `adapter_type` text not null
- `task_key` text not null
- `session_params_json` jsonb null (adapter-defined shape)
- `session_display_id` text null (for UI/debug)
- `last_run_id` uuid fk `heartbeat_runs.id` null
- `last_error` text null
- `created_at` timestamptz not null
- `updated_at` timestamptz not null
Invariant: unique `(company_id, agent_id, adapter_type, task_key)`.
## 9.2 New table: `agent_wakeup_requests` ## 9.2 New table: `agent_wakeup_requests`
Queue + audit for wakeups. Queue + audit for wakeups.
@@ -662,13 +680,15 @@ On server startup:
- backward-compatible alias to wakeup API - backward-compatible alias to wakeup API
3. `GET /agents/:agentId/runtime-state` 3. `GET /agents/:agentId/runtime-state`
- board-only debug view - board-only debug view
4. `POST /agents/:agentId/runtime-state/reset-session` 4. `GET /agents/:agentId/task-sessions`
- clears stored session ID - board-only list of task-scoped adapter sessions
5. `GET /heartbeat-runs/:runId/events?afterSeq=:n` 5. `POST /agents/:agentId/runtime-state/reset-session`
- clears all task sessions for the agent, or one when `taskKey` is provided
6. `GET /heartbeat-runs/:runId/events?afterSeq=:n`
- fetch persisted lightweight timeline - fetch persisted lightweight timeline
6. `GET /heartbeat-runs/:runId/log` 7. `GET /heartbeat-runs/:runId/log`
- reads full log stream via `RunLogStore` (or redirects/presigned URL for object store) - reads full log stream via `RunLogStore` (or redirects/presigned URL for object store)
7. `GET /api/companies/:companyId/events/ws` 8. `GET /api/companies/:companyId/events/ws`
- websocket stream - websocket stream
## 13.2 Mutation logging ## 13.2 Mutation logging
@@ -726,7 +746,7 @@ All wakeup/run state mutations must create `activity_log` entries:
## 15. Acceptance Criteria ## 15. Acceptance Criteria
1. Agent with `claude-local` or `codex-local` can run, exit, and persist run result. 1. Agent with `claude-local` or `codex-local` can run, exit, and persist run result.
2. Session ID is persisted and used for next run resume automatically. 2. Session parameters are persisted per task scope and reused automatically for same-task resumes.
3. Token usage is persisted per run and accumulated per agent runtime state. 3. Token usage is persisted per run and accumulated per agent runtime state.
4. Timer, assignment, on-demand, and automation wakeups all enqueue through one coordinator. 4. Timer, assignment, on-demand, and automation wakeups all enqueue through one coordinator.
5. Pause/terminate interrupts running local process and prevents new wakeups. 5. Pause/terminate interrupts running local process and prevents new wakeups.
@@ -737,7 +757,6 @@ All wakeup/run state mutations must create `activity_log` entries:
## 16. Open Questions ## 16. Open Questions
1. Should timer default be `null` (off until enabled) or `300` seconds by default? 1. Should timer default be `null` (off until enabled) or `300` seconds by default?
2. For invalid resume session errors, should default behavior be fail-fast or auto-reset-and-retry-once? 2. What should the default retention policy be for full log objects vs Postgres metadata?
3. What should the default retention policy be for full log objects vs Postgres metadata? 3. Should agent API credentials be allowed in prompt templates by default, or require explicit opt-in toggle?
4. Should agent API credentials be allowed in prompt templates by default, or require explicit opt-in toggle? 4. Should websocket be the only realtime channel, or should we also expose SSE for simpler clients?
5. Should websocket be the only realtime channel, or should we also expose SSE for simpler clients?

View File

@@ -71,11 +71,13 @@ Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run conte
## 4. Session resume behavior ## 4. Session resume behavior
Paperclip stores session IDs for resumable adapters. Paperclip stores resumable session state per `(agent, taskKey, adapterType)`.
`taskKey` is derived from wakeup context (`taskKey`, `taskId`, or `issueId`).
- Next heartbeat reuses the saved session automatically. - A heartbeat for the same task key reuses the previous session for that task.
- This gives continuity across heartbeats. - Different task keys for the same agent keep separate session state.
- You can reset a session if context gets stale or confused. - If restore fails, adapters should retry once with a fresh session and continue.
- You can reset all sessions for an agent or reset one task session by task key.
Use session reset when: Use session reset when:

View File

@@ -6,6 +6,7 @@ import {
createAgentKeySchema, createAgentKeySchema,
createAgentHireSchema, createAgentHireSchema,
createAgentSchema, createAgentSchema,
resetAgentSessionSchema,
updateAgentPermissionsSchema, updateAgentPermissionsSchema,
wakeAgentSchema, wakeAgentSchema,
updateAgentSchema, updateAgentSchema,
@@ -357,7 +358,7 @@ export function agentRoutes(db: Db) {
res.json(state); res.json(state);
}); });
router.post("/agents/:id/runtime-state/reset-session", async (req, res) => { router.get("/agents/:id/task-sessions", async (req, res) => {
assertBoard(req); assertBoard(req);
const id = req.params.id as string; const id = req.params.id as string;
const agent = await svc.getById(id); const agent = await svc.getById(id);
@@ -367,7 +368,30 @@ export function agentRoutes(db: Db) {
} }
assertCompanyAccess(req, agent.companyId); assertCompanyAccess(req, agent.companyId);
const state = await heartbeat.resetRuntimeSession(id); const sessions = await heartbeat.listTaskSessions(id);
res.json(
sessions.map((session) => ({
...session,
sessionParamsJson: redactEventPayload(session.sessionParamsJson ?? null),
})),
);
});
router.post("/agents/:id/runtime-state/reset-session", validate(resetAgentSessionSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId);
const taskKey =
typeof req.body.taskKey === "string" && req.body.taskKey.trim().length > 0
? req.body.taskKey.trim()
: null;
const state = await heartbeat.resetRuntimeSession(id, { taskKey });
await logActivity(db, { await logActivity(db, {
companyId: agent.companyId, companyId: agent.companyId,
@@ -376,6 +400,7 @@ export function agentRoutes(db: Db) {
action: "agent.runtime_session_reset", action: "agent.runtime_session_reset",
entityType: "agent", entityType: "agent",
entityId: id, entityId: id,
details: { taskKey: taskKey ?? null },
}); });
res.json(state); res.json(state);

View File

@@ -6,6 +6,7 @@ import {
agentConfigRevisions, agentConfigRevisions,
agentApiKeys, agentApiKeys,
agentRuntimeState, agentRuntimeState,
agentTaskSessions,
agentWakeupRequests, agentWakeupRequests,
heartbeatRunEvents, heartbeatRunEvents,
heartbeatRuns, heartbeatRuns,
@@ -302,6 +303,7 @@ export function agentService(db: Db) {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id)); await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id)); await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id)); await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id)); await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id)); await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));

View File

@@ -5,6 +5,7 @@ import {
agents, agents,
agentApiKeys, agentApiKeys,
agentRuntimeState, agentRuntimeState,
agentTaskSessions,
agentWakeupRequests, agentWakeupRequests,
issues, issues,
issueComments, issueComments,
@@ -56,6 +57,7 @@ export function companyService(db: Db) {
db.transaction(async (tx) => { db.transaction(async (tx) => {
// Delete from child tables in dependency order // Delete from child tables in dependency order
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id)); await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id)); await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id)); await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id)); await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));

View File

@@ -3,6 +3,7 @@ import type { Db } from "@paperclip/db";
import { import {
agents, agents,
agentRuntimeState, agentRuntimeState,
agentTaskSessions,
agentWakeupRequests, agentWakeupRequests,
heartbeatRunEvents, heartbeatRunEvents,
heartbeatRuns, heartbeatRuns,
@@ -13,7 +14,7 @@ import { logger } from "../middleware/logger.js";
import { publishLiveEvent } from "./live-events.js"; import { publishLiveEvent } from "./live-events.js";
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
import { getServerAdapter, runningProcesses } from "../adapters/index.js"; import { getServerAdapter, runningProcesses } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta } from "../adapters/index.js"; import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js"; import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
@@ -38,6 +39,118 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null; return typeof value === "string" && value.trim().length > 0 ? value : null;
} }
function deriveTaskKey(
contextSnapshot: Record<string, unknown> | null | undefined,
payload: Record<string, unknown> | null | undefined,
) {
return (
readNonEmptyString(contextSnapshot?.taskKey) ??
readNonEmptyString(contextSnapshot?.taskId) ??
readNonEmptyString(contextSnapshot?.issueId) ??
readNonEmptyString(payload?.taskKey) ??
readNonEmptyString(payload?.taskId) ??
readNonEmptyString(payload?.issueId) ??
null
);
}
function runTaskKey(run: typeof heartbeatRuns.$inferSelect) {
return deriveTaskKey(run.contextSnapshot as Record<string, unknown> | null, null);
}
function isSameTaskScope(left: string | null, right: string | null) {
return (left ?? null) === (right ?? null);
}
function truncateDisplayId(value: string | null | undefined, max = 128) {
if (!value) return null;
return value.length > max ? value.slice(0, max) : value;
}
const defaultSessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
const asObj = parseObject(raw);
if (Object.keys(asObj).length > 0) return asObj;
const sessionId = readNonEmptyString((raw as Record<string, unknown> | null)?.sessionId);
if (sessionId) return { sessionId };
return null;
},
serialize(params: Record<string, unknown> | null) {
if (!params || Object.keys(params).length === 0) return null;
return params;
},
getDisplayId(params: Record<string, unknown> | null) {
return readNonEmptyString(params?.sessionId);
},
};
function getAdapterSessionCodec(adapterType: string) {
const adapter = getServerAdapter(adapterType);
return adapter.sessionCodec ?? defaultSessionCodec;
}
function normalizeSessionParams(params: Record<string, unknown> | null | undefined) {
if (!params) return null;
return Object.keys(params).length > 0 ? params : null;
}
function resolveNextSessionState(input: {
codec: AdapterSessionCodec;
adapterResult: AdapterExecutionResult;
previousParams: Record<string, unknown> | null;
previousDisplayId: string | null;
previousLegacySessionId: string | null;
}) {
const { codec, adapterResult, previousParams, previousDisplayId, previousLegacySessionId } = input;
if (adapterResult.clearSession) {
return {
params: null as Record<string, unknown> | null,
displayId: null as string | null,
legacySessionId: null as string | null,
};
}
const explicitParams = adapterResult.sessionParams;
const hasExplicitParams = adapterResult.sessionParams !== undefined;
const hasExplicitSessionId = adapterResult.sessionId !== undefined;
const explicitSessionId = readNonEmptyString(adapterResult.sessionId);
const hasExplicitDisplay = adapterResult.sessionDisplayId !== undefined;
const explicitDisplayId = readNonEmptyString(adapterResult.sessionDisplayId);
const shouldUsePrevious = !hasExplicitParams && !hasExplicitSessionId && !hasExplicitDisplay;
const candidateParams =
hasExplicitParams
? explicitParams
: hasExplicitSessionId
? (explicitSessionId ? { sessionId: explicitSessionId } : null)
: previousParams;
const serialized = normalizeSessionParams(codec.serialize(normalizeSessionParams(candidateParams) ?? null));
const deserialized = normalizeSessionParams(codec.deserialize(serialized));
const displayId = truncateDisplayId(
explicitDisplayId ??
(codec.getDisplayId ? codec.getDisplayId(deserialized) : null) ??
readNonEmptyString(deserialized?.sessionId) ??
(shouldUsePrevious ? previousDisplayId : null) ??
explicitSessionId ??
(shouldUsePrevious ? previousLegacySessionId : null),
);
const legacySessionId =
explicitSessionId ??
readNonEmptyString(deserialized?.sessionId) ??
displayId ??
(shouldUsePrevious ? previousLegacySessionId : null);
return {
params: serialized,
displayId,
legacySessionId,
};
}
export function heartbeatService(db: Db) { export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore(); const runLogStore = getRunLogStore();
@@ -65,6 +178,96 @@ export function heartbeatService(db: Db) {
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
} }
async function getTaskSession(
companyId: string,
agentId: string,
adapterType: string,
taskKey: string,
) {
return db
.select()
.from(agentTaskSessions)
.where(
and(
eq(agentTaskSessions.companyId, companyId),
eq(agentTaskSessions.agentId, agentId),
eq(agentTaskSessions.adapterType, adapterType),
eq(agentTaskSessions.taskKey, taskKey),
),
)
.then((rows) => rows[0] ?? null);
}
async function upsertTaskSession(input: {
companyId: string;
agentId: string;
adapterType: string;
taskKey: string;
sessionParamsJson: Record<string, unknown> | null;
sessionDisplayId: string | null;
lastRunId: string | null;
lastError: string | null;
}) {
const existing = await getTaskSession(
input.companyId,
input.agentId,
input.adapterType,
input.taskKey,
);
if (existing) {
return db
.update(agentTaskSessions)
.set({
sessionParamsJson: input.sessionParamsJson,
sessionDisplayId: input.sessionDisplayId,
lastRunId: input.lastRunId,
lastError: input.lastError,
updatedAt: new Date(),
})
.where(eq(agentTaskSessions.id, existing.id))
.returning()
.then((rows) => rows[0] ?? null);
}
return db
.insert(agentTaskSessions)
.values({
companyId: input.companyId,
agentId: input.agentId,
adapterType: input.adapterType,
taskKey: input.taskKey,
sessionParamsJson: input.sessionParamsJson,
sessionDisplayId: input.sessionDisplayId,
lastRunId: input.lastRunId,
lastError: input.lastError,
})
.returning()
.then((rows) => rows[0] ?? null);
}
async function clearTaskSessions(
companyId: string,
agentId: string,
opts?: { taskKey?: string | null; adapterType?: string | null },
) {
const conditions = [
eq(agentTaskSessions.companyId, companyId),
eq(agentTaskSessions.agentId, agentId),
];
if (opts?.taskKey) {
conditions.push(eq(agentTaskSessions.taskKey, opts.taskKey));
}
if (opts?.adapterType) {
conditions.push(eq(agentTaskSessions.adapterType, opts.adapterType));
}
return db
.delete(agentTaskSessions)
.where(and(...conditions))
.returning()
.then((rows) => rows.length);
}
async function ensureRuntimeState(agent: typeof agents.$inferSelect) { async function ensureRuntimeState(agent: typeof agents.$inferSelect) {
const existing = await getRuntimeState(agent.id); const existing = await getRuntimeState(agent.id);
if (existing) return existing; if (existing) return existing;
@@ -260,6 +463,7 @@ export function heartbeatService(db: Db) {
}); });
} }
await finalizeAgentStatus(run.agentId, "failed"); await finalizeAgentStatus(run.agentId, "failed");
await startNextQueuedRunForAgent(run.agentId);
runningProcesses.delete(run.id); runningProcesses.delete(run.id);
reaped.push(run.id); reaped.push(run.id);
} }
@@ -274,6 +478,7 @@ export function heartbeatService(db: Db) {
agent: typeof agents.$inferSelect, agent: typeof agents.$inferSelect,
run: typeof heartbeatRuns.$inferSelect, run: typeof heartbeatRuns.$inferSelect,
result: AdapterExecutionResult, result: AdapterExecutionResult,
session: { legacySessionId: string | null },
) { ) {
const existing = await ensureRuntimeState(agent); const existing = await ensureRuntimeState(agent);
const usage = result.usage; const usage = result.usage;
@@ -286,7 +491,7 @@ export function heartbeatService(db: Db) {
.update(agentRuntimeState) .update(agentRuntimeState)
.set({ .set({
adapterType: agent.adapterType, adapterType: agent.adapterType,
sessionId: result.clearSession ? null : (result.sessionId ?? existing.sessionId), sessionId: session.legacySessionId,
lastRunId: run.id, lastRunId: run.id,
lastRunStatus: run.status, lastRunStatus: run.status,
lastError: result.errorMessage ?? null, lastError: result.errorMessage ?? null,
@@ -320,6 +525,30 @@ export function heartbeatService(db: Db) {
} }
} }
async function startNextQueuedRunForAgent(agentId: string) {
const running = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "running")))
.limit(1)
.then((rows) => rows[0] ?? null);
if (running) return null;
const nextQueued = await db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), eq(heartbeatRuns.status, "queued")))
.orderBy(asc(heartbeatRuns.createdAt))
.limit(1)
.then((rows) => rows[0] ?? null);
if (!nextQueued) return null;
void executeRun(nextQueued.id).catch((err) => {
logger.error({ err, runId: nextQueued.id }, "queued heartbeat execution failed");
});
return nextQueued;
}
async function executeRun(runId: string) { async function executeRun(runId: string) {
const run = await getRun(runId); const run = await getRun(runId);
if (!run) return; if (!run) return;
@@ -339,7 +568,41 @@ export function heartbeatService(db: Db) {
return; return;
} }
if (run.status === "queued") {
const activeForAgent = await db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, run.agentId), inArray(heartbeatRuns.status, ["queued", "running"])))
.orderBy(asc(heartbeatRuns.createdAt));
const runningOther = activeForAgent.some((candidate) => candidate.status === "running" && candidate.id !== run.id);
const first = activeForAgent[0] ?? null;
if (runningOther || (first && first.id !== run.id)) {
return;
}
}
const runtime = await ensureRuntimeState(agent); const runtime = await ensureRuntimeState(agent);
const context = parseObject(run.contextSnapshot);
const taskKey = deriveTaskKey(context, null);
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
const taskSession = taskKey
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
: null;
const previousSessionParams = normalizeSessionParams(
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
);
const previousSessionDisplayId = truncateDisplayId(
taskSession?.sessionDisplayId ??
(sessionCodec.getDisplayId ? sessionCodec.getDisplayId(previousSessionParams) : null) ??
readNonEmptyString(previousSessionParams?.sessionId) ??
runtime.sessionId,
);
const runtimeForAdapter = {
sessionId: readNonEmptyString(previousSessionParams?.sessionId) ?? runtime.sessionId,
sessionParams: previousSessionParams,
sessionDisplayId: previousSessionDisplayId,
taskKey,
};
let seq = 1; let seq = 1;
let handle: RunLogHandle | null = null; let handle: RunLogHandle | null = null;
@@ -349,7 +612,7 @@ export function heartbeatService(db: Db) {
try { try {
await setRunStatus(runId, "running", { await setRunStatus(runId, "running", {
startedAt: new Date(), startedAt: new Date(),
sessionIdBefore: runtime.sessionId, sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
}); });
await setWakeupStatus(run.wakeupRequestId, "claimed", { claimedAt: new Date() }); await setWakeupStatus(run.wakeupRequestId, "claimed", { claimedAt: new Date() });
@@ -426,7 +689,6 @@ export function heartbeatService(db: Db) {
}; };
const config = parseObject(agent.adapterConfig); const config = parseObject(agent.adapterConfig);
const context = (run.contextSnapshot ?? {}) as Record<string, unknown>;
const onAdapterMeta = async (meta: AdapterInvocationMeta) => { const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
await appendRunEvent(currentRun, seq++, { await appendRunEvent(currentRun, seq++, {
eventType: "adapter.invoke", eventType: "adapter.invoke",
@@ -455,13 +717,20 @@ export function heartbeatService(db: Db) {
const adapterResult = await adapter.execute({ const adapterResult = await adapter.execute({
runId: run.id, runId: run.id,
agent, agent,
runtime, runtime: runtimeForAdapter,
config, config,
context, context,
onLog, onLog,
onMeta: onAdapterMeta, onMeta: onAdapterMeta,
authToken: authToken ?? undefined, authToken: authToken ?? undefined,
}); });
const nextSessionState = resolveNextSessionState({
codec: sessionCodec,
adapterResult,
previousParams: previousSessionParams,
previousDisplayId: runtimeForAdapter.sessionDisplayId,
previousLegacySessionId: runtimeForAdapter.sessionId,
});
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
const latestRun = await getRun(run.id); const latestRun = await getRun(run.id);
@@ -515,7 +784,7 @@ export function heartbeatService(db: Db) {
signal: adapterResult.signal, signal: adapterResult.signal,
usageJson, usageJson,
resultJson: adapterResult.resultJson ?? null, resultJson: adapterResult.resultJson ?? null,
sessionIdAfter: adapterResult.sessionId ?? runtime.sessionId, sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId,
stdoutExcerpt, stdoutExcerpt,
stderrExcerpt, stderrExcerpt,
logBytes: logSummary?.bytes, logBytes: logSummary?.bytes,
@@ -543,7 +812,28 @@ export function heartbeatService(db: Db) {
} }
if (finalizedRun) { if (finalizedRun) {
await updateRuntimeState(agent, finalizedRun, adapterResult); await updateRuntimeState(agent, finalizedRun, adapterResult, {
legacySessionId: nextSessionState.legacySessionId,
});
if (taskKey) {
if (adapterResult.clearSession || (!nextSessionState.params && !nextSessionState.displayId)) {
await clearTaskSessions(agent.companyId, agent.id, {
taskKey,
adapterType: agent.adapterType,
});
} else {
await upsertTaskSession({
companyId: agent.companyId,
agentId: agent.id,
adapterType: agent.adapterType,
taskKey,
sessionParamsJson: nextSessionState.params,
sessionDisplayId: nextSessionState.displayId,
lastRunId: finalizedRun.id,
lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"),
});
}
}
} }
await finalizeAgentStatus(agent.id, outcome); await finalizeAgentStatus(agent.id, outcome);
} catch (err) { } catch (err) {
@@ -587,10 +877,27 @@ export function heartbeatService(db: Db) {
signal: null, signal: null,
timedOut: false, timedOut: false,
errorMessage: message, errorMessage: message,
}, {
legacySessionId: runtimeForAdapter.sessionId,
}); });
if (taskKey && (previousSessionParams || previousSessionDisplayId || taskSession)) {
await upsertTaskSession({
companyId: agent.companyId,
agentId: agent.id,
adapterType: agent.adapterType,
taskKey,
sessionParamsJson: previousSessionParams,
sessionDisplayId: previousSessionDisplayId,
lastRunId: failedRun.id,
lastError: message,
});
}
} }
await finalizeAgentStatus(agent.id, "failed"); await finalizeAgentStatus(agent.id, "failed");
} finally {
await startNextQueuedRunForAgent(agent.id);
} }
} }
@@ -601,6 +908,7 @@ export function heartbeatService(db: Db) {
const reason = opts.reason ?? null; const reason = opts.reason ?? null;
const payload = opts.payload ?? null; const payload = opts.payload ?? null;
const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]); const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]);
const taskKey = deriveTaskKey(contextSnapshot, payload);
if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) { if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) {
contextSnapshot.wakeReason = reason; contextSnapshot.wakeReason = reason;
@@ -611,6 +919,9 @@ export function heartbeatService(db: Db) {
if (!readNonEmptyString(contextSnapshot["taskId"]) && issueIdFromPayload) { if (!readNonEmptyString(contextSnapshot["taskId"]) && issueIdFromPayload) {
contextSnapshot.taskId = issueIdFromPayload; contextSnapshot.taskId = issueIdFromPayload;
} }
if (!readNonEmptyString(contextSnapshot["taskKey"]) && taskKey) {
contextSnapshot.taskKey = taskKey;
}
if (!readNonEmptyString(contextSnapshot["wakeSource"])) { if (!readNonEmptyString(contextSnapshot["wakeSource"])) {
contextSnapshot.wakeSource = source; contextSnapshot.wakeSource = source;
} }
@@ -655,14 +966,17 @@ export function heartbeatService(db: Db) {
return null; return null;
} }
const activeRun = await db const activeRuns = await db
.select() .select()
.from(heartbeatRuns) .from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))) .where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"])))
.orderBy(desc(heartbeatRuns.createdAt)) .orderBy(desc(heartbeatRuns.createdAt));
.then((rows) => rows[0] ?? null);
if (activeRun) { const sameScopeRun = activeRuns.find((candidate) =>
isSameTaskScope(runTaskKey(candidate), taskKey),
);
if (sameScopeRun) {
await db.insert(agentWakeupRequests).values({ await db.insert(agentWakeupRequests).values({
companyId: agent.companyId, companyId: agent.companyId,
agentId, agentId,
@@ -675,10 +989,10 @@ export function heartbeatService(db: Db) {
requestedByActorType: opts.requestedByActorType ?? null, requestedByActorType: opts.requestedByActorType ?? null,
requestedByActorId: opts.requestedByActorId ?? null, requestedByActorId: opts.requestedByActorId ?? null,
idempotencyKey: opts.idempotencyKey ?? null, idempotencyKey: opts.idempotencyKey ?? null,
runId: activeRun.id, runId: sameScopeRun.id,
finishedAt: new Date(), finishedAt: new Date(),
}); });
return activeRun; return sameScopeRun;
} }
const wakeupRequest = await db const wakeupRequest = await db
@@ -698,7 +1012,27 @@ export function heartbeatService(db: Db) {
.returning() .returning()
.then((rows) => rows[0]); .then((rows) => rows[0]);
const runtimeForRun = await getRuntimeState(agent.id); let sessionBefore: string | null = null;
if (taskKey) {
const codec = getAdapterSessionCodec(agent.adapterType);
const existingTaskSession = await getTaskSession(
agent.companyId,
agent.id,
agent.adapterType,
taskKey,
);
const parsedParams = normalizeSessionParams(
codec.deserialize(existingTaskSession?.sessionParamsJson ?? null),
);
sessionBefore = truncateDisplayId(
existingTaskSession?.sessionDisplayId ??
(codec.getDisplayId ? codec.getDisplayId(parsedParams) : null) ??
readNonEmptyString(parsedParams?.sessionId),
);
} else {
const runtimeForRun = await getRuntimeState(agent.id);
sessionBefore = runtimeForRun?.sessionId ?? null;
}
const newRun = await db const newRun = await db
.insert(heartbeatRuns) .insert(heartbeatRuns)
@@ -710,7 +1044,7 @@ export function heartbeatService(db: Db) {
status: "queued", status: "queued",
wakeupRequestId: wakeupRequest.id, wakeupRequestId: wakeupRequest.id,
contextSnapshot, contextSnapshot,
sessionIdBefore: runtimeForRun?.sessionId ?? null, sessionIdBefore: sessionBefore,
}) })
.returning() .returning()
.then((rows) => rows[0]); .then((rows) => rows[0]);
@@ -735,9 +1069,7 @@ export function heartbeatService(db: Db) {
}, },
}); });
void executeRun(newRun.id).catch((err) => { await startNextQueuedRunForAgent(agent.id);
logger.error({ err, runId: newRun.id }, "heartbeat execution failed");
});
return newRun; return newRun;
} }
@@ -763,29 +1095,67 @@ export function heartbeatService(db: Db) {
getRuntimeState: async (agentId: string) => { getRuntimeState: async (agentId: string) => {
const state = await getRuntimeState(agentId); const state = await getRuntimeState(agentId);
if (state) return state;
const agent = await getAgent(agentId); const agent = await getAgent(agentId);
if (!agent) return null; if (!agent) return null;
return ensureRuntimeState(agent); const ensured = state ?? (await ensureRuntimeState(agent));
const latestTaskSession = await db
.select()
.from(agentTaskSessions)
.where(and(eq(agentTaskSessions.companyId, agent.companyId), eq(agentTaskSessions.agentId, agent.id)))
.orderBy(desc(agentTaskSessions.updatedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
return {
...ensured,
sessionDisplayId: latestTaskSession?.sessionDisplayId ?? ensured.sessionId,
sessionParamsJson: latestTaskSession?.sessionParamsJson ?? null,
};
}, },
resetRuntimeSession: async (agentId: string) => { listTaskSessions: async (agentId: string) => {
const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
return db
.select()
.from(agentTaskSessions)
.where(and(eq(agentTaskSessions.companyId, agent.companyId), eq(agentTaskSessions.agentId, agentId)))
.orderBy(desc(agentTaskSessions.updatedAt), desc(agentTaskSessions.createdAt));
},
resetRuntimeSession: async (agentId: string, opts?: { taskKey?: string | null }) => {
const agent = await getAgent(agentId); const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found"); if (!agent) throw notFound("Agent not found");
await ensureRuntimeState(agent); await ensureRuntimeState(agent);
const taskKey = readNonEmptyString(opts?.taskKey);
const clearedTaskSessions = await clearTaskSessions(
agent.companyId,
agent.id,
taskKey ? { taskKey, adapterType: agent.adapterType } : undefined,
);
const runtimePatch: Partial<typeof agentRuntimeState.$inferInsert> = {
sessionId: null,
lastError: null,
updatedAt: new Date(),
};
if (!taskKey) {
runtimePatch.stateJson = {};
}
return db const updated = await db
.update(agentRuntimeState) .update(agentRuntimeState)
.set({ .set(runtimePatch)
sessionId: null,
stateJson: {},
lastError: null,
updatedAt: new Date(),
})
.where(eq(agentRuntimeState.agentId, agentId)) .where(eq(agentRuntimeState.agentId, agentId))
.returning() .returning()
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
if (!updated) return null;
return {
...updated,
sessionDisplayId: null,
sessionParamsJson: null,
clearedTaskSessions,
};
}, },
listEvents: (runId: string, afterSeq = 0, limit = 200) => listEvents: (runId: string, afterSeq = 0, limit = 200) =>
@@ -909,6 +1279,7 @@ export function heartbeatService(db: Db) {
runningProcesses.delete(run.id); runningProcesses.delete(run.id);
await finalizeAgentStatus(run.agentId, "cancelled"); await finalizeAgentStatus(run.agentId, "cancelled");
await startNextQueuedRunForAgent(run.agentId);
return cancelled; return cancelled;
}, },