fix: align orchestrator worker with local goosed

This commit is contained in:
john
2026-07-25 00:04:50 +08:00
parent e3d7b9ee76
commit cf36a9c84a
10 changed files with 145 additions and 17 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ MEMIND_ORCHESTRATOR_EXECUTION_ENABLED=0
MEMIND_ORCHESTRATOR_ENABLED_EXECUTORS= MEMIND_ORCHESTRATOR_ENABLED_EXECUTORS=
MEMIND_ORCHESTRATOR_USER_ALLOWLIST= MEMIND_ORCHESTRATOR_USER_ALLOWLIST=
MEMIND_ORCHESTRATOR_TENANT_ALLOWLIST= MEMIND_ORCHESTRATOR_TENANT_ALLOWLIST=
MEMIND_EXECUTOR_GOOSED_URL=http://host.docker.internal:18006 MEMIND_EXECUTOR_GOOSED_URL=https://host.docker.internal:18006
MEMIND_EXECUTOR_GOOSED_TOKEN= MEMIND_EXECUTOR_GOOSED_TOKEN=
MEMIND_EXECUTOR_GOOSED_TLS_INSECURE=1
MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON={} MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON={}
+2 -1
View File
@@ -62,9 +62,10 @@ services:
MEMIND_ORCHESTRATOR_SERVICE_TOKEN: ${MEMIND_ORCHESTRATOR_SERVICE_TOKEN:?set MEMIND_ORCHESTRATOR_SERVICE_TOKEN} MEMIND_ORCHESTRATOR_SERVICE_TOKEN: ${MEMIND_ORCHESTRATOR_SERVICE_TOKEN:?set MEMIND_ORCHESTRATOR_SERVICE_TOKEN}
MEMIND_ORCHESTRATOR_WORKER_TOKEN: ${MEMIND_ORCHESTRATOR_WORKER_TOKEN:-local-worker-disabled} MEMIND_ORCHESTRATOR_WORKER_TOKEN: ${MEMIND_ORCHESTRATOR_WORKER_TOKEN:-local-worker-disabled}
MEMIND_EXECUTOR_WORKER_ID: ${MEMIND_EXECUTOR_GOOSED_WORKER_ID:-colima-goosed-1} MEMIND_EXECUTOR_WORKER_ID: ${MEMIND_EXECUTOR_GOOSED_WORKER_ID:-colima-goosed-1}
MEMIND_EXECUTOR_GOOSED_URL: ${MEMIND_EXECUTOR_GOOSED_URL:-http://host.docker.internal:18006} MEMIND_EXECUTOR_GOOSED_URL: ${MEMIND_EXECUTOR_GOOSED_URL:-https://host.docker.internal:18006}
MEMIND_EXECUTOR_GOOSED_TOKEN: ${MEMIND_EXECUTOR_GOOSED_TOKEN:-} MEMIND_EXECUTOR_GOOSED_TOKEN: ${MEMIND_EXECUTOR_GOOSED_TOKEN:-}
MEMIND_EXECUTOR_ALLOW_REMOTE_GOOSED: "1" MEMIND_EXECUTOR_ALLOW_REMOTE_GOOSED: "1"
MEMIND_EXECUTOR_GOOSED_TLS_INSECURE: ${MEMIND_EXECUTOR_GOOSED_TLS_INSECURE:-1}
MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON: ${MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON:-{}} MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON: ${MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON:-{}}
MEMIND_EXECUTOR_POLL_MS: ${MEMIND_EXECUTOR_POLL_MS:-1000} MEMIND_EXECUTOR_POLL_MS: ${MEMIND_EXECUTOR_POLL_MS:-1000}
MEMIND_EXECUTOR_HEARTBEAT_MS: ${MEMIND_EXECUTOR_HEARTBEAT_MS:-10000} MEMIND_EXECUTOR_HEARTBEAT_MS: ${MEMIND_EXECUTOR_HEARTBEAT_MS:-10000}
+56 -6
View File
@@ -2,6 +2,7 @@ import { spawn as nodeSpawn } from 'node:child_process';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { Agent, fetch as undiciFetch } from 'undici';
const DEFAULT_OUTPUT_LIMIT = 64 * 1024; const DEFAULT_OUTPUT_LIMIT = 64 * 1024;
const DEFAULT_ALLOWED_ENV = Object.freeze(['PATH', 'LANG', 'LC_ALL', 'TMPDIR']); const DEFAULT_ALLOWED_ENV = Object.freeze(['PATH', 'LANG', 'LC_ALL', 'TMPDIR']);
@@ -27,6 +28,13 @@ function isLoopbackHostname(hostname) {
|| normalized.endsWith('.localhost'); || normalized.endsWith('.localhost');
} }
function isLocalExecutorHostname(hostname) {
const normalized = String(hostname ?? '').trim().toLowerCase();
return isLoopbackHostname(normalized)
|| normalized === 'host.docker.internal'
|| normalized === 'gateway.docker.internal';
}
function normalizeBaseUrl(value, { allowRemote = false } = {}) { function normalizeBaseUrl(value, { allowRemote = false } = {}) {
const url = new URL(String(value ?? '').trim()); const url = new URL(String(value ?? '').trim());
if (!['http:', 'https:'].includes(url.protocol)) { if (!['http:', 'https:'].includes(url.protocol)) {
@@ -94,19 +102,28 @@ function terminalGoosedEvent(event) {
export function createGoosedExecutorAdapter({ export function createGoosedExecutorAdapter({
baseUrl, baseUrl,
secret = '', secret = '',
fetchImpl = globalThis.fetch, fetchImpl = null,
allowRemote = false, allowRemote = false,
tlsInsecure = false,
workspaceResolver = async (reference) => reference?.id ?? null, workspaceResolver = async (reference) => reference?.id ?? null,
} = {}) { } = {}) {
const target = normalizeBaseUrl(baseUrl, { allowRemote }); const target = normalizeBaseUrl(baseUrl, { allowRemote });
if (typeof fetchImpl !== 'function') throw new Error('Goosed adapter requires fetch'); const targetUrl = new URL(target);
if (tlsInsecure && !isLocalExecutorHostname(targetUrl.hostname)) {
throw new Error('Insecure executor TLS is restricted to local targets');
}
const transport = fetchImpl ?? undiciFetch;
if (typeof transport !== 'function') throw new Error('Goosed adapter requires fetch');
const dispatcher = tlsInsecure
? new Agent({ connect: { rejectUnauthorized: false } })
: null;
const active = new Map(); const active = new Map();
function headers(extra = {}) { function headers(extra = {}) {
return { return {
accept: 'application/json', accept: 'application/json',
'content-type': 'application/json', 'content-type': 'application/json',
...(secret ? { authorization: `Bearer ${secret}` } : {}), ...(secret ? { 'X-Secret-Key': secret } : {}),
...extra, ...extra,
}; };
} }
@@ -129,7 +146,7 @@ export function createGoosedExecutorAdapter({
'Goosed workspace reference could not be resolved', 'Goosed workspace reference could not be resolved',
); );
} }
const start = await fetchImpl(`${target}/agent/start`, { const start = await transport(`${target}/agent/start`, {
method: 'POST', method: 'POST',
headers: headers(), headers: headers(),
body: JSON.stringify({ body: JSON.stringify({
@@ -137,6 +154,7 @@ export function createGoosedExecutorAdapter({
enable_context_memory: false, enable_context_memory: false,
}), }),
signal: controller.signal, signal: controller.signal,
...(dispatcher ? { dispatcher } : {}),
}); });
if (!start.ok) throw await responseError(start, 'GOOSED_START_FAILED', 'Goosed start failed'); if (!start.ok) throw await responseError(start, 'GOOSED_START_FAILED', 'Goosed start failed');
const session = await start.json(); const session = await start.json();
@@ -148,12 +166,13 @@ export function createGoosedExecutorAdapter({
sessionRef: { kind: 'goosed-session', id: sessionId }, sessionRef: { kind: 'goosed-session', id: sessionId },
}); });
const eventsResponse = await fetchImpl( const eventsResponse = await transport(
`${target}/sessions/${encodeURIComponent(sessionId)}/events`, `${target}/sessions/${encodeURIComponent(sessionId)}/events`,
{ {
method: 'GET', method: 'GET',
headers: headers({ accept: 'text/event-stream' }), headers: headers({ accept: 'text/event-stream' }),
signal: controller.signal, signal: controller.signal,
...(dispatcher ? { dispatcher } : {}),
}, },
); );
if (!eventsResponse.ok || !eventsResponse.body) { if (!eventsResponse.ok || !eventsResponse.body) {
@@ -185,7 +204,7 @@ export function createGoosedExecutorAdapter({
return { eventCount, finalEvent }; return { eventCount, finalEvent };
})(); })();
const reply = await fetchImpl( const reply = await transport(
`${target}/sessions/${encodeURIComponent(sessionId)}/reply`, `${target}/sessions/${encodeURIComponent(sessionId)}/reply`,
{ {
method: 'POST', method: 'POST',
@@ -198,6 +217,7 @@ export function createGoosedExecutorAdapter({
}, },
}), }),
signal: controller.signal, signal: controller.signal,
...(dispatcher ? { dispatcher } : {}),
}, },
); );
if (!reply.ok) throw await responseError(reply, 'GOOSED_REPLY_FAILED', 'Goosed reply failed'); if (!reply.ok) throw await responseError(reply, 'GOOSED_REPLY_FAILED', 'Goosed reply failed');
@@ -247,6 +267,23 @@ export function createGoosedExecutorAdapter({
async getState(jobId) { async getState(jobId) {
return { active: active.has(jobId) }; return { active: active.has(jobId) };
}, },
async health() {
try {
const response = await transport(`${target}/status`, {
method: 'GET',
headers: headers(),
signal: AbortSignal.timeout(2_000),
...(dispatcher ? { dispatcher } : {}),
});
return { ok: response.ok, status: response.status };
} catch (error) {
return {
ok: false,
status: null,
code: String(error?.code ?? error?.name ?? 'GOOSED_UNREACHABLE').slice(0, 128),
};
}
},
async *streamEvents() { async *streamEvents() {
throw adapterError( throw adapterError(
'GOOSED_STREAM_PUSH_ONLY', 'GOOSED_STREAM_PUSH_ONLY',
@@ -431,6 +468,18 @@ export function createProcessExecutorAdapter({
async getState(jobId) { async getState(jobId) {
return { active: active.has(jobId) }; return { active: active.has(jobId) };
}, },
async health() {
try {
await fs.access(executable);
await fs.access(allowedRoot);
return { ok: true };
} catch (error) {
return {
ok: false,
code: String(error?.code ?? 'EXECUTOR_PROCESS_UNAVAILABLE').slice(0, 128),
};
}
},
async *streamEvents() { async *streamEvents() {
throw adapterError( throw adapterError(
'EXECUTOR_PROCESS_STREAM_PUSH_ONLY', 'EXECUTOR_PROCESS_STREAM_PUSH_ONLY',
@@ -465,6 +514,7 @@ export const executorAdapterInternals = {
adapterError, adapterError,
appendBounded, appendBounded,
isLoopbackHostname, isLoopbackHostname,
isLocalExecutorHostname,
normalizeBaseUrl, normalizeBaseUrl,
parseSse, parseSse,
resolveWorkspace, resolveWorkspace,
@@ -57,6 +57,7 @@ test('Goosed adapter uses the current session API, streams bounded metadata and
]); ]);
} }
if (url.endsWith('/reply')) return Response.json({ ok: true }); if (url.endsWith('/reply')) return Response.json({ ok: true });
if (url.endsWith('/status')) return Response.json({ ok: true });
throw new Error(`Unexpected URL: ${url}`); throw new Error(`Unexpected URL: ${url}`);
}, },
}); });
@@ -67,7 +68,7 @@ test('Goosed adapter uses the current session API, streams bounded metadata and
assert.deepEqual(result.artifactRefs, [{ kind: 'goosed-session', id: 'session-1' }]); assert.deepEqual(result.artifactRefs, [{ kind: 'goosed-session', id: 'session-1' }]);
assert.equal(result.metrics.eventCount, 2); assert.equal(result.metrics.eventCount, 2);
assert.equal(calls[0].url, 'http://127.0.0.1:18006/agent/start'); assert.equal(calls[0].url, 'http://127.0.0.1:18006/agent/start');
assert.equal(calls[0].init.headers.authorization, 'Bearer secret'); assert.equal(calls[0].init.headers['X-Secret-Key'], 'secret');
assert.equal(JSON.parse(calls[0].init.body).working_dir, '/tmp/canary'); assert.equal(JSON.parse(calls[0].init.body).working_dir, '/tmp/canary');
assert.equal( assert.equal(
JSON.parse(calls.find((call) => call.url.endsWith('/reply')).init.body) JSON.parse(calls.find((call) => call.url.endsWith('/reply')).init.body)
@@ -83,6 +84,7 @@ test('Goosed adapter uses the current session API, streams bounded metadata and
], ],
); );
assert.equal(JSON.stringify(emitted).includes('not-projected'), false); assert.equal(JSON.stringify(emitted).includes('not-projected'), false);
assert.deepEqual(await adapter.health(), { ok: true, status: 200 });
}); });
test('Goosed adapter rejects remote targets unless explicitly allowed', () => { test('Goosed adapter rejects remote targets unless explicitly allowed', () => {
@@ -96,6 +98,21 @@ test('Goosed adapter rejects remote targets unless explicitly allowed', () => {
allowRemote: true, allowRemote: true,
}), }),
); );
assert.throws(
() => createGoosedExecutorAdapter({
baseUrl: 'https://executor.example.com',
allowRemote: true,
tlsInsecure: true,
}),
/restricted to local targets/,
);
assert.doesNotThrow(
() => createGoosedExecutorAdapter({
baseUrl: 'https://host.docker.internal:18006',
allowRemote: true,
tlsInsecure: true,
}),
);
}); });
test('Aider process adapter resolves a workspace alias and never invokes a shell', async () => { test('Aider process adapter resolves a workspace alias and never invokes a shell', async () => {
@@ -121,6 +121,7 @@ export function createExecutorWorkerCoordinator({
executors, executors,
leaseDurationMs = defaultLeaseDurationMs, leaseDurationMs = defaultLeaseDurationMs,
draining = false, draining = false,
adapterHealth = {},
} = {}) { } = {}) {
const normalizedWorkerId = requiredIdentifier(workerId, 'worker_id'); const normalizedWorkerId = requiredIdentifier(workerId, 'worker_id');
const normalizedExecutors = [...new Set( const normalizedExecutors = [...new Set(
@@ -141,6 +142,17 @@ export function createExecutorWorkerCoordinator({
workerId: normalizedWorkerId, workerId: normalizedWorkerId,
executors: normalizedExecutors, executors: normalizedExecutors,
draining: draining === true, draining: draining === true,
adapterHealth: Object.fromEntries(
normalizedExecutors.map((executor) => [
executor,
{
ok: adapterHealth?.[executor]?.ok === true,
code: adapterHealth?.[executor]?.code == null
? null
: String(adapterHealth[executor].code).slice(0, 128),
},
]),
),
lastSeenAt: now, lastSeenAt: now,
}); });
if (draining === true) return null; if (draining === true) return null;
@@ -414,7 +426,11 @@ export function createExecutorWorkerCoordinator({
version: WORKER_PROTOCOL_VERSION, version: WORKER_PROTOCOL_VERSION,
...(await store.getQueueStats({ now })), ...(await store.getQueueStats({ now })),
workers, workers,
healthyWorkers: workers.filter((worker) => !worker.stale && !worker.draining).length, healthyWorkers: workers.filter((worker) => (
!worker.stale
&& !worker.draining
&& Object.values(worker.adapterHealth ?? {}).some((health) => health?.ok === true)
)).length,
}; };
} }
@@ -112,6 +112,22 @@ export function createWorkerAdapterRegistry(initialAdapters = []) {
ids() { ids() {
return [...adapters.keys()]; return [...adapters.keys()];
}, },
async health() {
const result = {};
for (const [id, adapter] of adapters) {
try {
result[id] = typeof adapter.health === 'function'
? await adapter.health()
: { ok: true };
} catch (error) {
result[id] = {
ok: false,
code: String(error?.code ?? 'EXECUTOR_ADAPTER_HEALTH_FAILED').slice(0, 128),
};
}
}
return result;
},
_initialize() { _initialize() {
for (const adapter of initialAdapters) this.register(adapter); for (const adapter of initialAdapters) this.register(adapter);
return this; return this;
@@ -132,6 +148,7 @@ export function createExecutorWorkerRuntime({
if (!client?.claim) throw new Error('Executor worker runtime requires client'); if (!client?.claim) throw new Error('Executor worker runtime requires client');
let stopping = false; let stopping = false;
const active = new Map(); const active = new Map();
let lastAdapterHealth = {};
async function executeAdapter(adapter, job, leaseToken, controller) { async function executeAdapter(adapter, job, leaseToken, controller) {
return adapter.submit(job.request, { return adapter.submit(job.request, {
@@ -233,12 +250,23 @@ export function createExecutorWorkerRuntime({
return { return {
async runOnce() { async runOnce() {
if (stopping) return { processed: false, reason: 'stopping' }; if (stopping) return { processed: false, reason: 'stopping' };
lastAdapterHealth = await registry.health();
const healthyExecutors = registry.ids().filter(
(executor) => lastAdapterHealth[executor]?.ok === true,
);
const claim = await client.claim({ const claim = await client.claim({
workerId: normalizedWorkerId, workerId: normalizedWorkerId,
executors: registry.ids(), executors: healthyExecutors.length ? healthyExecutors : registry.ids(),
leaseDurationMs, leaseDurationMs,
draining: healthyExecutors.length === 0,
adapterHealth: lastAdapterHealth,
}); });
if (!claim) return { processed: false, reason: 'queue_empty' }; if (!claim) {
return {
processed: false,
reason: healthyExecutors.length ? 'queue_empty' : 'adapters_unhealthy',
};
}
return processClaim(claim); return processClaim(claim);
}, },
async recoverExpired(options = {}) { async recoverExpired(options = {}) {
@@ -251,6 +279,7 @@ export function createExecutorWorkerRuntime({
stopping, stopping,
activeJobs: [...active.keys()], activeJobs: [...active.keys()],
adapters: registry.list(), adapters: registry.list(),
adapterHealth: lastAdapterHealth,
heartbeatMs, heartbeatMs,
leaseDurationMs, leaseDurationMs,
}; };
@@ -262,6 +291,7 @@ export function createExecutorWorkerRuntime({
executors: registry.ids(), executors: registry.ids(),
leaseDurationMs, leaseDurationMs,
draining: true, draining: true,
adapterHealth: lastAdapterHealth,
}).catch(() => null); }).catch(() => null);
for (const { controller } of active.values()) controller.abort('worker_stopping'); for (const { controller } of active.values()) controller.abort('worker_stopping');
}, },
@@ -85,6 +85,7 @@ test('worker runtime claims, executes, emits progress and completes through leas
client.calls.map((call) => call[0]), client.calls.map((call) => call[0]),
['claim', 'start', 'progress', 'complete'], ['claim', 'start', 'progress', 'complete'],
); );
assert.deepEqual(client.calls[0][1].adapterHealth, { goosed: { ok: true } });
assert.equal(client.calls.at(-1)[2].leaseToken, 'lease-1'); assert.equal(client.calls.at(-1)[2].leaseToken, 'lease-1');
assert.deepEqual(worker.status().activeJobs, []); assert.deepEqual(worker.status().activeJobs, []);
}); });
+11 -1
View File
@@ -12,7 +12,8 @@
"@langchain/langgraph": "^1.4.8", "@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint-postgres": "^1.0.4", "@langchain/langgraph-checkpoint-postgres": "^1.0.4",
"express": "^4.21.2", "express": "^4.21.2",
"pg": "^8.12.0" "pg": "^8.12.0",
"undici": "^6.27.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -1238,6 +1239,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+2 -1
View File
@@ -16,6 +16,7 @@
"@langchain/langgraph": "^1.4.8", "@langchain/langgraph": "^1.4.8",
"@langchain/langgraph-checkpoint-postgres": "^1.0.4", "@langchain/langgraph-checkpoint-postgres": "^1.0.4",
"express": "^4.21.2", "express": "^4.21.2",
"pg": "^8.12.0" "pg": "^8.12.0",
"undici": "^6.27.0"
} }
} }
+4 -3
View File
@@ -32,7 +32,7 @@ function jsonObject(value, fallback = {}) {
} }
} }
export function createConfiguredWorker({ env = process.env, fetchImpl = globalThis.fetch } = {}) { export function createConfiguredWorker({ env = process.env, fetchImpl = null } = {}) {
const registry = createWorkerAdapterRegistry(); const registry = createWorkerAdapterRegistry();
const workspaceAliases = jsonObject(env.MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON); const workspaceAliases = jsonObject(env.MEMIND_EXECUTOR_WORKSPACE_ALIASES_JSON);
const allowedRoot = String(env.MEMIND_EXECUTOR_WORKSPACE_ROOT ?? '').trim(); const allowedRoot = String(env.MEMIND_EXECUTOR_WORKSPACE_ROOT ?? '').trim();
@@ -41,8 +41,9 @@ export function createConfiguredWorker({ env = process.env, fetchImpl = globalTh
registry.register(createGoosedExecutorAdapter({ registry.register(createGoosedExecutorAdapter({
baseUrl: goosedUrl, baseUrl: goosedUrl,
secret: env.MEMIND_EXECUTOR_GOOSED_TOKEN, secret: env.MEMIND_EXECUTOR_GOOSED_TOKEN,
fetchImpl, ...(fetchImpl ? { fetchImpl } : {}),
allowRemote: envFlag(env.MEMIND_EXECUTOR_ALLOW_REMOTE_GOOSED, false), allowRemote: envFlag(env.MEMIND_EXECUTOR_ALLOW_REMOTE_GOOSED, false),
tlsInsecure: envFlag(env.MEMIND_EXECUTOR_GOOSED_TLS_INSECURE, false),
workspaceResolver: async (reference) => workspaceAliases[reference?.id] ?? null, workspaceResolver: async (reference) => workspaceAliases[reference?.id] ?? null,
})); }));
} }
@@ -67,7 +68,7 @@ export function createConfiguredWorker({ env = process.env, fetchImpl = globalTh
baseUrl: env.MEMIND_ORCHESTRATOR_URL ?? 'http://127.0.0.1:8093', baseUrl: env.MEMIND_ORCHESTRATOR_URL ?? 'http://127.0.0.1:8093',
serviceToken: env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN, serviceToken: env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
workerToken: env.MEMIND_ORCHESTRATOR_WORKER_TOKEN, workerToken: env.MEMIND_ORCHESTRATOR_WORKER_TOKEN,
fetchImpl, fetchImpl: fetchImpl ?? globalThis.fetch,
}); });
return createExecutorWorkerRuntime({ return createExecutorWorkerRuntime({
client, client,