merge: integrate langgraph execution runtime
# Conflicts: # agent-run-gateway.test.mjs # capabilities.mjs # package.json # server.mjs
This commit is contained in:
+200
-8
@@ -187,6 +187,24 @@ function positiveInteger(value, fallback) {
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export function normalizeAgentRunWorkerIdentity(identity = {}) {
|
||||
const runtimeRoot = path.resolve(
|
||||
String(identity.runtimeRoot ?? process.cwd()).trim() || process.cwd(),
|
||||
);
|
||||
const buildId =
|
||||
String(
|
||||
identity.buildId
|
||||
?? process.env.MEMIND_RUNTIME_BUILD_ID
|
||||
?? process.env.MEMIND_RELEASE_ID
|
||||
?? process.env.GIT_COMMIT
|
||||
?? 'dev',
|
||||
).trim() || 'dev';
|
||||
const workerId =
|
||||
String(identity.workerId ?? '').trim()
|
||||
|| `${process.pid}:${crypto.randomUUID()}`;
|
||||
return Object.freeze({ workerId, runtimeRoot, buildId });
|
||||
}
|
||||
|
||||
function timeoutError(ms) {
|
||||
const err = new Error(`agent run timed out after ${ms}ms`);
|
||||
err.code = 'AGENT_RUN_TIMEOUT';
|
||||
@@ -377,8 +395,10 @@ export function createAgentRunGateway({
|
||||
conversationMemoryService = null,
|
||||
syncUserPagesOnSuccess = null,
|
||||
observePersonalMemoryOnSuccess = null,
|
||||
observeWorkflowRun = null,
|
||||
isSessionExternallyBusy = null,
|
||||
validateRunDeliverables = null,
|
||||
quiesceSessionOnTerminal = null,
|
||||
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
|
||||
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
|
||||
maxConcurrentRuns = positiveInteger(
|
||||
@@ -393,7 +413,9 @@ export function createAgentRunGateway({
|
||||
process.env.MEMIND_AGENT_RUN_HEARTBEAT_MS,
|
||||
DEFAULT_RUN_HEARTBEAT_MS,
|
||||
),
|
||||
workerIdentity = null,
|
||||
}) {
|
||||
const worker = normalizeAgentRunWorkerIdentity(workerIdentity ?? {});
|
||||
const sessionStore = resolveSessionAccess({ userAuth, sessionAccess });
|
||||
const inFlight = new Set();
|
||||
const queuedDispatches = [];
|
||||
@@ -426,6 +448,105 @@ export function createAgentRunGateway({
|
||||
);
|
||||
}
|
||||
|
||||
async function quiesceTerminalSession(runId, {
|
||||
userId,
|
||||
sessionId,
|
||||
requestId = null,
|
||||
status,
|
||||
}) {
|
||||
if (!sessionId || typeof quiesceSessionOnTerminal !== 'function') return;
|
||||
try {
|
||||
const result = await quiesceSessionOnTerminal({
|
||||
runId,
|
||||
userId,
|
||||
sessionId,
|
||||
requestId,
|
||||
status,
|
||||
});
|
||||
await appendEvent(runId, 'session_extensions_quiesced', {
|
||||
sessionId,
|
||||
status,
|
||||
removed: result?.removed ?? [],
|
||||
skipped: Boolean(result?.skipped),
|
||||
cancellation: result?.cancellation ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
await appendEvent(runId, 'session_extension_quiesce_failed', {
|
||||
sessionId,
|
||||
status,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}).catch(() => {});
|
||||
console.warn(
|
||||
`[AgentRun] failed to quiesce session ${sessionId}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function appendExecutionPlanEvent(input, source) {
|
||||
const executionPlan = source?.executionPlan;
|
||||
if (!executionPlan?.dryRun) return;
|
||||
await appendEvent(input.runId, 'workflow_execution_planned', {
|
||||
version: executionPlan.version ?? 'workflow-execution-decision-v1',
|
||||
mode: source.mode ?? null,
|
||||
candidateEngine: executionPlan.candidateEngine ?? null,
|
||||
effectiveEngine: executionPlan.effectiveEngine ?? 'native',
|
||||
fallbackEngine: executionPlan.fallbackEngine ?? 'native',
|
||||
reason: executionPlan.reason ?? 'execution_gate_disabled',
|
||||
candidateReason: executionPlan.candidateReason ?? null,
|
||||
configVersion: executionPlan.configVersion ?? null,
|
||||
bucket: executionPlan.bucket ?? null,
|
||||
taskType: executionPlan.taskType ?? input.taskType ?? null,
|
||||
dryRun: true,
|
||||
handoffAllowed: false,
|
||||
}).catch((error) => {
|
||||
console.warn(
|
||||
'[AgentRun] workflow execution plan event skipped:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchShadowObservation(input) {
|
||||
if (typeof observeWorkflowRun !== 'function' || input.toolMode !== 'code') return;
|
||||
setImmediate(() => {
|
||||
const observationStartedAt = nowMs();
|
||||
void Promise.resolve()
|
||||
.then(() => observeWorkflowRun(input))
|
||||
.then(async (result) => {
|
||||
await appendExecutionPlanEvent(input, result);
|
||||
if (!result?.observed) return;
|
||||
await appendEvent(input.runId, 'workflow_shadow_completed', {
|
||||
engine: result.engine ?? 'langgraph',
|
||||
mode: result.mode ?? 'shadow',
|
||||
configVersion: result.configVersion ?? null,
|
||||
status: result.shadowRun?.status ?? null,
|
||||
phase: result.shadowRun?.phase ?? null,
|
||||
taskType: result.shadowRun?.plan?.taskType ?? input.taskType ?? null,
|
||||
executorAdapter: result.shadowRun?.plan?.executorAdapter ?? null,
|
||||
latencyMs: Math.max(0, nowMs() - observationStartedAt),
|
||||
});
|
||||
})
|
||||
.catch(async (error) => {
|
||||
await appendExecutionPlanEvent(input, error);
|
||||
console.warn(
|
||||
'[AgentRun] workflow shadow observation failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
await appendEvent(input.runId, 'workflow_shadow_failed', {
|
||||
code: String(error?.code ?? 'WORKFLOW_SHADOW_FAILED').slice(0, 128),
|
||||
message: String(error instanceof Error ? error.message : error).slice(0, 1000),
|
||||
latencyMs: Math.max(0, nowMs() - observationStartedAt),
|
||||
}).catch((appendError) => {
|
||||
console.warn(
|
||||
'[AgentRun] workflow shadow failure event skipped:',
|
||||
appendError instanceof Error ? appendError.message : appendError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function appendRunSnapshot(runId) {
|
||||
if (!isRunStreamReplayEnabled()) return;
|
||||
const row = await getRunById(runId);
|
||||
@@ -528,8 +649,11 @@ export function createAgentRunGateway({
|
||||
await pool.query(
|
||||
`INSERT INTO h5_agent_runs
|
||||
(id, user_id, agent_session_id, request_id, status, attempts,
|
||||
user_message_json, error_message, created_at, updated_at, started_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL)`,
|
||||
user_message_json, error_message, created_at, updated_at, started_at, completed_at,
|
||||
required_runtime_root, required_build_id,
|
||||
claimed_worker_id, claimed_runtime_root, claimed_build_id)
|
||||
VALUES (?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL,
|
||||
?, ?, NULL, NULL, NULL)`,
|
||||
[
|
||||
runId,
|
||||
userId,
|
||||
@@ -538,6 +662,8 @@ export function createAgentRunGateway({
|
||||
serializeMessage(runMessage),
|
||||
createdAt,
|
||||
createdAt,
|
||||
worker.runtimeRoot,
|
||||
worker.buildId,
|
||||
],
|
||||
);
|
||||
await appendEvent(runId, 'queued', {
|
||||
@@ -545,9 +671,22 @@ export function createAgentRunGateway({
|
||||
toolMode: normalizedToolMode,
|
||||
taskType: normalizedTaskType,
|
||||
forceDeepReasoning: Boolean(forceDeepReasoning),
|
||||
requiredRuntimeRoot: worker.runtimeRoot,
|
||||
requiredBuildId: worker.buildId,
|
||||
});
|
||||
const createdRun = projectRun(await getRunById(runId));
|
||||
dispatchShadowObservation({
|
||||
runId,
|
||||
requestId: normalizedRequestId,
|
||||
userId,
|
||||
sessionId: sessionId || null,
|
||||
workflowName: 'code-run-v1',
|
||||
userMessage: runMessage,
|
||||
toolMode: normalizedToolMode,
|
||||
taskType: normalizedTaskType,
|
||||
});
|
||||
if (autoDispatch) dispatchRun(runId);
|
||||
return projectRun(await getRunById(runId));
|
||||
return createdRun;
|
||||
}
|
||||
|
||||
async function prepareRunDeliverables({ userId, sessionId, runId, runStartedAtMs = null }) {
|
||||
@@ -623,6 +762,9 @@ export function createAgentRunGateway({
|
||||
attempt,
|
||||
pid: process.pid,
|
||||
heartbeatMs,
|
||||
workerId: worker.workerId,
|
||||
runtimeRoot: worker.runtimeRoot,
|
||||
buildId: worker.buildId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[AgentRun] worker heartbeat failed:', err instanceof Error ? err.message : err);
|
||||
@@ -1188,12 +1330,32 @@ export function createAgentRunGateway({
|
||||
const nextAttempt = Number(row.attempts ?? 0) + 1;
|
||||
const [claim] = await pool.query(
|
||||
`UPDATE h5_agent_runs
|
||||
SET status = 'running', attempts = ?, started_at = COALESCE(started_at, ?), updated_at = ?, error_message = NULL
|
||||
WHERE id = ? AND status IN ('queued', 'retryable')`,
|
||||
[nextAttempt, nowMs(), nowMs(), runId],
|
||||
SET status = 'running', attempts = ?, started_at = COALESCE(started_at, ?),
|
||||
updated_at = ?, error_message = NULL,
|
||||
claimed_worker_id = ?, claimed_runtime_root = ?, claimed_build_id = ?
|
||||
WHERE id = ?
|
||||
AND status IN ('queued', 'retryable')
|
||||
AND (required_runtime_root IS NULL OR required_runtime_root = ?)
|
||||
AND (required_build_id IS NULL OR required_build_id = ?)`,
|
||||
[
|
||||
nextAttempt,
|
||||
nowMs(),
|
||||
nowMs(),
|
||||
worker.workerId,
|
||||
worker.runtimeRoot,
|
||||
worker.buildId,
|
||||
runId,
|
||||
worker.runtimeRoot,
|
||||
worker.buildId,
|
||||
],
|
||||
);
|
||||
if (Number(claim?.affectedRows ?? 0) === 0) return;
|
||||
await appendEvent(runId, 'running', { attempt: nextAttempt });
|
||||
await appendEvent(runId, 'running', {
|
||||
attempt: nextAttempt,
|
||||
workerId: worker.workerId,
|
||||
runtimeRoot: worker.runtimeRoot,
|
||||
buildId: worker.buildId,
|
||||
});
|
||||
const stopHeartbeat = startRunHeartbeat(runId, { attempt: nextAttempt });
|
||||
|
||||
try {
|
||||
@@ -1221,12 +1383,28 @@ export function createAgentRunGateway({
|
||||
await markRun(runId, retryable ? 'retryable' : 'failed', {
|
||||
error_message: message,
|
||||
completed_at: retryable ? null : nowMs(),
|
||||
...(retryable
|
||||
? {
|
||||
claimed_worker_id: null,
|
||||
claimed_runtime_root: null,
|
||||
claimed_build_id: null,
|
||||
}
|
||||
: {}),
|
||||
}, { expectedStatus: 'running' });
|
||||
if (retryable && autoDispatch) {
|
||||
setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
|
||||
}
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
const terminalRun = await getRunById(runId).catch(() => null);
|
||||
if (terminalRun && TERMINAL_STATUSES.has(terminalRun.status)) {
|
||||
await quiesceTerminalSession(runId, {
|
||||
userId: terminalRun.user_id ?? row.user_id,
|
||||
sessionId: terminalRun.agent_session_id ?? row.agent_session_id ?? null,
|
||||
requestId: terminalRun.request_id ?? row.request_id ?? null,
|
||||
status: terminalRun.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1439,6 +1617,12 @@ export function createAgentRunGateway({
|
||||
if (!marked) continue;
|
||||
item.status = 'succeeded';
|
||||
item.recoveredAs = 'deliverables';
|
||||
await quiesceTerminalSession(row.id, {
|
||||
userId: row.user_id,
|
||||
sessionId: row.agent_session_id ?? null,
|
||||
requestId: row.request_id ?? null,
|
||||
status: 'succeeded',
|
||||
});
|
||||
recovered.push(item);
|
||||
continue;
|
||||
}
|
||||
@@ -1486,6 +1670,12 @@ export function createAgentRunGateway({
|
||||
error: message,
|
||||
});
|
||||
item.status = 'failed';
|
||||
await quiesceTerminalSession(row.id, {
|
||||
userId: row.user_id,
|
||||
sessionId: row.agent_session_id ?? null,
|
||||
requestId: row.request_id ?? null,
|
||||
status: 'failed',
|
||||
});
|
||||
}
|
||||
recovered.push(item);
|
||||
}
|
||||
@@ -1522,9 +1712,11 @@ export function createAgentRunGateway({
|
||||
`SELECT id
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'retryable')
|
||||
AND (required_runtime_root IS NULL OR required_runtime_root = ?)
|
||||
AND (required_build_id IS NULL OR required_build_id = ?)
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT ?`,
|
||||
[take],
|
||||
[worker.runtimeRoot, worker.buildId, take],
|
||||
);
|
||||
for (const row of rows) {
|
||||
dispatchRun(row.id);
|
||||
|
||||
Reference in New Issue
Block a user