From 93ea9577d2c978891ce5cbe79932c434b6da91aa Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:05:02 -0500 Subject: [PATCH] refactor: extract provider adapter registry (#1230) --- AGENTS.md | 35 +- docs/AGENT-PROVIDERS.md | 16 + docs/CODEX-INTEGRATION.md | 14 +- .../agent-provider-adapter-registry.test.ts | 177 +++++ .../agent-provider-adapter-registry.ts | 272 +++++++ server/src/services/clawdbot-agent-service.ts | 710 +++++++----------- 6 files changed, 761 insertions(+), 463 deletions(-) create mode 100644 server/src/__tests__/agent-provider-adapter-registry.test.ts create mode 100644 server/src/services/agent-provider-adapter-registry.ts diff --git a/AGENTS.md b/AGENTS.md index 40021cf2..2ab0711d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -492,23 +492,24 @@ it. ## File locations quick-reference -| What | Where | -| ---------------- | ------------------------------------- | -| API routes | `server/src/routes/` | -| Services | `server/src/services/` | -| Zod schemas | `server/src/schemas/` | -| Storage | `server/src/storage/` | -| Server utilities | `server/src/utils/` | -| React components | `web/src/components/` | -| Zustand stores | `web/src/stores/` | -| CLI commands | `cli/src/commands/` | -| Shared types | `shared/src/` | -| MCP server | `mcp/src/` | -| Prompt registry | `prompt-registry/` | -| SOPs | `docs/SOP-*.md` | -| Agent registry | `.veritas-kanban/agent-registry.json` | -| Agent run logs | `.veritas-kanban/logs/` | -| Telemetry events | `.veritas-kanban/telemetry/` | +| What | Where | +| ----------------- | -------------------------------------------------------- | +| API routes | `server/src/routes/` | +| Services | `server/src/services/` | +| Zod schemas | `server/src/schemas/` | +| Storage | `server/src/storage/` | +| Server utilities | `server/src/utils/` | +| Provider adapters | `server/src/services/agent-provider-adapter-registry.ts` | +| React components | `web/src/components/` | +| Zustand stores | `web/src/stores/` | +| CLI commands | `cli/src/commands/` | +| Shared types | `shared/src/` | +| MCP server | `mcp/src/` | +| Prompt registry | `prompt-registry/` | +| SOPs | `docs/SOP-*.md` | +| Agent registry | `.veritas-kanban/agent-registry.json` | +| Agent run logs | `.veritas-kanban/logs/` | +| Telemetry events | `.veritas-kanban/telemetry/` | --- diff --git a/docs/AGENT-PROVIDERS.md b/docs/AGENT-PROVIDERS.md index 055a8a21..e8468e6d 100644 --- a/docs/AGENT-PROVIDERS.md +++ b/docs/AGENT-PROVIDERS.md @@ -1073,6 +1073,22 @@ execution-tree identity to the durable attempt. Missing or inconsistent evidence fails before the adapter is called, so an adapter cannot widen capacity or substitute an external hidden queue. +### Provider adapter lifecycle ownership + +`server/src/services/agent-provider-adapter-registry.ts` is the executable +provider-selection authority. Its `resolve(provider, surface)` interface owns +the exact adapter identity, task-envelope renderer, runtime probe, run-event +mapper, start dispatch, and stop behavior for every executable provider. +Unknown or non-executable providers fail before this seam; there is no implicit +OpenClaw fallback. + +`ClawdbotAgentService` remains the shared run orchestrator. It supplies +admission, supervisor, sandbox, budget, journal, and completion effects to the +registry host without duplicating provider selection. Full attempt mutations +cross `AttemptLifecycleCoordinator`, which verifies active-attempt ownership, +optimistic revisions, history maintenance, and terminal completion binding. +Provider adapters never write attempt state directly. + Active leases renew while the verified run is live; completion, interruption, cancellation, or launch failure releases the reservation idempotently. Workflow retry and fallback attempts release the prior step reservation before diff --git a/docs/CODEX-INTEGRATION.md b/docs/CODEX-INTEGRATION.md index dcc2ffe9..95945ad7 100644 --- a/docs/CODEX-INTEGRATION.md +++ b/docs/CODEX-INTEGRATION.md @@ -80,7 +80,17 @@ POST /api/github/codex/delegate ## Architecture Direction -v4.3 uses an explicit provider adapter contract inside the agent service. `codex` agents resolve to the local Codex CLI runner, `codex-sdk` agents resolve to the SDK session runner, `codex-cloud` uses GitHub-native delegation, and existing agents keep the OpenClaw request-file behavior. +Executable task providers resolve through the dedicated +`AgentProviderAdapterRegistry`. The registry owns exact provider selection, +task-envelope rendering, runtime probing, run-event mapping, start dispatch, +and stop semantics. `ClawdbotAgentService` supplies shared admission, +supervision, journaling, budget, and completion effects without selecting an +implicit fallback adapter. + +`codex` agents resolve to the local Codex CLI runner, `codex-sdk` agents resolve +to the SDK session runner, and `codex-cloud` uses GitHub-native delegation. +OpenClaw task dispatch uses the gateway `sessions_spawn` path and persists the +returned session identity on the active attempt. Expected long-term provider capabilities: @@ -93,7 +103,7 @@ Expected long-term provider capabilities: - optional `review` - optional `cloudDelegate` -The provider abstraction should support: +The provider adapter interface supports: - OpenClaw compatibility through an OpenClaw provider adapter. - Codex CLI through a local process provider. diff --git a/server/src/__tests__/agent-provider-adapter-registry.test.ts b/server/src/__tests__/agent-provider-adapter-registry.test.ts new file mode 100644 index 00000000..5e37fbe3 --- /dev/null +++ b/server/src/__tests__/agent-provider-adapter-registry.test.ts @@ -0,0 +1,177 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { EXECUTABLE_AGENT_PROVIDERS, type ExecutableAgentProvider } from '@veritas-kanban/shared'; +import { + AgentProviderAdapterRegistry, + type AgentProviderAdapterHost, + type AgentProviderStartContext, +} from '../services/agent-provider-adapter-registry.js'; +import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js'; + +const RENDERER_NAMES: Record = { + 'codex-cli': 'renderCodexCliTaskEnvelope', + 'codex-sdk': 'renderCodexSdkTaskEnvelope', + 'codex-app-server': 'renderCodexAppServerTaskEnvelope', + 'acp-stdio': 'renderAcpStdioTaskEnvelope', + 'claude-code': 'renderClaudeCodeTaskEnvelope', + 'hermes-cli': 'renderHermesTaskEnvelope', + openclaw: 'renderOpenClawTaskEnvelope', +}; + +function createHost(): AgentProviderAdapterHost { + return { + probe: vi.fn(async (provider) => + providerRuntimeManifestFixture({ provider, adapter: provider }) + ), + probeAcp: vi.fn(async () => + providerRuntimeManifestFixture({ provider: 'acp-stdio', adapter: 'acp-stdio' }) + ), + assertTransport: vi.fn(), + getPending: vi.fn(), + startCodexCli: vi.fn(async () => undefined), + startCodexSdk: vi.fn(async () => undefined), + handleCodexSdkError: vi.fn(async () => undefined), + startCodexAppServer: vi.fn(async () => undefined), + startAcpStdio: vi.fn(async () => undefined), + startClaudeCode: vi.fn(async () => undefined), + startHermesCli: vi.fn(async () => undefined), + startOpenClaw: vi.fn(async () => undefined), + warn: vi.fn(), + }; +} + +function startContext(provider: ExecutableAgentProvider): AgentProviderStartContext { + return { + task: { id: 'task_provider_registry' }, + transport: { + schemaVersion: 'provider-task-envelope-transport/v1', + provider, + taskEnvelopeDigest: 'task-envelope-digest', + callbackPosture: provider === 'openclaw' ? 'veritas-http' : 'harness-owned', + completionNormalization: 'harness', + content: 'Run the task.', + }, + logPath: '/tmp/provider-registry.log', + attemptId: 'attempt_provider_registry', + startedAt: '2026-08-24T06:00:00.000Z', + emitter: new EventEmitter(), + attempt: { id: 'attempt_provider_registry', status: 'running', agent: 'codex' }, + runLaunchManifest: { digest: 'run-launch-digest' }, + conversation: { + schemaVersion: 'conversation-lifecycle/v1', + mode: 'fresh', + intent: 'fresh', + state: 'active', + contextWindow: { posture: 'unknown', measuredAt: '2026-08-24T06:00:00.000Z' }, + createdAt: '2026-08-24T06:00:00.000Z', + updatedAt: '2026-08-24T06:00:00.000Z', + }, + admission: { + schemaVersion: 'provider-admission-evidence/v1', + source: 'direct', + outcome: 'admitted', + reservationId: 'reservation_provider_registry', + executionTree: { + rootObjectiveId: 'objective_provider_registry', + nodeId: 'node_provider_registry', + depth: 0, + edge: 'root', + }, + }, + } as AgentProviderStartContext; +} + +describe('AgentProviderAdapterRegistry', () => { + it('resolves every executable provider without an implicit fallback', async () => { + const host = createHost(); + const registry = new AgentProviderAdapterRegistry(host); + + for (const provider of EXECUTABLE_AGENT_PROVIDERS) { + const adapter = registry.resolve(provider); + + expect(adapter.id).toBe(provider); + expect(adapter.renderTaskEnvelope.name).toBe(RENDERER_NAMES[provider]); + expect(adapter.runEventMapper.mapEvent).toEqual(expect.any(Function)); + await expect( + adapter.probe({ + health: { + type: provider, + name: provider, + enabled: true, + configured: true, + command: provider, + executableFound: true, + authenticated: true, + healthy: true, + checkedAt: '2026-08-24T06:00:00.000Z', + }, + }) + ).resolves.toMatchObject({ provider, adapter: provider }); + } + + expect(host.probeAcp).toHaveBeenCalledOnce(); + expect(host.probe).toHaveBeenCalledTimes(EXECUTABLE_AGENT_PROVIDERS.length - 1); + }); + + it('dispatches starts through the exact provider operation', async () => { + const host = createHost(); + const registry = new AgentProviderAdapterRegistry(host); + const startOperations: Record> = { + 'codex-cli': host.startCodexCli, + 'codex-sdk': host.startCodexSdk, + 'codex-app-server': host.startCodexAppServer, + 'acp-stdio': host.startAcpStdio, + 'claude-code': host.startClaudeCode, + 'hermes-cli': host.startHermesCli, + openclaw: host.startOpenClaw, + }; + + for (const provider of EXECUTABLE_AGENT_PROVIDERS) { + await registry.resolve(provider).start(startContext(provider)); + expect(startOperations[provider]).toHaveBeenCalledOnce(); + } + + expect(host.assertTransport).toHaveBeenCalledTimes(EXECUTABLE_AGENT_PROVIDERS.length); + }); + + it('keeps stop behavior behind the adapter seam', async () => { + const host = createHost(); + const registry = new AgentProviderAdapterRegistry(host); + const abortController = new AbortController(); + const cancel = vi.fn(async () => undefined); + const close = vi.fn(async () => undefined); + + await registry.resolve('codex-sdk').stop({ + taskId: 'task_provider_registry', + pending: { + taskId: 'task_provider_registry', + attemptId: 'attempt_provider_registry', + abortController, + }, + }); + await registry.resolve('acp-stdio').stop({ + taskId: 'task_provider_registry', + pending: { + taskId: 'task_provider_registry', + attemptId: 'attempt_provider_registry', + acpControl: { cancel, close }, + }, + }); + await registry.resolve('openclaw').stop({ + taskId: 'task_provider_registry', + pending: { + taskId: 'task_provider_registry', + attemptId: 'attempt_provider_registry', + openclawSessionKey: 'session_provider_registry', + }, + }); + + expect(abortController.signal.aborted).toBe(true); + expect(cancel).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(host.warn).toHaveBeenCalledWith( + expect.objectContaining({ sessionKey: 'session_provider_registry' }), + expect.stringContaining('OpenClaw stop requested') + ); + }); +}); diff --git a/server/src/services/agent-provider-adapter-registry.ts b/server/src/services/agent-provider-adapter-registry.ts new file mode 100644 index 00000000..305440b3 --- /dev/null +++ b/server/src/services/agent-provider-adapter-registry.ts @@ -0,0 +1,272 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import type { EventEmitter } from 'node:events'; +import type { + AdmissionLaunchSource, + AgentConfig, + ExecutableAgentProvider, + ExecutionTreeIdentity, + ProviderRuntimeManifest, + RunLaunchManifest, + SandboxPolicyDryRunResult, + Task, + TaskAttempt, + ConversationLifecycleRecord, +} from '@veritas-kanban/shared'; +import type { AcpStdioControl } from './acp-stdio-adapter.js'; +import type { AgentProviderProbeContext } from './provider-runtime-resolution.js'; +import { + getProviderRuntimeAdapterDefinition, + type ProviderRuntimeAdapterDefinition, + type ProviderRuntimeSurface, +} from './provider-runtime-adapter-registry.js'; +import { + getProviderRunEventMapper, + type ProviderRunEventMapper, +} from './provider-run-event-mappers.js'; +import { + renderAcpStdioTaskEnvelope, + renderClaudeCodeTaskEnvelope, + renderCodexAppServerTaskEnvelope, + renderCodexCliTaskEnvelope, + renderCodexSdkTaskEnvelope, + renderHermesTaskEnvelope, + renderOpenClawTaskEnvelope, + type ProviderTaskEnvelopeRenderInput, + type ProviderTaskEnvelopeTransport, +} from './provider-task-envelope-renderer.js'; + +export interface AgentProviderAdmissionEvidence { + schemaVersion: 'provider-admission-evidence/v1'; + source: AdmissionLaunchSource; + outcome: 'admitted' | 'queued-dispatch'; + reservationId: string; + queueEntryId?: string; + executionTree: ExecutionTreeIdentity; +} + +export interface AgentProviderStartContext { + task: Task; + agentConfig?: AgentConfig; + transport: ProviderTaskEnvelopeTransport; + logPath: string; + attemptId: string; + startedAt: string; + emitter: EventEmitter; + attempt: TaskAttempt; + sandboxPolicy?: SandboxPolicyDryRunResult; + runLaunchManifest: RunLaunchManifest; + conversation: ConversationLifecycleRecord; + admission: AgentProviderAdmissionEvidence; +} + +export interface ProviderAdapterPendingRun { + taskId: string; + attemptId: string; + process?: ChildProcessWithoutNullStreams; + abortController?: AbortController; + codexAppServerControl?: { + interrupt(): Promise; + close(): void; + }; + acpControl?: Pick; + openclawSessionKey?: string; +} + +export interface AgentProviderStopContext { + taskId: string; + pending: ProviderAdapterPendingRun; +} + +export interface AgentProviderAdapter { + id: ExecutableAgentProvider; + label: string; + renderTaskEnvelope(input: ProviderTaskEnvelopeRenderInput): ProviderTaskEnvelopeTransport; + probe(context: AgentProviderProbeContext): Promise; + runEventMapper: ProviderRunEventMapper; + start(context: AgentProviderStartContext): Promise | void; + stop(context: AgentProviderStopContext): Promise | void; +} + +export interface AgentProviderAdapterHost { + probe( + provider: ExecutableAgentProvider, + context: AgentProviderProbeContext, + definition: ProviderRuntimeAdapterDefinition + ): Promise; + probeAcp( + context: AgentProviderProbeContext, + definition: ProviderRuntimeAdapterDefinition + ): Promise; + assertTransport( + provider: ExecutableAgentProvider, + transport: ProviderTaskEnvelopeTransport, + manifest: RunLaunchManifest + ): void; + getPending(taskId: string): ProviderAdapterPendingRun | undefined; + startCodexCli(context: AgentProviderStartContext): Promise; + startCodexSdk( + context: AgentProviderStartContext, + abortController: AbortController + ): Promise; + handleCodexSdkError( + context: AgentProviderStartContext, + abortController: AbortController, + error: unknown + ): Promise; + startCodexAppServer(context: AgentProviderStartContext): Promise; + startAcpStdio(context: AgentProviderStartContext): Promise; + startClaudeCode(context: AgentProviderStartContext): Promise; + startHermesCli(context: AgentProviderStartContext): Promise; + startOpenClaw(context: AgentProviderStartContext): Promise; + warn(details: Record, message: string): void; +} + +type TaskEnvelopeRenderer = ( + input: ProviderTaskEnvelopeRenderInput +) => ProviderTaskEnvelopeTransport; + +const TASK_ENVELOPE_RENDERERS: Record = { + 'codex-cli': renderCodexCliTaskEnvelope, + 'codex-sdk': renderCodexSdkTaskEnvelope, + 'codex-app-server': renderCodexAppServerTaskEnvelope, + 'acp-stdio': renderAcpStdioTaskEnvelope, + 'claude-code': renderClaudeCodeTaskEnvelope, + 'hermes-cli': renderHermesTaskEnvelope, + openclaw: renderOpenClawTaskEnvelope, +}; + +/** + * Owns executable-provider selection and adapter lifecycle semantics. The host + * supplies orchestration effects; callers learn only the resolved adapter + * interface and never branch on provider identity themselves. + */ +export class AgentProviderAdapterRegistry { + constructor(private readonly host: AgentProviderAdapterHost) {} + + resolve( + provider: ExecutableAgentProvider, + surface: ProviderRuntimeSurface = 'task' + ): AgentProviderAdapter { + const definition = getProviderRuntimeAdapterDefinition(provider, surface); + return { + id: definition.id, + label: definition.label, + renderTaskEnvelope: TASK_ENVELOPE_RENDERERS[provider], + probe: (context) => + provider === 'acp-stdio' + ? this.host.probeAcp(context, definition) + : this.host.probe(provider, context, definition), + runEventMapper: getProviderRunEventMapper(provider), + start: (context) => { + this.host.assertTransport(provider, context.transport, context.runLaunchManifest); + return this.start(provider, context); + }, + stop: (context) => this.stop(provider, context), + }; + } + + private start( + provider: ExecutableAgentProvider, + context: AgentProviderStartContext + ): Promise | void { + switch (provider) { + case 'codex-cli': + return this.host.startCodexCli(context); + case 'codex-sdk': { + const abortController = new AbortController(); + const pending = this.host.getPending(context.task.id); + if (pending) pending.abortController = abortController; + void this.host + .startCodexSdk(context, abortController) + .catch((error: unknown) => + this.host.handleCodexSdkError(context, abortController, error) + ); + return; + } + case 'codex-app-server': + return this.host.startCodexAppServer(context); + case 'acp-stdio': + return this.host.startAcpStdio(context); + case 'claude-code': + return this.host.startClaudeCode(context); + case 'hermes-cli': + return this.host.startHermesCli(context); + case 'openclaw': + return this.host.startOpenClaw(context); + } + } + + private async stop( + provider: ExecutableAgentProvider, + { pending }: AgentProviderStopContext + ): Promise { + switch (provider) { + case 'codex-cli': + if (pending.process && !pending.process.killed) pending.process.kill('SIGTERM'); + return; + case 'codex-sdk': + pending.abortController?.abort(); + return; + case 'codex-app-server': { + try { + await pending.codexAppServerControl?.interrupt(); + } catch (error) { + this.host.warn( + { err: error, taskId: pending.taskId }, + 'Codex app-server cooperative interrupt failed; closing the supervised process' + ); + } + pending.codexAppServerControl?.close(); + const child = pending.process; + if (!child || child.exitCode != null || child.signalCode != null) return; + const forcedStop = setTimeout(() => { + if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL'); + }, 5_000); + child.once('close', () => clearTimeout(forcedStop)); + return; + } + case 'acp-stdio': + pending.abortController?.abort(); + await pending.acpControl?.cancel().catch(() => undefined); + await pending.acpControl?.close().catch(() => undefined); + return; + case 'claude-code': { + const child = pending.process; + if (!child || child.exitCode != null || child.signalCode != null) return; + child.kill('SIGTERM'); + const forcedStop = setTimeout(() => { + if (child.exitCode == null && child.signalCode == null) { + child.kill('SIGKILL'); + this.host.warn( + { taskId: pending.taskId }, + '[ClawdbotAgent] Claude Code SIGKILL issued after graceful stop timeout' + ); + } + }, 5_000); + child.once('close', () => clearTimeout(forcedStop)); + return; + } + case 'hermes-cli': + if (pending.process && !pending.process.killed) { + pending.process.kill('SIGTERM'); + const forcedStop = setTimeout(() => { + if (pending.process && !pending.process.killed) { + pending.process.kill('SIGKILL'); + this.host.warn( + { taskId: pending.taskId }, + '[ClawdbotAgent] Hermes SIGKILL issued after graceful stop timeout' + ); + } + }, 5_000); + pending.process.once('close', () => clearTimeout(forcedStop)); + } + return; + case 'openclaw': + this.host.warn( + { taskId: pending.taskId, sessionKey: pending.openclawSessionKey }, + '[ClawdbotAgent] OpenClaw stop requested; sub-session will complete via callback' + ); + return; + } + } +} diff --git a/server/src/services/clawdbot-agent-service.ts b/server/src/services/clawdbot-agent-service.ts index f38d180d..5e700988 100644 --- a/server/src/services/clawdbot-agent-service.ts +++ b/server/src/services/clawdbot-agent-service.ts @@ -54,17 +54,7 @@ import { buildSafeCodexEnv } from '../utils/codex-env.js'; import { getRuntimeDir, getLogsDir } from '../utils/paths.js'; import { buildSafeHermesEnv } from '../utils/hermes-env.js'; import { HttpOpenClawTaskAdapter } from './openclaw-workflow-adapter.js'; -import { - renderCodexCliTaskEnvelope, - renderCodexSdkTaskEnvelope, - renderCodexAppServerTaskEnvelope, - renderClaudeCodeTaskEnvelope, - renderAcpStdioTaskEnvelope, - renderHermesTaskEnvelope, - renderOpenClawTaskEnvelope, - type ProviderTaskEnvelopeRenderInput, - type ProviderTaskEnvelopeTransport, -} from './provider-task-envelope-renderer.js'; +import { type ProviderTaskEnvelopeTransport } from './provider-task-envelope-renderer.js'; import type { ThreadEvent } from '@openai/codex-sdk'; import { evaluateTaskReadiness, @@ -213,11 +203,19 @@ import { type RunEventJournalService, } from './run-event-journal-service.js'; import { getRunTerminalService, type RunTerminalService } from './run-terminal-service.js'; +import { type ProviderMappedRunEvent } from './provider-run-event-mappers.js'; import { - getProviderRunEventMapper, - type ProviderMappedRunEvent, - type ProviderRunEventMapper, -} from './provider-run-event-mappers.js'; + AgentProviderAdapterRegistry, + type AgentProviderAdapterHost, + type AgentProviderAdmissionEvidence, + type AgentProviderStartContext, +} from './agent-provider-adapter-registry.js'; +export type { + AgentProviderAdapter, + AgentProviderAdmissionEvidence, + AgentProviderStartContext, + AgentProviderStopContext, +} from './agent-provider-adapter-registry.js'; import { buildClaudeCodeArgs, buildSafeClaudeCodeEnv, @@ -339,45 +337,6 @@ const providerDependencyExecutionOptions = { }, } satisfies DependencyCircuitExecutionOptions; -export interface AgentProviderStartContext { - task: Task; - agentConfig?: AgentConfig; - transport: ProviderTaskEnvelopeTransport; - logPath: string; - attemptId: string; - startedAt: string; - emitter: EventEmitter; - attempt: TaskAttempt; - sandboxPolicy?: SandboxPolicyDryRunResult; - runLaunchManifest: RunLaunchManifest; - conversation: ConversationLifecycleRecord; - admission: AgentProviderAdmissionEvidence; -} - -export interface AgentProviderAdmissionEvidence { - schemaVersion: 'provider-admission-evidence/v1'; - source: AdmissionLaunchSource; - outcome: 'admitted' | 'queued-dispatch'; - reservationId: string; - queueEntryId?: string; - executionTree: ExecutionTreeIdentity; -} - -export interface AgentProviderStopContext { - taskId: string; - pending: PendingAgent; -} - -export interface AgentProviderAdapter { - id: ExecutableAgentProvider; - label: string; - renderTaskEnvelope(input: ProviderTaskEnvelopeRenderInput): ProviderTaskEnvelopeTransport; - probe(context: AgentProviderProbeContext): Promise; - runEventMapper: ProviderRunEventMapper; - start(context: AgentProviderStartContext): Promise | void; - stop(context: AgentProviderStopContext): Promise | void; -} - export interface AgentStatus { taskId: string; attemptId: string; @@ -637,6 +596,7 @@ export class ClawdbotAgentService { private taskEnvelopes: TaskEnvelopeService; private runLaunchManifests: RunLaunchManifestService; private runLaunchCompiler: RunLaunchCompiler; + private providerAdapters: AgentProviderAdapterRegistry; private providerCompletions: ProviderCompletionService; private attemptLifecycle: AttemptLifecycleCoordinator; private credentialLeases: CredentialLeaseLifecycle; @@ -767,6 +727,7 @@ export class ClawdbotAgentService { this.workspaceExecutionTrust = workspaceExecutionTrust; this.phaseAuthority = phaseAuthority; this.phaseTransitions = phaseTransitions; + this.providerAdapters = this.createProviderAdapterRegistry(); this.runLaunchCompiler = new RunLaunchCompiler({ runLaunchManifests: this.runLaunchManifests, workspaceFiles: this.workspaceFiles, @@ -1928,7 +1889,7 @@ export class ClawdbotAgentService { : agentConfig; const provider = resolveExecutableAgentProvider(profileAgentConfig, agent); const agentHealth = await this.assertAgentAvailable(agent, profileAgentConfig); - const adapter = this.resolveProviderAdapter(provider); + const adapter = this.providerAdapters.resolve(provider); const budgetService = getAgentBudgetService(); const budgetSources = { workspaceBudget: config.features?.budget?.enabled @@ -2239,7 +2200,7 @@ export class ClawdbotAgentService { : agentConfig; const provider = resolveExecutableAgentProvider(profileAgentConfig, agent); const agentHealth = await this.assertAgentAvailable(agent, profileAgentConfig); - const adapter = this.resolveProviderAdapter(provider); + const adapter = this.providerAdapters.resolve(provider); const budgetService = getAgentBudgetService(); const budgetSources = { workspaceBudget: config.features?.budget?.enabled @@ -3675,7 +3636,7 @@ export class ClawdbotAgentService { attempt.id, undefined, 'openclaw', - this.resolveProviderAdapter('openclaw').runEventMapper.mapEvent( + this.providerAdapters.resolve('openclaw').runEventMapper.mapEvent( 'message.completed', { type: 'message.completed', @@ -4015,7 +3976,7 @@ export class ClawdbotAgentService { attemptId, undefined, 'openclaw', - this.resolveProviderAdapter('openclaw').runEventMapper.mapEvent( + this.providerAdapters.resolve('openclaw').runEventMapper.mapEvent( 'message.completed', { type: 'message.completed', @@ -4398,7 +4359,7 @@ export class ClawdbotAgentService { if (supervisor.control.kind === 'local-process') { await this.runSupervisor.stopLocalProcess(pending.supervisorId); } else { - await this.resolveProviderAdapter(pending.provider).stop({ + await this.providerAdapters.resolve(pending.provider).stop({ taskId: pending.taskId, pending, }); @@ -4406,7 +4367,7 @@ export class ClawdbotAgentService { return; } - await this.resolveProviderAdapter(pending.provider).stop({ + await this.providerAdapters.resolve(pending.provider).stop({ taskId: pending.taskId, pending, }); @@ -5199,7 +5160,7 @@ export class ClawdbotAgentService { .map((event) => `- ${event.message}`) .join('\n')}\n` ); - await this.resolveProviderAdapter(pending.provider).stop({ taskId, pending }); + await this.providerAdapters.resolve(pending.provider).stop({ taskId, pending }); return { status: 'interrupted', terminalSource: 'operator-interruption', @@ -5389,7 +5350,7 @@ export class ClawdbotAgentService { const health = await this.assertAgentAvailable(agent, agentConfig); return this.dependencyExecution.execute( providerDependencyIdentity(provider, agentConfig.model), - () => this.resolveProviderAdapter(provider, surface).probe({ agentConfig, health }), + () => this.providerAdapters.resolve(provider, surface).probe({ agentConfig, health }), providerDependencyExecutionOptions ); } @@ -5427,24 +5388,40 @@ export class ClawdbotAgentService { return health; } - private resolveProviderAdapter( - provider: ExecutableAgentProvider, - surface: ProviderRuntimeSurface = 'task' - ): AgentProviderAdapter { - const definition = getProviderRuntimeAdapterDefinition(provider, surface); - const probe = (context: AgentProviderProbeContext) => - this.providerRuntimeManifests.probe( - buildProviderRuntimeProbeRequest(provider, context, definition) - ); - - if (provider === 'codex-cli') { - return { - id: definition.id, - label: definition.label, - renderTaskEnvelope: renderCodexCliTaskEnvelope, - probe, - runEventMapper: getProviderRunEventMapper(provider), - start: async ({ + private createProviderAdapterRegistry(): AgentProviderAdapterRegistry { + const host: AgentProviderAdapterHost = { + probe: (provider, context, definition) => + this.providerRuntimeManifests.probe( + buildProviderRuntimeProbeRequest(provider, context, definition) + ), + probeAcp: (context, definition) => this.probeAcpProviderRuntime(context, definition), + assertTransport: (provider, transport, manifest) => + this.assertProviderAdapterTransport(provider, transport, manifest), + getPending: (taskId) => pendingAgents.get(taskId), + startCodexCli: ({ + task, + agentConfig, + transport, + logPath, + attemptId, + startedAt, + emitter, + sandboxPolicy, + runLaunchManifest, + }) => + this.startCodexCli( + task, + agentConfig, + transport.content, + logPath, + attemptId, + startedAt, + emitter, + sandboxPolicy, + runLaunchManifest + ), + startCodexSdk: ( + { task, agentConfig, transport, @@ -5454,375 +5431,222 @@ export class ClawdbotAgentService { emitter, sandboxPolicy, runLaunchManifest, - }) => { - this.assertProviderAdapterTransport(provider, transport, runLaunchManifest); - await this.startCodexCli( - task, - agentConfig, - transport.content, - logPath, - attemptId, - startedAt, - emitter, - sandboxPolicy, - runLaunchManifest - ); }, - stop: ({ pending }) => { - if (pending.process && !pending.process.killed) pending.process.kill('SIGTERM'); - }, - }; - } - - if (provider === 'codex-sdk') { - return { - id: definition.id, - label: definition.label, - renderTaskEnvelope: renderCodexSdkTaskEnvelope, - probe, - runEventMapper: getProviderRunEventMapper(provider), - start: async ({ + abortController + ) => + this.startCodexSdk( task, agentConfig, - transport, + transport.content, + logPath, + attemptId, + startedAt, + emitter, + abortController, + sandboxPolicy, + runLaunchManifest + ), + handleCodexSdkError: (context, abortController, error) => + this.handleCodexSdkAdapterError(context, abortController, error), + startCodexAppServer: ({ + task, + agentConfig, + transport, + logPath, + attemptId, + startedAt, + emitter, + sandboxPolicy, + runLaunchManifest, + }) => + this.startCodexAppServer( + task, + agentConfig, + transport.content, logPath, attemptId, startedAt, emitter, sandboxPolicy, - runLaunchManifest, - }) => { - this.assertProviderAdapterTransport(provider, transport, runLaunchManifest); - const abortController = new AbortController(); - const pending = pendingAgents.get(task.id); - if (pending) pending.abortController = abortController; - void this.startCodexSdk( - task, - agentConfig, - transport.content, - logPath, - attemptId, - startedAt, - emitter, - abortController, - sandboxPolicy, - runLaunchManifest - ).catch(async (error: unknown) => { - const current = pendingAgents.get(task.id); - if (!current || current.attemptId !== attemptId) return; - if (error instanceof CompletionPersistenceError) { - if (emitter.listenerCount('error') > 0) { - emitter.emit('error', error.persistenceCause); - } - log.error( - { err: error.persistenceCause, taskId: task.id, attemptId }, - 'Codex SDK completion could not be persisted after bounded retries' - ); - return; - } - abortController.abort(); - const message = this.redactTraceText( - error instanceof Error ? error.message : 'Codex SDK attempt failed' - ); - try { - const journalEvent = await this.appendRunEvent( - task.id, - attemptId, - 'run.error', - { summary: message, error: message, phase: 'stream' }, - { - provider: 'codex-sdk', - adapter: 'codex-sdk', - agent: agentConfig?.type || 'codex-sdk', - model: agentConfig?.model, - } - ); - this.emitJournalOutput(journalEvent); - await this.appendLog(logPath, `\n## Codex SDK Error\n\n${message}\n`); - } catch (logError) { - log.error( - { err: logError, taskId: task.id }, - 'Failed to record Codex SDK error evidence' - ); - } - try { - await this.completeAgent( - task.id, - { success: false, error: message }, - { - attemptId, - terminalSource: 'stream', - providerRuntimeManifestDigest: current.providerRuntimeManifest.digest, - } - ); - } catch (finalizationError) { - const retryable = - current.preparedCompletion !== undefined && - !(finalizationError instanceof CompletionOwnershipError); - if (!retryable && pendingAgents.get(task.id)?.attemptId === attemptId) { - pendingAgents.delete(task.id); - } - if (emitter.listenerCount('error') > 0) { - emitter.emit('error', finalizationError); - } - log.error( - { err: finalizationError, taskId: task.id, attemptId, retryable }, - retryable - ? 'Codex SDK failure completion remains pending after bounded persistence retries' - : 'Codex SDK failure could not update stale persisted attempt state' - ); - } - }); - }, - stop: ({ pending }) => { - pending.abortController?.abort(); - }, - }; - } - - if (provider === 'codex-app-server') { - return { - id: definition.id, - label: definition.label, - renderTaskEnvelope: renderCodexAppServerTaskEnvelope, - probe, - runEventMapper: getProviderRunEventMapper(provider), - start: async ({ + runLaunchManifest + ), + startAcpStdio: ({ + task, + agentConfig, + transport, + logPath, + attemptId, + sandboxPolicy, + runLaunchManifest, + conversation, + }) => + this.startAcpStdio( task, agentConfig, - transport, + transport.content, + logPath, + attemptId, + sandboxPolicy, + runLaunchManifest, + conversation + ), + startClaudeCode: ({ + task, + agentConfig, + transport, + logPath, + attemptId, + startedAt, + emitter, + sandboxPolicy, + runLaunchManifest, + }) => + this.startClaudeCode( + task, + agentConfig, + transport.content, logPath, attemptId, startedAt, emitter, sandboxPolicy, - runLaunchManifest, - }) => { - this.assertProviderAdapterTransport(provider, transport, runLaunchManifest); - await this.startCodexAppServer( - task, - agentConfig, - transport.content, - logPath, - attemptId, - startedAt, - emitter, - sandboxPolicy, - runLaunchManifest - ); - }, - stop: async ({ pending }) => { - try { - await pending.codexAppServerControl?.interrupt(); - } catch (error) { - log.warn( - { err: error, taskId: pending.taskId }, - 'Codex app-server cooperative interrupt failed; closing the supervised process' - ); - } - pending.codexAppServerControl?.close(); - const child = pending.process; - if (!child || child.exitCode != null || child.signalCode != null) return; - const forcedStop = setTimeout(() => { - if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL'); - }, 5_000); - child.once('close', () => clearTimeout(forcedStop)); - }, - }; - } - - if (provider === 'acp-stdio') { - return { - id: definition.id, - label: definition.label, - renderTaskEnvelope: renderAcpStdioTaskEnvelope, - probe: (context) => this.probeAcpProviderRuntime(context, definition), - runEventMapper: getProviderRunEventMapper(provider), - start: async ({ + runLaunchManifest + ), + startHermesCli: ({ + task, + agentConfig, + transport, + logPath, + attemptId, + startedAt, + emitter, + sandboxPolicy, + }) => + this.startHermesCli( task, agentConfig, - transport, - logPath, - attemptId, - sandboxPolicy, - runLaunchManifest, - conversation, - }) => { - this.assertProviderAdapterTransport(provider, transport, runLaunchManifest); - await this.startAcpStdio( - task, - agentConfig, - transport.content, - logPath, - attemptId, - sandboxPolicy, - runLaunchManifest, - conversation - ); - }, - stop: async ({ pending }) => { - pending.abortController?.abort(); - await pending.acpControl?.cancel().catch(() => undefined); - await pending.acpControl?.close().catch(() => undefined); - }, - }; - } - - if (provider === 'claude-code') { - return { - id: definition.id, - label: definition.label, - renderTaskEnvelope: renderClaudeCodeTaskEnvelope, - probe, - runEventMapper: getProviderRunEventMapper(provider), - start: async ({ - task, - agentConfig, - transport, + transport.content, logPath, attemptId, startedAt, emitter, - sandboxPolicy, - runLaunchManifest, - }) => { - this.assertProviderAdapterTransport(provider, transport, runLaunchManifest); - await this.startClaudeCode( - task, - agentConfig, - transport.content, - logPath, - attemptId, - startedAt, - emitter, - sandboxPolicy, - runLaunchManifest - ); - }, - stop: ({ pending }) => { - const child = pending.process; - if (!child || child.exitCode != null || child.signalCode != null) return; - child.kill('SIGTERM'); - const forcedStop = setTimeout(() => { - if (child.exitCode == null && child.signalCode == null) { - child.kill('SIGKILL'); - log.warn( - { taskId: pending.taskId }, - '[ClawdbotAgent] Claude Code SIGKILL issued after graceful stop timeout' - ); - } - }, 5_000); - child.once('close', () => clearTimeout(forcedStop)); - }, - }; - } - - if (provider === 'hermes-cli') { - return { - id: definition.id, - label: definition.label, - renderTaskEnvelope: renderHermesTaskEnvelope, - probe, - runEventMapper: getProviderRunEventMapper(provider), - start: async ({ - task, - agentConfig, - transport, - logPath, - attemptId, - startedAt, - emitter, - sandboxPolicy, - runLaunchManifest, - }) => { - this.assertProviderAdapterTransport(provider, transport, runLaunchManifest); - await this.startHermesCli( - task, - agentConfig, - transport.content, - logPath, - attemptId, - startedAt, - emitter, - sandboxPolicy - ); - }, - stop: ({ pending }) => { - if (pending.process && !pending.process.killed) { - pending.process.kill('SIGTERM'); - // Bounded forced-stop: send SIGKILL after 5 s if the process is still running - const forcedStop = setTimeout(() => { - if (pending.process && !pending.process.killed) { - pending.process.kill('SIGKILL'); - log.warn( - { taskId: pending.taskId }, - '[ClawdbotAgent] Hermes SIGKILL issued after graceful stop timeout' - ); - } - }, 5_000); - pending.process.once('close', () => clearTimeout(forcedStop)); - } - }, - }; - } - - return { - id: definition.id, - label: definition.label, - renderTaskEnvelope: renderOpenClawTaskEnvelope, - probe, - runEventMapper: getProviderRunEventMapper(provider), - start: async ({ transport, task, attemptId, agentConfig, runLaunchManifest }) => { - this.assertProviderAdapterTransport(provider, transport, runLaunchManifest); - // Use the HTTP gateway adapter (sessions_spawn) instead of writing a request file. - // The real spawn acknowledgement surfaces policy denial or gateway - // unreachability, which the caller's error handler rolls back to 'todo'. - const openclawAdapter = new HttpOpenClawTaskAdapter(); - const result = await openclawAdapter.spawnTask({ - taskId: task.id, - attemptId, - agentId: agentConfig?.type || 'openclaw', - agentName: agentConfig?.name, - model: agentConfig?.model, - prompt: transport.content, - timeoutSeconds: 900, - }); - await this.attemptLifecycle.patchActiveAttempt(task.id, attemptId, { - sessionKey: result.sessionKey, - }); - await this.recordConversationIdentity(task.id, attemptId, { - conversationId: result.sessionKey, - }); - void this.recordAgentStarted( - task, - attemptId, - agentConfig?.type || 'openclaw', - 'openclaw', - agentConfig - ); - const pending = pendingAgents.get(task.id); - if (!pending || pending.attemptId !== attemptId || !pending.supervisorId) { - throw new ConflictError('OpenClaw session has no durable run supervisor binding.', { - taskId: task.id, - attemptId, - }); - } - pending.openclawSessionKey = result.sessionKey; - await this.runSupervisor.attachRemoteSession(pending.supervisorId, result.sessionKey); - log.info( - { taskId: task.id, attemptId, sessionKey: result.sessionKey }, - '[ClawdbotAgent] OpenClaw session spawned via gateway' - ); - }, - stop: async ({ pending }) => { - // OpenClaw does not expose a direct stop API for sub-sessions in v2026.6.11. - // Completion is driven by the callback URL included in the task prompt. - log.warn( - { taskId: pending.taskId, sessionKey: pending.openclawSessionKey }, - '[ClawdbotAgent] OpenClaw stop requested; sub-session will complete via callback' - ); - }, + sandboxPolicy + ), + startOpenClaw: (context) => this.startOpenClawAdapter(context), + warn: (details, message) => log.warn(details, message), }; + return new AgentProviderAdapterRegistry(host); + } + + private async handleCodexSdkAdapterError( + context: AgentProviderStartContext, + abortController: AbortController, + error: unknown + ): Promise { + const { task, attemptId, emitter, logPath, agentConfig } = context; + const current = pendingAgents.get(task.id); + if (!current || current.attemptId !== attemptId) return; + if (error instanceof CompletionPersistenceError) { + if (emitter.listenerCount('error') > 0) { + emitter.emit('error', error.persistenceCause); + } + log.error( + { err: error.persistenceCause, taskId: task.id, attemptId }, + 'Codex SDK completion could not be persisted after bounded retries' + ); + return; + } + + abortController.abort(); + const message = this.redactTraceText( + error instanceof Error ? error.message : 'Codex SDK attempt failed' + ); + try { + const journalEvent = await this.appendRunEvent( + task.id, + attemptId, + 'run.error', + { summary: message, error: message, phase: 'stream' }, + { + provider: 'codex-sdk', + adapter: 'codex-sdk', + agent: agentConfig?.type || 'codex-sdk', + model: agentConfig?.model, + } + ); + this.emitJournalOutput(journalEvent); + await this.appendLog(logPath, `\n## Codex SDK Error\n\n${message}\n`); + } catch (logError) { + log.error({ err: logError, taskId: task.id }, 'Failed to record Codex SDK error evidence'); + } + + try { + await this.completeAgent( + task.id, + { success: false, error: message }, + { + attemptId, + terminalSource: 'stream', + providerRuntimeManifestDigest: current.providerRuntimeManifest.digest, + } + ); + } catch (finalizationError) { + const retryable = + current.preparedCompletion !== undefined && + !(finalizationError instanceof CompletionOwnershipError); + if (!retryable && pendingAgents.get(task.id)?.attemptId === attemptId) { + pendingAgents.delete(task.id); + } + if (emitter.listenerCount('error') > 0) { + emitter.emit('error', finalizationError); + } + log.error( + { err: finalizationError, taskId: task.id, attemptId, retryable }, + retryable + ? 'Codex SDK failure completion remains pending after bounded persistence retries' + : 'Codex SDK failure could not update stale persisted attempt state' + ); + } + } + + private async startOpenClawAdapter(context: AgentProviderStartContext): Promise { + const { transport, task, attemptId, agentConfig } = context; + const openclawAdapter = new HttpOpenClawTaskAdapter(); + const result = await openclawAdapter.spawnTask({ + taskId: task.id, + attemptId, + agentId: agentConfig?.type || 'openclaw', + agentName: agentConfig?.name, + model: agentConfig?.model, + prompt: transport.content, + timeoutSeconds: 900, + }); + await this.attemptLifecycle.patchActiveAttempt(task.id, attemptId, { + sessionKey: result.sessionKey, + }); + await this.recordConversationIdentity(task.id, attemptId, { + conversationId: result.sessionKey, + }); + void this.recordAgentStarted( + task, + attemptId, + agentConfig?.type || 'openclaw', + 'openclaw', + agentConfig + ); + const pending = pendingAgents.get(task.id); + if (!pending || pending.attemptId !== attemptId || !pending.supervisorId) { + throw new ConflictError('OpenClaw session has no durable run supervisor binding.', { + taskId: task.id, + attemptId, + }); + } + pending.openclawSessionKey = result.sessionKey; + await this.runSupervisor.attachRemoteSession(pending.supervisorId, result.sessionKey); + log.info( + { taskId: task.id, attemptId, sessionKey: result.sessionKey }, + '[ClawdbotAgent] OpenClaw session spawned via gateway' + ); } private assertProviderAdapterLaunchManifest( @@ -7153,11 +6977,13 @@ export class ClawdbotAgentService { attemptId, agentConfig, 'codex-app-server', - this.resolveProviderAdapter('codex-app-server').runEventMapper.mapEvent( - classified.providerType, - recordValueForProvider(record, 'params'), - classified.summary - ) + this.providerAdapters + .resolve('codex-app-server') + .runEventMapper.mapEvent( + classified.providerType, + recordValueForProvider(record, 'params'), + classified.summary + ) ); this.emitJournalOutput(journalEvent); if (classified.usage) { @@ -7264,11 +7090,9 @@ export class ClawdbotAgentService { attemptId, agentConfig, 'codex-app-server', - this.resolveProviderAdapter('codex-app-server').runEventMapper.mapEvent( - method, - recordValueForProvider(record, 'params'), - summary - ) + this.providerAdapters + .resolve('codex-app-server') + .runEventMapper.mapEvent(method, recordValueForProvider(record, 'params'), summary) ); this.emitJournalOutput(requested); const resolved = await this.appendRunEvent( @@ -7723,11 +7547,9 @@ export class ClawdbotAgentService { attemptId, agentConfig, 'claude-code', - this.resolveProviderAdapter('claude-code').runEventMapper.mapEvent( - classified.providerType, - record, - classified.summary - ) + this.providerAdapters + .resolve('claude-code') + .runEventMapper.mapEvent(classified.providerType, record, classified.summary) ); this.emitJournalOutput(journalEvent); if (classified.usage) { @@ -8741,7 +8563,7 @@ export class ClawdbotAgentService { ): Promise { const content = this.redactTraceText(chunk.trimEnd()); if (!content.trim()) return; - const mapper = this.resolveProviderAdapter(provider).runEventMapper; + const mapper = this.providerAdapters.resolve(provider).runEventMapper; const event = await this.appendMappedProviderEvent( task, attemptId, @@ -8823,7 +8645,7 @@ export class ClawdbotAgentService { attemptId, agentConfig, provider, - this.resolveProviderAdapter(provider).runEventMapper.mapEvent(type, event, sanitizedSummary) + this.providerAdapters.resolve(provider).runEventMapper.mapEvent(type, event, sanitizedSummary) ); this.emitJournalOutput(journalEvent); if (usage) {