feat: persist blocked executor jobs

This commit is contained in:
john
2026-07-24 23:07:46 +08:00
parent d21c4849a9
commit bbb43f97a3
17 changed files with 649 additions and 26 deletions
@@ -102,6 +102,20 @@ not a deployment backend. The compile-time dispatch capability remains false,
so creating a job records `blocked` state without importing an executor SDK,
launching a process, or touching a workspace.
Phase 3.3 persists that state in the Orchestrator-owned PostgreSQL database.
LangGraph checkpoint tables and `executor_jobs` share the isolated
`memind_orchestrator` schema and credentials, but remain behind separate
storage interfaces and readiness probes. This is a deployment simplification,
not a domain ownership leak: neither store can access Memind business tables,
and the Executor Job Store can move to a separate database without changing
the graph or the Memind-facing protocol.
The `build_plan` node creates the deterministic preview job
`<runId>:executor-preview` with idempotency key
`<runId>:build_plan:v1`. The stored result is terminal `blocked` state with
`executor_dispatch_not_implemented`; it is a durable audit and recovery
boundary, not an execution claim.
The implemented internal API is:
```text
@@ -123,8 +137,9 @@ validate_run -> build_plan -> finalize_run
```
It requires `policy.executionMode=observe-only` and
`policy.sideEffectsAllowed=false`. It projects the Native executor boundary but
cannot call Goosed, Aider, OpenHands, Tool Gateway, or the filesystem.
`policy.sideEffectsAllowed=false`. It projects the Native executor boundary and
persists a blocked Executor Job, but cannot dispatch Goosed, Aider, OpenHands,
Tool Gateway, or the filesystem.
## Failure isolation
@@ -139,7 +154,8 @@ failure:
service URL boundary.
Successful observations are projected as `workflow_shadow_completed`; graph
checkpoints stay in the Orchestrator-owned PostgreSQL database.
checkpoints and blocked Executor Jobs stay in the Orchestrator-owned PostgreSQL
database.
Both terminal projection events contain bounded observation latency. The
Memind-owned observability service aggregates those events and may join them to
+5
View File
@@ -268,6 +268,11 @@ export type OrchestratorServiceHealth = {
service: string | null;
checkpoint: { kind?: string; durable?: boolean } | null;
execution: string | null;
executorGateway: {
dispatchImplemented: boolean;
executionEnabled: boolean;
store: { kind: string | null; durable: boolean } | null;
} | null;
} | null;
};
+4 -1
View File
@@ -173,7 +173,7 @@ export function OrchestratorPage() {
<div>
<h3 style={{ margin: 0 }}>Executor Gateway</h3>
<p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>
Phase 3.2 contract-only
Phase 3.3 blocked contract-only
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
@@ -264,6 +264,9 @@ export function OrchestratorPage() {
{serviceHealth?.details?.checkpoint
? ` · checkpoint ${serviceHealth.details.checkpoint.kind ?? 'unknown'}`
: ''}
{serviceHealth?.details?.executorGateway?.store
? ` · executor jobs ${serviceHealth.details.executorGateway.store.kind ?? 'unknown'}`
: ''}
</span>
<button
type="button"
+20 -4
View File
@@ -43,7 +43,7 @@ Phase 2 adds:
- a real LangGraph `StateGraph` service on the versioned HTTP boundary;
- a PostgreSQL `PostgresSaver` checkpoint owner;
- an explicitly non-durable MemorySaver option for tests and local debugging;
- a `code-run-v1` observe-only graph with no executor or tool access;
- a `code-run-v1` observe-only graph with no executor dispatch or tool access;
- a Portal fire-and-forget shadow projection;
- live health probing from memindadm;
- a separate Colima/Compose deployment artifact.
@@ -110,6 +110,22 @@ Phase 3.2 establishes the Executor Gateway boundary:
`EXECUTOR_DISPATCH_IMPLEMENTED=false` is a code-level gate. Phase 3.2 can record
blocked, idempotent job state but cannot invoke an adapter or launch a process.
Phase 3.3 makes that blocked state durable:
- the Orchestrator owns a PostgreSQL `executor_jobs` table beside its LangGraph
checkpoint tables, under the same isolated schema and database credentials;
- the `build_plan` node atomically creates one deterministic Executor Job per
run and graph-node version;
- retries recover the same job through its idempotency key, while a changed
semantic request fails closed;
- `/health` and `/ready` report and probe checkpoint and Executor Job storage
independently;
- memory mode keeps both stores explicitly non-durable.
The persisted job is still a preview terminal state:
`status=blocked`, `reason=executor_dispatch_not_implemented`, and
`executionEnabled=false`. Phase 3.3 does not call Goosed, Aider, or OpenHands.
The default memindadm mode remains `off`. Canary and Active routing are not
wired to the LangGraph executor in Phase 3. Native Agent Run remains the sole
executor in every mode.
@@ -131,7 +147,7 @@ Supported modes:
## Local process
PostgreSQL is the default and required checkpoint backend:
PostgreSQL is the default and required checkpoint and Executor Job backend:
```bash
MEMIND_ORCHESTRATOR_DATABASE_URL='postgresql://...' \
@@ -203,8 +219,8 @@ requires the repository release gates and a separately approved deployment.
| `MEMIND_ORCHESTRATOR_PORT` | HTTP port | `8093` |
| `MEMIND_ORCHESTRATOR_SERVICE_TOKEN` | Bearer token for `/v1/*` | empty |
| `MEMIND_ORCHESTRATOR_CHECKPOINT_MODE` | `postgres` or explicit `memory` | `postgres` |
| `MEMIND_ORCHESTRATOR_DATABASE_URL` | Dedicated checkpoint PostgreSQL URL | required |
| `MEMIND_ORCHESTRATOR_DATABASE_SCHEMA` | Checkpoint schema | `memind_orchestrator` |
| `MEMIND_ORCHESTRATOR_DATABASE_URL` | Dedicated Orchestrator PostgreSQL URL | required |
| `MEMIND_ORCHESTRATOR_DATABASE_SCHEMA` | Checkpoint and Executor Job schema | `memind_orchestrator` |
## Extraction test
+10
View File
@@ -223,6 +223,16 @@ async function probeServiceHealth(config, {
durable: Boolean(body.checkpoint.durable),
} : null,
execution: body.execution == null ? null : String(body.execution).slice(0, 64),
executorGateway: body.executorGateway && typeof body.executorGateway === 'object' ? {
dispatchImplemented: body.executorGateway.dispatchImplemented === true,
executionEnabled: body.executorGateway.executionEnabled === true,
store: body.executorGateway.store && typeof body.executorGateway.store === 'object' ? {
kind: body.executorGateway.store.kind == null
? null
: String(body.executorGateway.store.kind).slice(0, 64),
durable: body.executorGateway.store.durable === true,
} : null,
} : null,
} : null,
};
} catch (error) {
@@ -198,6 +198,12 @@ test('orchestrator runtime probe reports sanitized service health', async () =>
service: 'memind-langgraph-orchestrator',
checkpoint: { kind: 'postgres', durable: true, password: 'must-not-pass-through' },
execution: 'observe-only',
executorGateway: {
dispatchImplemented: false,
executionEnabled: false,
store: { kind: 'postgres', durable: true, password: 'must-not-pass-through' },
adapters: [{ id: 'goosed', secret: 'must-not-pass-through' }],
},
ignoredSecret: 'must-not-pass-through',
}), {
status: 200,
@@ -214,6 +220,11 @@ test('orchestrator runtime probe reports sanitized service health', async () =>
assert.equal(requestedUrl, 'http://127.0.0.1:8093/ready');
assert.equal(runtime.serviceHealth.ok, true);
assert.equal(runtime.serviceHealth.details.checkpoint.durable, true);
assert.deepEqual(runtime.serviceHealth.details.executorGateway, {
dispatchImplemented: false,
executionEnabled: false,
store: { kind: 'postgres', durable: true },
});
assert.equal('password' in runtime.serviceHealth.details.checkpoint, false);
assert.equal('ignoredSecret' in runtime.serviceHealth.details, false);
});
+7 -1
View File
@@ -40,7 +40,13 @@ test('orchestrator HTTP API exposes health and authenticated run endpoints', asy
const health = await fetch(`${server.baseUrl}/health`);
assert.equal(health.status, 200);
assert.equal((await health.json()).execution, 'observe-only');
const healthBody = await health.json();
assert.equal(healthBody.execution, 'observe-only');
assert.equal(healthBody.executorGateway.executionEnabled, false);
assert.deepEqual(healthBody.executorGateway.store, {
kind: 'memory',
durable: false,
});
const ready = await fetch(`${server.baseUrl}/ready`);
assert.equal(ready.status, 200);
assert.equal((await ready.json()).ready, true);
@@ -67,12 +67,49 @@ function validateNode(state) {
};
}
function planNode(state) {
async function planNode(state, executorGateway) {
const taskType = String(state.spec.input?.taskType ?? '').trim() || 'code-change';
const instruction = String(state.spec.input?.instruction ?? '');
const executorJob = await executorGateway.createJob({
jobId: `${state.spec.runId}:executor-preview`,
idempotencyKey: `${state.spec.runId}:build_plan:v1`,
executor: 'goosed',
task: {
type: taskType,
instruction,
workspaceRef: state.spec.input?.workspaceRef ?? null,
inputRefs: state.spec.input?.inputRefs ?? [],
},
subject: state.spec.subject,
authorization: {
executionAllowed: false,
actorId: state.spec.subject?.userId ?? null,
},
policy: {
sideEffectsAllowed: false,
networkAllowed: false,
},
controls: {
timeoutMs: state.spec.limits?.timeoutMs,
cancellationAllowed: true,
fallbackExecutor: 'aider',
},
metadata: {
source: 'langgraph-shadow-plan',
workflow: state.spec.workflow,
},
});
const plan = {
taskType,
executorAdapter: 'native-agent-run',
executorJob: {
kind: 'executor-job',
id: executorJob.job.jobId,
executor: executorJob.job.executor,
status: executorJob.job.status,
reason: executorJob.job.reason,
durable: executorGateway.status().store.durable,
},
instructionCharacters: instruction.length,
steps: [
'accept_control_plane_run',
@@ -86,6 +123,7 @@ function planNode(state) {
events: [eventFor(state, 'workflow_planned', {
taskType,
executorAdapter: plan.executorAdapter,
executorJob: plan.executorJob,
stepCount: plan.steps.length,
})],
};
@@ -106,11 +144,14 @@ function finalizeNode(state) {
};
}
export function createCodeRunShadowGraph({ checkpointer } = {}) {
export function createCodeRunShadowGraph({ checkpointer, executorGateway } = {}) {
if (!checkpointer) throw new Error('Code run shadow graph requires a checkpointer');
if (!executorGateway?.createJob) {
throw new Error('Code run shadow graph requires an Executor Gateway');
}
return new StateGraph(ShadowState)
.addNode('validate_run', validateNode)
.addNode('build_plan', planNode)
.addNode('build_plan', (state) => planNode(state, executorGateway))
.addNode('finalize_run', finalizeNode)
.addEdge(START, 'validate_run')
.addEdge('validate_run', 'build_plan')
+2 -1
View File
@@ -230,6 +230,7 @@ export function createInMemoryExecutorJobStore() {
const byId = new Map();
const byIdempotencyKey = new Map();
return {
kind: 'memory',
durable: false,
async getById(jobId) {
const value = byId.get(String(jobId ?? '').trim());
@@ -397,7 +398,7 @@ export function createExecutorGateway({
dispatchImplemented: EXECUTOR_DISPATCH_IMPLEMENTED,
executionEnabled: false,
store: {
kind: store.durable ? 'durable' : 'memory',
kind: String(store.kind ?? (store.durable ? 'durable' : 'memory')),
durable: store.durable === true,
},
adapters: registry.list(),
@@ -0,0 +1,191 @@
import pg from 'pg';
import { createInMemoryExecutorJobStore } from './executor-gateway.mjs';
const { Pool } = pg;
const DEFAULT_EXECUTOR_SCHEMA = 'memind_orchestrator';
const EXECUTOR_JOB_TABLE = 'executor_jobs';
function normalizeMode(value) {
const mode = String(value ?? 'postgres').trim().toLowerCase();
if (mode === 'memory' || mode === 'postgres') return mode;
throw new Error(`Unsupported executor job store mode: ${value}`);
}
function normalizeSchema(value) {
const schema = String(value ?? DEFAULT_EXECUTOR_SCHEMA).trim();
if (!/^[a-z_][a-z0-9_]{0,62}$/i.test(schema)) {
throw new Error('Invalid Executor Gateway PostgreSQL schema');
}
return schema;
}
function parseState(value) {
if (value == null) return null;
if (typeof value === 'object') return structuredClone(value);
try {
return JSON.parse(String(value));
} catch {
return null;
}
}
function projectRow(row) {
return parseState(row?.state_json);
}
export function createPostgresExecutorJobStore({
pool,
schema = DEFAULT_EXECUTOR_SCHEMA,
} = {}) {
if (!pool?.query) throw new Error('PostgreSQL Executor Job Store requires a pool');
const normalizedSchema = normalizeSchema(schema);
const table = `"${normalizedSchema}"."${EXECUTOR_JOB_TABLE}"`;
return {
kind: 'postgres',
durable: true,
async ensureSchema() {
await pool.query(`CREATE SCHEMA IF NOT EXISTS "${normalizedSchema}"`);
await pool.query(`
CREATE TABLE IF NOT EXISTS ${table} (
job_id VARCHAR(128) PRIMARY KEY,
idempotency_key VARCHAR(200) NOT NULL UNIQUE,
request_fingerprint CHAR(64) NOT NULL,
state_json JSONB NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
`);
await pool.query(`
CREATE INDEX IF NOT EXISTS executor_jobs_updated_at_idx
ON ${table} (updated_at DESC)
`);
},
async getById(jobId) {
const result = await pool.query(
`SELECT state_json FROM ${table} WHERE job_id = $1 LIMIT 1`,
[String(jobId ?? '').trim()],
);
return projectRow(result.rows?.[0]);
},
async getByIdempotencyKey(key) {
const result = await pool.query(
`SELECT state_json FROM ${table} WHERE idempotency_key = $1 LIMIT 1`,
[String(key ?? '').trim()],
);
return projectRow(result.rows?.[0]);
},
async createIfAbsent(record) {
const inserted = await pool.query(
`INSERT INTO ${table}
(job_id, idempotency_key, request_fingerprint, state_json, created_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, $6)
ON CONFLICT DO NOTHING
RETURNING state_json`,
[
record.jobId,
record.idempotencyKey,
record.requestFingerprint,
JSON.stringify(record),
record.createdAt,
record.updatedAt,
],
);
const created = projectRow(inserted.rows?.[0]);
if (created) return { created: true, record: created };
const existingResult = await pool.query(
`SELECT state_json FROM ${table} WHERE idempotency_key = $1 LIMIT 1`,
[record.idempotencyKey],
);
const existing = projectRow(existingResult.rows?.[0]);
if (existing) return { created: false, record: existing };
const error = new Error(`Executor job id already exists: ${record.jobId}`);
error.code = 'EXECUTOR_JOB_ID_CONFLICT';
error.status = 409;
throw error;
},
async update(record) {
const result = await pool.query(
`UPDATE ${table}
SET state_json = $2::jsonb,
updated_at = $3
WHERE job_id = $1
RETURNING state_json`,
[record.jobId, JSON.stringify(record), record.updatedAt],
);
return projectRow(result.rows?.[0]);
},
};
}
export async function createExecutorJobPersistence({
mode = process.env.MEMIND_ORCHESTRATOR_CHECKPOINT_MODE,
connectionString = process.env.MEMIND_ORCHESTRATOR_DATABASE_URL,
schema = process.env.MEMIND_ORCHESTRATOR_DATABASE_SCHEMA,
poolFactory = (config) => new Pool(config),
} = {}) {
const normalizedMode = normalizeMode(mode);
if (normalizedMode === 'memory') {
const store = createInMemoryExecutorJobStore();
return {
kind: 'memory',
durable: false,
store,
async probe() {
return true;
},
async close() {},
};
}
const normalizedConnectionString = String(connectionString ?? '').trim();
if (!normalizedConnectionString) {
const error = new Error(
'MEMIND_ORCHESTRATOR_DATABASE_URL is required for PostgreSQL Executor Job Store',
);
error.code = 'ORCHESTRATOR_DATABASE_URL_REQUIRED';
throw error;
}
const normalizedSchema = normalizeSchema(schema);
const pool = poolFactory({ connectionString: normalizedConnectionString });
const store = createPostgresExecutorJobStore({ pool, schema: normalizedSchema });
try {
await store.ensureSchema();
} catch (error) {
try {
await pool.end?.();
} catch {
// Preserve the schema initialization failure.
}
throw error;
}
return {
kind: 'postgres',
durable: true,
store,
async probe() {
await pool.query('SELECT 1 AS ok');
return true;
},
async close() {
await pool.end?.();
},
};
}
export const executorJobStoreInternals = {
DEFAULT_EXECUTOR_SCHEMA,
EXECUTOR_JOB_TABLE,
normalizeMode,
normalizeSchema,
parseState,
projectRow,
};
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createExecutorJobPersistence,
createPostgresExecutorJobStore,
executorJobStoreInternals,
} from './executor-job-store.mjs';
function record(overrides = {}) {
return {
version: 'executor-job-state-v1',
jobId: 'job-1',
idempotencyKey: 'idem-1',
requestFingerprint: 'a'.repeat(64),
status: 'blocked',
createdAt: 1000,
updatedAt: 1000,
...overrides,
};
}
test('PostgreSQL Executor Job Store creates an isolated schema and job table', async () => {
const queries = [];
const store = createPostgresExecutorJobStore({
schema: 'memind_orchestrator_test',
pool: {
async query(sql, params = []) {
queries.push({ sql, params });
return { rows: [] };
},
},
});
await store.ensureSchema();
assert.equal(store.kind, 'postgres');
assert.equal(store.durable, true);
assert.match(queries[0].sql, /CREATE SCHEMA IF NOT EXISTS "memind_orchestrator_test"/);
assert.match(queries[1].sql, /"memind_orchestrator_test"\."executor_jobs"/);
assert.match(queries[1].sql, /idempotency_key VARCHAR\(200\) NOT NULL UNIQUE/);
assert.match(queries[2].sql, /executor_jobs_updated_at_idx/);
});
test('PostgreSQL Executor Job Store atomically creates and reloads idempotent state', async () => {
const stored = record();
const calls = [];
const store = createPostgresExecutorJobStore({
pool: {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('INSERT INTO')) {
return { rows: [{ state_json: stored }] };
}
if (sql.includes('UPDATE')) {
return { rows: [{ state_json: { ...stored, status: 'cancelled' } }] };
}
if (sql.includes('WHERE job_id =')) {
return { rows: [{ state_json: JSON.stringify(stored) }] };
}
if (sql.includes('WHERE idempotency_key =')) {
return { rows: [{ state_json: stored }] };
}
return { rows: [] };
},
},
});
const created = await store.createIfAbsent(stored);
assert.equal(created.created, true);
assert.deepEqual(created.record, stored);
assert.deepEqual(await store.getById('job-1'), stored);
assert.deepEqual(await store.getByIdempotencyKey('idem-1'), stored);
assert.equal((await store.update({ ...stored, status: 'cancelled' })).status, 'cancelled');
assert.equal(JSON.parse(calls[0].params[3]).jobId, 'job-1');
});
test('PostgreSQL Executor Job Store returns the existing idempotent record after conflict', async () => {
const stored = record();
const store = createPostgresExecutorJobStore({
pool: {
async query(sql) {
if (sql.includes('INSERT INTO')) return { rows: [] };
if (sql.includes('WHERE idempotency_key =')) {
return { rows: [{ state_json: stored }] };
}
return { rows: [] };
},
},
});
assert.deepEqual(
await store.createIfAbsent(stored),
{ created: false, record: stored },
);
});
test('Executor Job persistence supports explicit non-durable memory mode', async () => {
const persistence = await createExecutorJobPersistence({ mode: 'memory' });
assert.equal(persistence.kind, 'memory');
assert.equal(persistence.durable, false);
assert.equal(persistence.store.durable, false);
assert.equal(persistence.store.kind, 'memory');
assert.equal(await persistence.probe(), true);
await persistence.close();
});
test('Executor Job persistence initializes and closes its PostgreSQL pool', async () => {
const queries = [];
let closed = false;
const pool = {
async query(sql) {
queries.push(sql);
return { rows: [] };
},
async end() {
closed = true;
},
};
const persistence = await createExecutorJobPersistence({
mode: 'postgres',
connectionString: 'postgresql://orchestrator:test@postgres/orchestrator',
schema: 'memind_orchestrator',
poolFactory(config) {
assert.equal(
config.connectionString,
'postgresql://orchestrator:test@postgres/orchestrator',
);
return pool;
},
});
assert.equal(persistence.kind, 'postgres');
assert.equal(persistence.durable, true);
assert.equal(persistence.store.kind, 'postgres');
assert.equal(await persistence.probe(), true);
assert.match(queries.at(-1), /SELECT 1 AS ok/);
await persistence.close();
assert.equal(closed, true);
});
test('Executor Job persistence rejects missing PostgreSQL config and unsafe schema names', async () => {
let poolCreated = false;
await assert.rejects(
createExecutorJobPersistence({ mode: 'postgres', connectionString: '' }),
(error) => error.code === 'ORCHESTRATOR_DATABASE_URL_REQUIRED',
);
await assert.rejects(
createExecutorJobPersistence({
mode: 'postgres',
connectionString: 'postgresql://orchestrator:test@postgres/orchestrator',
schema: 'public; drop schema public',
poolFactory() {
poolCreated = true;
return null;
},
}),
/Invalid Executor Gateway PostgreSQL schema/,
);
assert.equal(poolCreated, false);
assert.throws(
() => executorJobStoreInternals.normalizeSchema('public; drop schema public'),
/Invalid Executor Gateway PostgreSQL schema/,
);
});
test('Executor Job persistence closes its pool when schema initialization fails', async () => {
let closed = false;
const initializationError = new Error('schema unavailable');
await assert.rejects(
createExecutorJobPersistence({
mode: 'postgres',
connectionString: 'postgresql://orchestrator:test@postgres/orchestrator',
poolFactory() {
return {
async query() {
throw initializationError;
},
async end() {
closed = true;
},
};
},
}),
initializationError,
);
assert.equal(closed, true);
});
+2 -1
View File
@@ -11,7 +11,8 @@
"@langchain/core": "^1.2.3",
"@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint-postgres": "^1.0.4",
"express": "^4.21.2"
"express": "^4.21.2",
"pg": "^8.12.0"
},
"engines": {
"node": ">=20"
+2 -1
View File
@@ -14,6 +14,7 @@
"@langchain/core": "^1.2.3",
"@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint-postgres": "^1.0.4",
"express": "^4.21.2"
"express": "^4.21.2",
"pg": "^8.12.0"
}
}
+12 -2
View File
@@ -3,6 +3,7 @@ import {
CODE_RUN_SHADOW_WORKFLOW,
createCodeRunShadowGraph,
} from './code-run-shadow-graph.mjs';
import { createExecutorGateway } from './executor-gateway.mjs';
function graphConfig(runId) {
return {
@@ -35,14 +36,22 @@ export function createLangGraphOrchestratorRuntime({
checkpointKind = 'unknown',
durable = false,
checkpointProbe = null,
executorJobStore = null,
executorJobProbe = null,
} = {}) {
const graph = createCodeRunShadowGraph({ checkpointer });
const executorGateway = createExecutorGateway({
...(executorJobStore ? { store: executorJobStore } : {}),
});
const graph = createCodeRunShadowGraph({ checkpointer, executorGateway });
const probe = typeof checkpointProbe === 'function'
? checkpointProbe
: async () => {
await checkpointer.getTuple(graphConfig('__orchestrator_readiness__'));
return true;
};
const probeExecutorJobs = typeof executorJobProbe === 'function'
? executorJobProbe
: async () => true;
async function getSnapshot(runId) {
return graph.getState(graphConfig(runId));
@@ -63,6 +72,7 @@ export function createLangGraphOrchestratorRuntime({
durable: Boolean(durable),
},
execution: 'observe-only',
executorGateway: executorGateway.status(),
};
}
@@ -122,7 +132,7 @@ export function createLangGraphOrchestratorRuntime({
health,
async ready() {
await probe();
await Promise.all([probe(), probeExecutorJobs()]);
return {
...health(),
ready: true,
+42
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MemorySaver } from '@langchain/langgraph';
import { createInMemoryExecutorJobStore } from './executor-gateway.mjs';
import { createLangGraphOrchestratorRuntime } from './runtime.mjs';
function runSpec(overrides = {}) {
@@ -35,6 +36,14 @@ test('LangGraph runtime checkpoints a deterministic observe-only code workflow',
assert.equal(state.result.observed, true);
assert.equal(state.result.executed, false);
assert.equal(state.plan.executorAdapter, 'native-agent-run');
assert.deepEqual(state.plan.executorJob, {
kind: 'executor-job',
id: 'run-shadow-1:executor-preview',
executor: 'goosed',
status: 'blocked',
reason: 'executor_dispatch_not_implemented',
durable: false,
});
const restored = await runtime.getState('run-shadow-1');
assert.deepEqual(restored, state);
@@ -66,6 +75,39 @@ test('LangGraph runtime is idempotent by run id', async () => {
assert.equal((await runtime.listEvents('run-shadow-1')).events.length, 3);
});
test('LangGraph runtime persists a durable blocked Executor Job and probes both stores', async () => {
const checkpointProbeCalls = [];
const executorProbeCalls = [];
const executorJobStore = {
...createInMemoryExecutorJobStore(),
kind: 'postgres',
durable: true,
};
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: new MemorySaver(),
checkpointKind: 'postgres',
durable: true,
checkpointProbe: async () => checkpointProbeCalls.push('checkpoint'),
executorJobStore,
executorJobProbe: async () => executorProbeCalls.push('executor-jobs'),
});
const state = await runtime.start(runSpec());
assert.equal(state.plan.executorJob.durable, true);
assert.equal(
(await executorJobStore.getById('run-shadow-1:executor-preview')).status,
'blocked',
);
const ready = await runtime.ready();
assert.equal(ready.ready, true);
assert.equal(ready.executorGateway.store.kind, 'postgres');
assert.equal(ready.executorGateway.store.durable, true);
assert.equal(ready.executorGateway.executionEnabled, false);
assert.deepEqual(checkpointProbeCalls, ['checkpoint']);
assert.deepEqual(executorProbeCalls, ['executor-jobs']);
});
test('LangGraph runtime rejects execution-enabled and unsupported workflows', async () => {
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: new MemorySaver(),
+47 -9
View File
@@ -1,6 +1,7 @@
import { pathToFileURL } from 'node:url';
import { createOrchestratorApp } from './app.mjs';
import { createOrchestratorCheckpoint } from './checkpoint.mjs';
import { createExecutorJobPersistence } from './executor-job-store.mjs';
import { createLangGraphOrchestratorRuntime } from './runtime.mjs';
function positivePort(value, fallback = 8093) {
@@ -18,11 +19,24 @@ export async function startOrchestratorServer({
connectionString: env.MEMIND_ORCHESTRATOR_DATABASE_URL,
schema: env.MEMIND_ORCHESTRATOR_DATABASE_SCHEMA,
});
let executorJobs;
try {
executorJobs = await createExecutorJobPersistence({
mode: env.MEMIND_ORCHESTRATOR_CHECKPOINT_MODE,
connectionString: env.MEMIND_ORCHESTRATOR_DATABASE_URL,
schema: env.MEMIND_ORCHESTRATOR_DATABASE_SCHEMA,
});
} catch (error) {
await Promise.allSettled([checkpoint.close()]);
throw error;
}
const runtime = createLangGraphOrchestratorRuntime({
checkpointer: checkpoint.checkpointer,
checkpointKind: checkpoint.kind,
durable: checkpoint.durable,
checkpointProbe: checkpoint.probe,
executorJobStore: executorJobs.store,
executorJobProbe: executorJobs.probe,
});
const app = createOrchestratorApp({
runtime,
@@ -30,20 +44,44 @@ export async function startOrchestratorServer({
});
const host = String(env.MEMIND_ORCHESTRATOR_HOST ?? '127.0.0.1').trim() || '127.0.0.1';
const port = positivePort(env.MEMIND_ORCHESTRATOR_PORT, 8093);
const server = await new Promise((resolve, reject) => {
const listening = app.listen(port, host, () => resolve(listening));
listening.once('error', reject);
});
let server;
try {
server = await new Promise((resolve, reject) => {
const listening = app.listen(port, host, () => resolve(listening));
listening.once('error', reject);
});
} catch (error) {
await Promise.allSettled([executorJobs.close(), checkpoint.close()]);
throw error;
}
logger.log(`[orchestrator] listening on http://${host}:${port} (${checkpoint.kind})`);
async function close() {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await checkpoint.close();
let serverCloseError = null;
try {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
} catch (error) {
serverCloseError = error;
}
const results = await Promise.allSettled([
executorJobs.close(),
checkpoint.close(),
]);
if (serverCloseError) throw serverCloseError;
const failed = results.find((result) => result.status === 'rejected');
if (failed) throw failed.reason;
}
return { app, server, runtime, checkpoint, close };
return {
app,
server,
runtime,
checkpoint,
executorJobs,
close,
};
}
const isEntrypoint = process.argv[1]
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test from 'node:test';
import { startOrchestratorServer } from './server.mjs';
async function reservePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const { port } = server.address();
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;
}
test('orchestrator server owns checkpoint and Executor Job Store lifecycle', async (t) => {
const port = await reservePort();
const running = await startOrchestratorServer({
env: {
MEMIND_ORCHESTRATOR_CHECKPOINT_MODE: 'memory',
MEMIND_ORCHESTRATOR_HOST: '127.0.0.1',
MEMIND_ORCHESTRATOR_PORT: String(port),
},
logger: { log() {} },
});
let closed = false;
t.after(async () => {
if (!closed) await running.close();
});
assert.equal(running.checkpoint.kind, 'memory');
assert.equal(running.executorJobs.kind, 'memory');
const response = await fetch(`http://127.0.0.1:${port}/ready`);
assert.equal(response.status, 200);
const ready = await response.json();
assert.equal(ready.ready, true);
assert.deepEqual(ready.executorGateway.store, {
kind: 'memory',
durable: false,
});
await running.close();
closed = true;
assert.equal(running.server.listening, false);
});