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:
@@ -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?
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user