From d21c4849a9afe3340759d196132298cce82ed02d Mon Sep 17 00:00:00 2001 From: john Date: Fri, 24 Jul 2026 22:50:37 +0800 Subject: [PATCH] feat: add executor gateway contracts --- .../memind-orchestrator-boundary.md | 15 + ops/src/api/admin.ts | 11 + ops/src/pages/admin/OrchestratorPage.tsx | 30 ++ services/orchestrator/README.md | 17 + services/orchestrator/admin-config.mjs | 3 + services/orchestrator/admin-config.test.mjs | 9 + services/orchestrator/executor-gateway.mjs | 418 ++++++++++++++++++ .../orchestrator/executor-gateway.test.mjs | 144 ++++++ 8 files changed, 647 insertions(+) create mode 100644 services/orchestrator/executor-gateway.mjs create mode 100644 services/orchestrator/executor-gateway.test.mjs diff --git a/docs/architecture/memind-orchestrator-boundary.md b/docs/architecture/memind-orchestrator-boundary.md index 29f36b9..339b283 100644 --- a/docs/architecture/memind-orchestrator-boundary.md +++ b/docs/architecture/memind-orchestrator-boundary.md @@ -86,6 +86,21 @@ The framework-neutral contracts are: - `orchestrator-event-v1` - `workflow-execution-request-v1` - `workflow-execution-decision-v1` +- `executor-job-request-v1` +- `executor-job-state-v1` +- `executor-dispatch-decision-v1` + +The Phase 3.2 Executor Gateway owns the adapter registry and executor job state +transition boundary. Requests contain workspace/artifact references, +authorization and side-effect policy, timeout, cancellation, fallback, and an +idempotency key. The store interface requires atomic create-if-absent semantics +so retries return the original job and conflicting payloads fail closed. + +Goosed, Aider, and OpenHands currently appear only as disabled +`contract-only` adapters. The reference in-memory store is non-durable and is +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. The implemented internal API is: diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index ade9106..4fd8d61 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -237,6 +237,16 @@ export type OrchestratorEngineDescriptor = { capabilities: string[]; }; +export type OrchestratorExecutorDescriptor = { + id: string; + label: string; + kind: string; + enabled: boolean; + dispatchImplemented: boolean; + status: string; + capabilities: string[]; +}; + export type OrchestratorConfigState = { config: OrchestratorConfig; configVersion: number; @@ -245,6 +255,7 @@ export type OrchestratorConfigState = { source: string; runtime: OrchestratorRuntime; engines: OrchestratorEngineDescriptor[]; + executors: OrchestratorExecutorDescriptor[]; }; export type OrchestratorServiceHealth = { diff --git a/ops/src/pages/admin/OrchestratorPage.tsx b/ops/src/pages/admin/OrchestratorPage.tsx index 352546a..01e4c36 100644 --- a/ops/src/pages/admin/OrchestratorPage.tsx +++ b/ops/src/pages/admin/OrchestratorPage.tsx @@ -169,6 +169,36 @@ export function OrchestratorPage() {

+
+
+

Executor Gateway

+

+ Phase 3.2 仅注册版本化适配器契约;所有执行器均为 contract-only,不会启动进程或接管任务。 +

+
+
+ {(state.executors ?? []).map((executor) => ( +
+
+ {executor.label} + + {executor.status} + +
+

+ {executor.capabilities.join(' · ') || '无能力声明'} +

+ + dispatch {executor.dispatchImplemented ? 'implemented' : 'disabled'} + +
+ ))} +
+
+ diff --git a/services/orchestrator/README.md b/services/orchestrator/README.md index 3b5447a..0002b9f 100644 --- a/services/orchestrator/README.md +++ b/services/orchestrator/README.md @@ -93,6 +93,23 @@ reasons, task-type distribution, Native terminal coverage, recent decisions, and an explicit alert if any stored decision ever reports `handoffAllowed=true`. +Phase 3.2 establishes the Executor Gateway boundary: + +- `executor-job-request-v1`, `executor-job-state-v1`, and + `executor-dispatch-decision-v1` remain independent of Goosed, Aider, + OpenHands, and LangGraph SDK types; +- jobs use workspace and artifact references rather than host filesystem paths; +- the store contract provides atomic idempotency, lookup, and state update + operations; +- timeout, cancellation, fallback, authorization, network, and side-effect + controls are normalized before adapter selection; +- Goosed, Aider, and OpenHands are registered as `contract-only` adapters; +- the reference memory store is explicitly non-durable and used only for local + contract tests. + +`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. + 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. diff --git a/services/orchestrator/admin-config.mjs b/services/orchestrator/admin-config.mjs index 3e13fda..aa00113 100644 --- a/services/orchestrator/admin-config.mjs +++ b/services/orchestrator/admin-config.mjs @@ -7,6 +7,7 @@ import { normalizeWorkflowName, stableRolloutBucket, } from './contracts.mjs'; +import { listPhase3ExecutorAdapters } from './executor-gateway.mjs'; const CONFIG_TABLE = 'h5_orchestrator_admin_config'; const CONFIG_SCOPE = 'global'; @@ -301,6 +302,7 @@ export function createOrchestratorAdminConfigService(pool, { ...state, runtime: runtimeState(state.config, env), engines: engineCatalog(state.config), + executors: listPhase3ExecutorAdapters(), }; }, @@ -333,6 +335,7 @@ export function createOrchestratorAdminConfigService(pool, { ...state, runtime: runtimeState(state.config, env), engines: engineCatalog(state.config), + executors: listPhase3ExecutorAdapters(), ...(probe ? { serviceHealth: await probeServiceHealth(state.config, { fetchImpl }) } : {}), }; }, diff --git a/services/orchestrator/admin-config.test.mjs b/services/orchestrator/admin-config.test.mjs index 610ee2c..6b37c8d 100644 --- a/services/orchestrator/admin-config.test.mjs +++ b/services/orchestrator/admin-config.test.mjs @@ -35,6 +35,15 @@ test('orchestrator config defaults to a disabled native-safe runtime', async () assert.equal(state.runtime.reason, 'mode_off'); assert.equal(state.engines.find((engine) => engine.id === 'native').configured, true); assert.equal(state.engines.find((engine) => engine.id === 'langgraph').configured, false); + assert.deepEqual( + state.executors.map((executor) => executor.id), + ['goosed', 'aider', 'openhands'], + ); + assert.equal(state.executors.every((executor) => executor.enabled === false), true); + assert.equal( + state.executors.every((executor) => executor.dispatchImplemented === false), + true, + ); }); test('orchestrator config normalizes unsafe values and keeps a workflow allowlist', () => { diff --git a/services/orchestrator/executor-gateway.mjs b/services/orchestrator/executor-gateway.mjs new file mode 100644 index 0000000..c5b6245 --- /dev/null +++ b/services/orchestrator/executor-gateway.mjs @@ -0,0 +1,418 @@ +import crypto from 'node:crypto'; +import { normalizeWorkflowEngineId } from './contracts.mjs'; + +const EXECUTOR_JOB_REQUEST_VERSION = 'executor-job-request-v1'; +const EXECUTOR_JOB_STATE_VERSION = 'executor-job-state-v1'; +const EXECUTOR_DISPATCH_IMPLEMENTED = false; +const MAX_INSTRUCTION_CHARACTERS = 32_000; +const TERMINAL_JOB_STATUSES = new Set([ + 'succeeded', + 'failed', + 'cancelled', + 'timed_out', + 'blocked', +]); + +const PHASE3_EXECUTOR_DESCRIPTORS = Object.freeze([ + Object.freeze({ + id: 'goosed', + label: 'Goosed', + kind: 'remote', + capabilities: Object.freeze(['session-execution', 'streaming', 'tools']), + }), + Object.freeze({ + id: 'aider', + label: 'Aider', + kind: 'worker', + capabilities: Object.freeze(['code-edit', 'multi-file']), + }), + Object.freeze({ + id: 'openhands', + label: 'OpenHands', + kind: 'worker', + capabilities: Object.freeze(['repo-task', 'code-edit', 'command-execution']), + }), +]); + +function clone(value) { + return typeof structuredClone === 'function' + ? structuredClone(value) + : JSON.parse(JSON.stringify(value)); +} + +function clampInteger(value, fallback, min, max) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(max, Math.max(min, Math.floor(number))); +} + +function normalizeReference(value, fallbackKind) { + const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const kind = String(source.kind ?? fallbackKind ?? '').trim().slice(0, 64); + const id = String(source.id ?? '').trim().slice(0, 256); + return kind && id ? { kind, id } : null; +} + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, stableValue(value[key])]), + ); +} + +function fingerprint(value) { + return crypto + .createHash('sha256') + .update(JSON.stringify(stableValue(value))) + .digest('hex'); +} + +function gatewayError(code, message, status = 409) { + const error = new Error(message); + error.code = code; + error.status = status; + return error; +} + +export function normalizeExecutorJobRequest(input = {}) { + const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {}; + const jobId = String(source.jobId ?? crypto.randomUUID()).trim(); + const idempotencyKey = String(source.idempotencyKey ?? '').trim().slice(0, 200); + const executor = normalizeWorkflowEngineId(source.executor, ''); + const instruction = String(source.task?.instruction ?? source.instruction ?? '') + .trim() + .slice(0, MAX_INSTRUCTION_CHARACTERS); + if (!jobId) throw gatewayError('EXECUTOR_JOB_ID_REQUIRED', 'Executor job requires jobId', 422); + if (!idempotencyKey) { + throw gatewayError( + 'EXECUTOR_IDEMPOTENCY_KEY_REQUIRED', + 'Executor job requires idempotencyKey', + 422, + ); + } + if (!executor) { + throw gatewayError('EXECUTOR_ID_REQUIRED', 'Executor job requires executor', 422); + } + if (!instruction) { + throw gatewayError('EXECUTOR_INSTRUCTION_REQUIRED', 'Executor job requires instruction', 422); + } + return { + version: EXECUTOR_JOB_REQUEST_VERSION, + jobId, + idempotencyKey, + executor, + task: { + type: String(source.task?.type ?? 'code_task').trim().slice(0, 128) || 'code_task', + instruction, + workspaceRef: normalizeReference(source.task?.workspaceRef, 'workspace'), + inputRefs: Array.isArray(source.task?.inputRefs) + ? source.task.inputRefs + .map((item) => normalizeReference(item, 'artifact')) + .filter(Boolean) + .slice(0, 100) + : [], + }, + subject: { + tenantId: String(source.subject?.tenantId ?? '').trim() || null, + userId: String(source.subject?.userId ?? '').trim() || null, + }, + authorization: { + executionAllowed: source.authorization?.executionAllowed === true, + actorId: String(source.authorization?.actorId ?? '').trim() || null, + }, + policy: { + sideEffectsAllowed: source.policy?.sideEffectsAllowed === true, + networkAllowed: source.policy?.networkAllowed === true, + }, + controls: { + timeoutMs: clampInteger(source.controls?.timeoutMs, 15 * 60_000, 500, 60 * 60_000), + cancellationAllowed: source.controls?.cancellationAllowed !== false, + fallbackExecutor: normalizeWorkflowEngineId(source.controls?.fallbackExecutor, ''), + }, + metadata: source.metadata && typeof source.metadata === 'object' + ? clone(source.metadata) + : {}, + }; +} + +function validateExecutorAdapter(adapter) { + if (!adapter || typeof adapter !== 'object') { + throw new Error('Executor adapter must be an object'); + } + const id = normalizeWorkflowEngineId(adapter.id, ''); + if (!id) throw new Error('Executor adapter requires a valid id'); + for (const method of ['submit', 'cancel', 'getState', 'streamEvents']) { + if (typeof adapter[method] !== 'function') { + throw new Error(`Executor adapter ${id} missing method: ${method}`); + } + } + return id; +} + +export function createExecutorAdapterRegistry(initialAdapters = []) { + const adapters = new Map(); + + function register(adapter) { + const id = validateExecutorAdapter(adapter); + if (adapters.has(id)) throw new Error(`Executor adapter already registered: ${id}`); + adapters.set(id, adapter); + return adapter; + } + + for (const adapter of initialAdapters) register(adapter); + + return { + register, + has(id) { + return adapters.has(normalizeWorkflowEngineId(id, '')); + }, + get(id) { + return adapters.get(normalizeWorkflowEngineId(id, '')) ?? null; + }, + list() { + return [...adapters.values()].map((adapter) => ({ + id: adapter.id, + label: adapter.label ?? adapter.id, + kind: adapter.kind ?? 'plugin', + enabled: adapter.enabled === true, + dispatchImplemented: adapter.dispatchImplemented === true, + status: adapter.status ?? 'unknown', + capabilities: Array.isArray(adapter.capabilities) + ? [...adapter.capabilities] + : [], + })); + }, + }; +} + +export function createDisabledExecutorAdapter(descriptor) { + const id = normalizeWorkflowEngineId(descriptor?.id, ''); + if (!id) throw new Error('Disabled executor adapter requires a valid id'); + const reject = () => { + throw gatewayError( + 'EXECUTOR_ADAPTER_DISABLED', + `Executor adapter ${id} is contract-only and cannot dispatch`, + ); + }; + return { + id, + label: descriptor.label ?? id, + kind: descriptor.kind ?? 'plugin', + enabled: false, + dispatchImplemented: false, + status: 'contract-only', + capabilities: Array.isArray(descriptor.capabilities) + ? [...descriptor.capabilities] + : [], + submit: reject, + cancel: reject, + getState: reject, + async *streamEvents() { + reject(); + }, + }; +} + +export function createPhase3ExecutorAdapterRegistry() { + return createExecutorAdapterRegistry( + PHASE3_EXECUTOR_DESCRIPTORS.map(createDisabledExecutorAdapter), + ); +} + +export function listPhase3ExecutorAdapters() { + return createPhase3ExecutorAdapterRegistry().list(); +} + +export function createInMemoryExecutorJobStore() { + const byId = new Map(); + const byIdempotencyKey = new Map(); + return { + durable: false, + async getById(jobId) { + const value = byId.get(String(jobId ?? '').trim()); + return value ? clone(value) : null; + }, + async getByIdempotencyKey(key) { + const jobId = byIdempotencyKey.get(String(key ?? '').trim()); + const value = jobId ? byId.get(jobId) : null; + return value ? clone(value) : null; + }, + async createIfAbsent(record) { + const existingId = byIdempotencyKey.get(record.idempotencyKey); + if (existingId) return { created: false, record: clone(byId.get(existingId)) }; + if (byId.has(record.jobId)) { + throw gatewayError('EXECUTOR_JOB_ID_CONFLICT', `Executor job id already exists: ${record.jobId}`); + } + byId.set(record.jobId, clone(record)); + byIdempotencyKey.set(record.idempotencyKey, record.jobId); + return { created: true, record: clone(record) }; + }, + async update(record) { + if (!byId.has(record.jobId)) return null; + byId.set(record.jobId, clone(record)); + return clone(record); + }, + }; +} + +function validateJobStore(store) { + for (const method of ['getById', 'getByIdempotencyKey', 'createIfAbsent', 'update']) { + if (typeof store?.[method] !== 'function') { + throw new Error(`Executor job store missing method: ${method}`); + } + } +} + +export function createExecutorGateway({ + registry = createPhase3ExecutorAdapterRegistry(), + store = createInMemoryExecutorJobStore(), + nowMs = Date.now, +} = {}) { + if (!registry?.has || !registry?.list) { + throw new Error('Executor Gateway requires an adapter registry'); + } + validateJobStore(store); + + function previewNormalized(request) { + const adapter = registry.get(request.executor); + const fallbackAdapter = request.controls.fallbackExecutor + ? registry.get(request.controls.fallbackExecutor) + : null; + const fallbackRegistered = Boolean(fallbackAdapter); + const fallbackAvailable = fallbackAdapter?.enabled === true + && fallbackAdapter?.dispatchImplemented === true; + const gates = { + implementation: EXECUTOR_DISPATCH_IMPLEMENTED, + adapterRegistered: Boolean(adapter), + adapterEnabled: adapter?.enabled === true, + executionAuthorized: request.authorization.executionAllowed, + sideEffectsAllowed: request.policy.sideEffectsAllowed, + idempotencyKeyPresent: Boolean(request.idempotencyKey), + workspaceReferencePresent: Boolean(request.task.workspaceRef), + }; + return { + version: 'executor-dispatch-decision-v1', + jobId: request.jobId, + executor: request.executor, + fallbackExecutor: request.controls.fallbackExecutor || null, + fallbackRegistered, + fallbackAvailable, + dispatchAllowed: Object.values(gates).every(Boolean), + reason: EXECUTOR_DISPATCH_IMPLEMENTED + ? 'executor_adapter_unavailable' + : 'executor_dispatch_not_implemented', + gates, + }; + } + + async function preview(input) { + return previewNormalized(normalizeExecutorJobRequest(input)); + } + + async function createJob(input) { + const request = normalizeExecutorJobRequest(input); + const { + jobId: _jobId, + idempotencyKey: _idempotencyKey, + ...semanticRequest + } = request; + const requestFingerprint = fingerprint(semanticRequest); + const existing = await store.getByIdempotencyKey(request.idempotencyKey); + if (existing) { + if (existing.requestFingerprint !== requestFingerprint) { + throw gatewayError( + 'EXECUTOR_IDEMPOTENCY_CONFLICT', + `Idempotency key already belongs to executor job ${existing.jobId}`, + ); + } + return { created: false, job: existing }; + } + const decision = previewNormalized(request); + const now = nowMs(); + const record = { + version: EXECUTOR_JOB_STATE_VERSION, + jobId: request.jobId, + idempotencyKey: request.idempotencyKey, + requestFingerprint, + executor: request.executor, + status: 'blocked', + reason: decision.reason, + attempts: 0, + dispatchAllowed: false, + cancellationAllowed: request.controls.cancellationAllowed, + timeoutMs: request.controls.timeoutMs, + fallbackExecutor: decision.fallbackExecutor, + fallbackRegistered: decision.fallbackRegistered, + fallbackAvailable: decision.fallbackAvailable, + decision, + createdAt: now, + updatedAt: now, + startedAt: null, + completedAt: now, + }; + const result = await store.createIfAbsent(record); + if ( + !result.created + && result.record.requestFingerprint !== requestFingerprint + ) { + throw gatewayError( + 'EXECUTOR_IDEMPOTENCY_CONFLICT', + `Idempotency key already belongs to executor job ${result.record.jobId}`, + ); + } + return { created: result.created, job: result.record }; + } + + async function cancel(jobId, { reason = 'cancelled_by_request' } = {}) { + const current = await store.getById(jobId); + if (!current) return null; + if (current.status === 'cancelled' || !current.cancellationAllowed) return current; + if (TERMINAL_JOB_STATUSES.has(current.status) && current.status !== 'blocked') return current; + const now = nowMs(); + return store.update({ + ...current, + status: 'cancelled', + reason: String(reason).slice(0, 256), + updatedAt: now, + completedAt: now, + }); + } + + return { + preview, + createJob, + cancel, + getJob(jobId) { + return store.getById(jobId); + }, + listAdapters() { + return registry.list(); + }, + status() { + return { + version: 'executor-gateway-status-v1', + dispatchImplemented: EXECUTOR_DISPATCH_IMPLEMENTED, + executionEnabled: false, + store: { + kind: store.durable ? 'durable' : 'memory', + durable: store.durable === true, + }, + adapters: registry.list(), + }; + }, + }; +} + +export const executorGatewayInternals = { + EXECUTOR_DISPATCH_IMPLEMENTED, + EXECUTOR_JOB_REQUEST_VERSION, + EXECUTOR_JOB_STATE_VERSION, + MAX_INSTRUCTION_CHARACTERS, + PHASE3_EXECUTOR_DESCRIPTORS, + TERMINAL_JOB_STATUSES, + fingerprint, + stableValue, +}; diff --git a/services/orchestrator/executor-gateway.test.mjs b/services/orchestrator/executor-gateway.test.mjs new file mode 100644 index 0000000..b452465 --- /dev/null +++ b/services/orchestrator/executor-gateway.test.mjs @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createExecutorAdapterRegistry, + createExecutorGateway, + createInMemoryExecutorJobStore, + createPhase3ExecutorAdapterRegistry, + normalizeExecutorJobRequest, +} from './executor-gateway.mjs'; + +function request(overrides = {}) { + return { + jobId: 'job-1', + idempotencyKey: 'run-1:node-execute:attempt-1', + executor: 'aider', + task: { + type: 'code_change', + instruction: 'Fix the login form validation', + workspaceRef: { kind: 'mindspace-workspace', id: 'workspace-1' }, + }, + subject: { userId: 'user-1' }, + authorization: { executionAllowed: true, actorId: 'user-1' }, + policy: { sideEffectsAllowed: true, networkAllowed: false }, + controls: { + timeoutMs: 20_000, + cancellationAllowed: true, + fallbackExecutor: 'goosed', + }, + ...overrides, + }; +} + +test('executor job contract uses resource references and bounded controls', () => { + const normalized = normalizeExecutorJobRequest(request({ + task: { + instruction: 'x'.repeat(40_000), + workspaceRef: { kind: 'workspace', id: 'workspace-1' }, + inputRefs: [ + { kind: 'artifact', id: 'artifact-1' }, + { kind: '', id: 'invalid' }, + ], + }, + controls: { timeoutMs: 100, fallbackExecutor: 'openhands' }, + })); + assert.equal(normalized.version, 'executor-job-request-v1'); + assert.equal(normalized.task.instruction.length, 32_000); + assert.deepEqual(normalized.task.workspaceRef, { kind: 'workspace', id: 'workspace-1' }); + assert.deepEqual(normalized.task.inputRefs, [{ kind: 'artifact', id: 'artifact-1' }]); + assert.equal(normalized.controls.timeoutMs, 500); + assert.equal(normalized.controls.fallbackExecutor, 'openhands'); + assert.equal('cwd' in normalized.task, false); +}); + +test('Phase 3 executor catalog exposes disabled Goosed, Aider and OpenHands adapters', () => { + const registry = createPhase3ExecutorAdapterRegistry(); + assert.deepEqual( + registry.list().map((adapter) => adapter.id), + ['goosed', 'aider', 'openhands'], + ); + for (const adapter of registry.list()) { + assert.equal(adapter.enabled, false); + assert.equal(adapter.dispatchImplemented, false); + assert.equal(adapter.status, 'contract-only'); + } +}); + +test('executor adapter registry rejects incomplete and duplicate adapters', () => { + const registry = createExecutorAdapterRegistry(); + assert.throws(() => registry.register({ id: 'broken' }), /missing method/); + const adapter = { + id: 'custom', + submit() {}, + cancel() {}, + getState() {}, + async *streamEvents() {}, + }; + registry.register(adapter); + assert.throws(() => registry.register(adapter), /already registered/); +}); + +test('Executor Gateway records a blocked idempotent job without calling an adapter', async () => { + let now = 1000; + const gateway = createExecutorGateway({ + store: createInMemoryExecutorJobStore(), + nowMs: () => now, + }); + const preview = await gateway.preview(request()); + assert.equal(preview.executor, 'aider'); + assert.equal(preview.fallbackExecutor, 'goosed'); + assert.equal(preview.fallbackRegistered, true); + assert.equal(preview.fallbackAvailable, false); + assert.equal(preview.dispatchAllowed, false); + assert.equal(preview.gates.implementation, false); + assert.equal(preview.gates.adapterEnabled, false); + + const first = await gateway.createJob(request()); + assert.equal(first.created, true); + assert.equal(first.job.status, 'blocked'); + assert.equal(first.job.attempts, 0); + assert.equal(first.job.reason, 'executor_dispatch_not_implemented'); + + now = 2000; + const repeated = await gateway.createJob(request()); + assert.equal(repeated.created, false); + assert.equal(repeated.job.jobId, first.job.jobId); + assert.equal(repeated.job.createdAt, 1000); +}); + +test('Executor Gateway rejects idempotency key reuse with a different payload', async () => { + const gateway = createExecutorGateway(); + await gateway.createJob(request()); + await assert.rejects( + () => gateway.createJob(request({ + jobId: 'job-2', + task: { + type: 'code_change', + instruction: 'A different instruction', + workspaceRef: { kind: 'mindspace-workspace', id: 'workspace-1' }, + }, + })), + (error) => error.code === 'EXECUTOR_IDEMPOTENCY_CONFLICT', + ); +}); + +test('Executor Gateway cancellation is idempotent and never invokes a disabled adapter', async () => { + let now = 1000; + const gateway = createExecutorGateway({ nowMs: () => now }); + await gateway.createJob(request()); + now = 1500; + const cancelled = await gateway.cancel('job-1', { reason: 'user_cancelled' }); + assert.equal(cancelled.status, 'cancelled'); + assert.equal(cancelled.reason, 'user_cancelled'); + assert.equal(cancelled.completedAt, 1500); + assert.deepEqual(await gateway.cancel('job-1'), cancelled); + assert.equal(await gateway.cancel('missing-job'), null); +}); + +test('Executor Gateway status remains hard-disabled with a non-durable reference store', () => { + const status = createExecutorGateway().status(); + assert.equal(status.version, 'executor-gateway-status-v1'); + assert.equal(status.dispatchImplemented, false); + assert.equal(status.executionEnabled, false); + assert.deepEqual(status.store, { kind: 'memory', durable: false }); +});