From c9ad841543c5fbf6c0e63e59123a492070fec63f Mon Sep 17 00:00:00 2001 From: john Date: Fri, 24 Jul 2026 22:30:41 +0800 Subject: [PATCH] feat: add dry-run workflow execution boundary --- agent-run-gateway.mjs | 20 +++ agent-run-gateway.test.mjs | 57 +++++++ .../memind-orchestrator-boundary.md | 11 ++ ops/src/api/admin.ts | 7 + ops/src/pages/admin/OrchestratorPage.tsx | 15 +- services/orchestrator/README.md | 23 ++- services/orchestrator/admin-config.mjs | 67 +++++++- services/orchestrator/admin-config.test.mjs | 52 ++++++- services/orchestrator/execution-adapter.mjs | 143 ++++++++++++++++++ .../orchestrator/execution-adapter.test.mjs | 113 ++++++++++++++ services/orchestrator/shadow-observer.mjs | 12 ++ .../orchestrator/shadow-observer.test.mjs | 50 ++++++ 12 files changed, 553 insertions(+), 17 deletions(-) create mode 100644 services/orchestrator/execution-adapter.mjs create mode 100644 services/orchestrator/execution-adapter.test.mjs diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 445d0e8..c6937a1 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -346,6 +346,26 @@ export function createAgentRunGateway({ void Promise.resolve() .then(() => observeWorkflowRun(input)) .then(async (result) => { + if (result?.executionPlan?.dryRun) { + await appendEvent(input.runId, 'workflow_execution_planned', { + version: result.executionPlan.version ?? 'workflow-execution-decision-v1', + mode: result.mode ?? null, + candidateEngine: result.executionPlan.candidateEngine ?? null, + effectiveEngine: result.executionPlan.effectiveEngine ?? 'native', + fallbackEngine: result.executionPlan.fallbackEngine ?? 'native', + reason: result.executionPlan.reason ?? 'execution_gate_disabled', + candidateReason: result.executionPlan.candidateReason ?? null, + configVersion: result.executionPlan.configVersion ?? null, + bucket: result.executionPlan.bucket ?? null, + dryRun: true, + handoffAllowed: false, + }).catch((error) => { + console.warn( + '[AgentRun] workflow execution plan event skipped:', + error instanceof Error ? error.message : error, + ); + }); + } if (!result?.observed) return; await appendEvent(input.runId, 'workflow_shadow_completed', { engine: result.engine ?? 'langgraph', diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 7eae8fa..024b130 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -468,6 +468,63 @@ test('shadow observer failure cannot fail a Native run and chat runs are not obs assert.equal(JSON.parse(failure.dataJson).code, 'SHADOW_UNAVAILABLE'); }); +test('canary dry-run records the candidate decision without changing Native run state', async () => { + const pool = createFakePool(); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + observeWorkflowRun: async () => ({ + observed: false, + mode: 'canary', + executionPlan: { + version: 'workflow-execution-decision-v1', + candidateEngine: 'langgraph', + effectiveEngine: 'native', + fallbackEngine: 'native', + reason: 'execution_gate_disabled', + candidateReason: 'percentage', + configVersion: 9, + bucket: 3, + dryRun: true, + handoffAllowed: false, + }, + }), + }); + + const run = await gateway.createRun('user-canary', { + requestId: 'req-canary-dry-run', + sessionId: 'session-canary', + userMessage: { role: 'user', content: 'refactor this module' }, + toolMode: 'code', + taskType: 'repo_refactor', + }); + + await waitFor(() => pool.events.some( + (event) => event.eventType === 'workflow_execution_planned', + )); + assert.equal(run.status, 'queued'); + assert.equal( + pool.events.some((event) => event.eventType === 'workflow_shadow_completed'), + false, + ); + const event = pool.events.find((item) => item.eventType === 'workflow_execution_planned'); + assert.deepEqual(JSON.parse(event.dataJson), { + version: 'workflow-execution-decision-v1', + mode: 'canary', + candidateEngine: 'langgraph', + effectiveEngine: 'native', + fallbackEngine: 'native', + reason: 'execution_gate_disabled', + candidateReason: 'percentage', + configVersion: 9, + bucket: 3, + dryRun: true, + handoffAllowed: false, + }); +}); + test('agent run starts a session and marks submitted reply as succeeded', async () => { const pool = createFakePool(); const submitted = []; diff --git a/docs/architecture/memind-orchestrator-boundary.md b/docs/architecture/memind-orchestrator-boundary.md index 78ed220..a8bda9a 100644 --- a/docs/architecture/memind-orchestrator-boundary.md +++ b/docs/architecture/memind-orchestrator-boundary.md @@ -59,12 +59,23 @@ Canary and Active can be configured and evaluated by the control plane, the Portal does not hand execution ownership to LangGraph yet. This prevents an administrative configuration mistake from creating two task executors. +Phase 3 makes that restriction explicit in the contract. Routing returns both +`candidateEngine` and `engine`: Canary or Active may nominate LangGraph, while +`engine` remains Native. The decision is projected as +`workflow_execution_planned` with `dryRun=true` and `handoffAllowed=false`. +The execution adapter normalizes authorization, idempotency, timeout, +cancellation, and fallback controls, but its non-Native dispatch path is +hard-disabled in code. Changing memindadm configuration or setting an +environment override cannot transfer execution ownership. + ## Protocol The framework-neutral contracts are: - `orchestrator-run-v1` - `orchestrator-event-v1` +- `workflow-execution-request-v1` +- `workflow-execution-decision-v1` The implemented internal API is: diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 74c90f8..878d42e 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -219,7 +219,14 @@ export type OrchestratorRuntime = { effective: boolean; reason: string | null; executesLangGraph: boolean; + plansLangGraph: boolean; shadowsLangGraph: boolean; + executionHandoff: { + implemented: boolean; + requested: boolean; + enabled: boolean; + reason: string | null; + }; }; export type OrchestratorEngineDescriptor = { diff --git a/ops/src/pages/admin/OrchestratorPage.tsx b/ops/src/pages/admin/OrchestratorPage.tsx index f3afaf2..e039749 100644 --- a/ops/src/pages/admin/OrchestratorPage.tsx +++ b/ops/src/pages/admin/OrchestratorPage.tsx @@ -25,6 +25,7 @@ const RUNTIME_REASON: Record = { mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。', service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。', kill_switch: '环境级紧急熔断已开启,强制回退 Native。', + execution_gate_disabled: 'Canary / Active 仅生成 Dry-run 候选决策;执行交接闸门仍被硬锁定,Native 是唯一执行者。', }; export function OrchestratorPage() { @@ -93,7 +94,7 @@ export function OrchestratorPage() { } if ( normalizedDraft.mode === 'active' - && !window.confirm('确认保存 Active 预配置?Phase 2 不会交出执行权,Native 仍是唯一执行者。') + && !window.confirm('确认保存 Active 预配置?Phase 3 仅生成 Dry-run 决策,Native 仍是唯一执行者。') ) { return; } @@ -122,7 +123,7 @@ export function OrchestratorPage() { : state.runtime.shadowsLangGraph ? 'Shadow 已生效:Native 继续执行,LangGraph 只观察和生成决策。' : state.runtime.executesLangGraph - ? 'Canary / Active 路由决策已预配置;Phase 2 尚未交出执行权,Native 仍是唯一执行者。' + ? '执行交接已开启。' : '配置有效。'; return ( @@ -155,6 +156,12 @@ export function OrchestratorPage() { 更新时间

{formatTime(state.updatedAt)}

+
+ 执行交接 +

+ {state.runtime.executionHandoff.enabled ? '已开启' : 'Dry-run 锁定'} +

+

{runtimeMessage} @@ -178,8 +185,8 @@ export function OrchestratorPage() { > - - + +