refactor: extract provider adapter registry (#1230)

This commit is contained in:
Brad Groux 2026-08-24 01:05:02 -05:00 committed by GitHub
parent 7cc8f253c7
commit 93ea9577d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 761 additions and 463 deletions

View file

@ -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/` |
---

View file

@ -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

View file

@ -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.

View file

@ -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<ExecutableAgentProvider, string> = {
'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<ExecutableAgentProvider, ReturnType<typeof vi.fn>> = {
'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')
);
});
});

View file

@ -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<void>;
close(): void;
};
acpControl?: Pick<AcpStdioControl, 'cancel' | 'close'>;
openclawSessionKey?: string;
}
export interface AgentProviderStopContext {
taskId: string;
pending: ProviderAdapterPendingRun;
}
export interface AgentProviderAdapter {
id: ExecutableAgentProvider;
label: string;
renderTaskEnvelope(input: ProviderTaskEnvelopeRenderInput): ProviderTaskEnvelopeTransport;
probe(context: AgentProviderProbeContext): Promise<ProviderRuntimeManifest>;
runEventMapper: ProviderRunEventMapper;
start(context: AgentProviderStartContext): Promise<void> | void;
stop(context: AgentProviderStopContext): Promise<void> | void;
}
export interface AgentProviderAdapterHost {
probe(
provider: ExecutableAgentProvider,
context: AgentProviderProbeContext,
definition: ProviderRuntimeAdapterDefinition
): Promise<ProviderRuntimeManifest>;
probeAcp(
context: AgentProviderProbeContext,
definition: ProviderRuntimeAdapterDefinition
): Promise<ProviderRuntimeManifest>;
assertTransport(
provider: ExecutableAgentProvider,
transport: ProviderTaskEnvelopeTransport,
manifest: RunLaunchManifest
): void;
getPending(taskId: string): ProviderAdapterPendingRun | undefined;
startCodexCli(context: AgentProviderStartContext): Promise<void>;
startCodexSdk(
context: AgentProviderStartContext,
abortController: AbortController
): Promise<void>;
handleCodexSdkError(
context: AgentProviderStartContext,
abortController: AbortController,
error: unknown
): Promise<void>;
startCodexAppServer(context: AgentProviderStartContext): Promise<void>;
startAcpStdio(context: AgentProviderStartContext): Promise<void>;
startClaudeCode(context: AgentProviderStartContext): Promise<void>;
startHermesCli(context: AgentProviderStartContext): Promise<void>;
startOpenClaw(context: AgentProviderStartContext): Promise<void>;
warn(details: Record<string, unknown>, message: string): void;
}
type TaskEnvelopeRenderer = (
input: ProviderTaskEnvelopeRenderInput
) => ProviderTaskEnvelopeTransport;
const TASK_ENVELOPE_RENDERERS: Record<ExecutableAgentProvider, TaskEnvelopeRenderer> = {
'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> | 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<void> {
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;
}
}
}

View file

@ -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<ProviderRuntimeManifest>;
runEventMapper: ProviderRunEventMapper;
start(context: AgentProviderStartContext): Promise<void> | void;
stop(context: AgentProviderStopContext): Promise<void> | 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<void> {
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<void> {
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<void> {
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) {