Cordis의 API는 복잡하지 않습니다:
플러그인은 inject로 의존성을 선언할 수 있습니다:
플러그인 내부의 등록은 ctx.effect(), ctx.on(), ctx.provide() 등의 인터페이스를 통해 처리합니다.
이 시스템의 실제 능력을 결정하는 부분은 API 아래에 있습니다. Context, Fiber, Service Registry, Effect cleanup이 계속 변하는 플러그인 그래프를 어떻게 함께 유지하는지가 그 핵심입니다.
이번 글에서는 소스 코드를 직접 따라가며 이 체인을 분석합니다.
Context가 보관하는 것은 일반적인 Map이 아닙니다
Cordis의 Context는 Proxy 기반 의존성 컨테이너입니다.[2]
루트 Context를 생성하면 몇 개의 기본 서비스가 설치됩니다:
Context
├── fiber
├── reflect
├── registry
├── events
└── logger
일반 속성 접근도 최종적으로 Service Resolver로 들어가므로 플러그인 코드에서는 다음처럼 직접 작성할 수 있습니다:
다음처럼 쓸 필요가 없습니다:
이 차이는 API 형태상의 차이에 불과합니다. 하지만 Context가 Scope 정보를 함께 담고 있다는 점이 더 중요합니다.
extend()는 자식 Context를 만들고, isolate()는 특정 Service name을 하위 트리에서 독립된 스코프로 분리하며, intercept()는 하위 플러그인에 Service별 config를 추가합니다.[2]
따라서 Context는 오히려 다음에 더 가깝습니다:
service namespace
+
scope
+
lifecycle owner
+
event environment
각 Plugin Fiber는 부모 Context에서 자신의 Context를 파생합니다:
그래서 플러그인이 만든 Effect는 자동으로 현재 Fiber에 소속됩니다.[1]
Fiber는 Plugin의 실행 인스턴스입니다
ctx.plugin()은 최종적으로 Fiber를 생성합니다.
현재 소스 코드는 Fiber에 다음 상태들을 정의합니다:
PENDING
LOADING
ACTIVE
FAILED
UNLOADING
DISPOSED
Fiber가 보유한 항목은 다음과 같습니다:
plugin runtime
config
inject declarations
resolved dependency implementations
owned effects
current epoch
in-flight lifecycle transition
따라서 Fiber는 단순한 PluginHandle보다 한 단계 더 많은 역할을 맡습니다. 현재 의존성 환경에서 플러그인의 유효성을 유지하는 것이죠.[1]
플러그인이 다음과 같이 선언한 뒤:
Fiber는 두 Service의 현재 구현을 각각 해석해 _store에 저장합니다.
둘 중 하나라도 의존성이 누락되면:
tools = available
llm = missing
Fiber의 epoch가 다음 값으로 바뀝니다:
__INACTIVE__
플러그인은 PENDING 상태를 유지합니다.
모든 의존성을 사용할 수 있을 때만 epoch가 현재 Service Provider Fiber의 uid로 구성됩니다:
:12:27
이 세부 사항은 중요합니다.
의존성 판단은 Service name의 존재 여부만 확인하는 것이 아니라, 현재 어떤 구현이 그 Service를 제공하는지도 epoch에 담습니다.
따라서 다음 두 상태는 서로 다른 것으로 간주됩니다:
tools provided by Fiber 12
반면:
tools provided by Fiber 31
Service name이 그대로 tools여도 Provider가 바뀌면 플러그인이 다시 로드됩니다.[1]
Service 등록 자체가 Effect입니다
ctx.provide(name, value)는 Map에 데이터를 단순히 넣는 방식이 아닙니다.
내부에서는 다음을 통해:
현재 Fiber가 소유한 Effect를 등록합니다.[2]
로드할 때는:
store[key] = implementation
Provider Fiber가 이미 ACTIVE 상태라면 등록 즉시 의존자에게 알립니다. Service가 플러그인의 LOADING 단계에서 등록되면, Cordis는 Fiber가 ACTIVE로 전환될 때까지 기다린 뒤 상태 전환 과정에서 해당 Fiber가 제공하는 Service를 함께 알립니다. 따라서 의존자가 Provider 자체의 초기화가 끝나기 전에 너무 일찍 활성화되는 일은 없습니다.[1][2]
언로드할 때 disposer는 다음과 같이 동작합니다:
delete store[key]
notify(name)
이렇게 만들어지는 라이프사이클 관계가 매우 중요합니다:
Fiber owns Service
↓
Fiber unload
↓
Service removed automatically
Plugin은 이제 다음을 따로 관리할 필요가 없습니다:
Service의 수명은 이를 제공한 Fiber에 자연스럽게 묶입니다.
이벤트 Listener, Accessor, Mixin 등의 기능도 비슷한 Effect ownership 방식을 사용합니다.
이것이 논문에서 말하는 Temporal Composability를 Cordis에 구현한 가장 직접적인 방식입니다.
A를 언로드하면 왜 B가 자동으로 무효화되는가
이제 하나의 의존성 체인을 분석해 보겠습니다:
Plugin A
└── provides serviceA
Plugin B
├── injects serviceA
└── provides serviceB
Plugin C
└── injects serviceB
초기 상태는 다음과 같습니다:
A ACTIVE
↓ serviceA
B ACTIVE
↓ serviceB
C ACTIVE
A를 언로드하면 A의 Service Effect부터 dispose되기 시작합니다.
ctx.provide('serviceA')에 대응하는 disposer가 먼저 구현을 삭제합니다:
그다음 다음을 호출합니다:
notify()는 현재 Registry에 있는 Fiber를 살펴보고, 해당 inject를 선언한 소비자를 찾습니다. B의 경우 다음과 같습니다:[2]
serviceA changed
↓
B._checkImpl('serviceA')
↓
implementation missing
↓
delete B._store.serviceA
↓
B._refresh()
_refresh()가 B의 epoch를 다시 계산합니다.
의존성이 누락되었으므로:
epoch = __INACTIVE__
다음으로 _setEpoch()에서 다음 상황을 확인합니다:
old epoch = active dependency epoch
new epoch = INACTIVE
그래서 B는 다음과 같이 전환됩니다:
UNLOADING
그리고 _unload()를 실행합니다.[1]
이 과정은 Provider를 언로드할 때 모든 하위 Plugin을 직접 찾아 일일이 파괴할 필요가 없다는 사실에 답해 줍니다.
Provider는 자신이 제공하는 Service만 철회하면 됩니다. Service Registry가 해당 Service에 의존하는 Fiber에 알리고, 각 Fiber가 자신의 의존성 선언을 기준으로 무효화 여부를 결정합니다.
B의 언로드가 왜 C에까지 영향을 주는가
B의 _unload()는 B가 소유한 Effects를 정리합니다.
그중 다음 항목도 포함됩니다:
provide(serviceB)
따라서 B를 언로드하는 동안 serviceB 역시 제거되고, 다음이 다시 실행됩니다:
그 결과 C의 의존성 상태가 바뀝니다:
serviceB disappears
↓
C._refresh()
↓
C epoch = INACTIVE
↓
C unload
전체 체인은 다음과 같이 이어집니다:
A unload
↓
serviceA removed
↓
B invalidated
↓
B unload
↓
serviceB removed
↓
C invalidated
↓
C unload
여기에는 놓치기 쉬운 대기 관계도 있습니다. ctx.provide()의 disposer는 Service를 삭제하고 notify()를 호출한 다음, 영향을 받는 의존 Fiber의 await()가 안정될 때까지 기다립니다. 따라서 A의 serviceA disposer는 B가 이번 라이프사이클 조정을 마칠 때까지 기다리고, B가 serviceB를 정리할 때는 다시 C가 안정될 때까지 기다립니다.[2]
비동기 관계를 다음과 같이 그릴 수 있습니다:
이 구조는 의존성 체인의 자원들이 단순히 한꺼번에 정리되지 않는 이유를 보여 줍니다. Service는 먼저 새 해석에서 사용할 수 없게 되고, 하위 Fiber는 이어서 자신의 철회를 완료합니다. 상위 disposer는 직접 영향을 받은 Fiber가 안정될 때까지 기다립니다. 의존 관계는 각 계층이 제공하는 Service를 통해 계속 아래로 전파됩니다.
이것은 분명히 일종의 연쇄 동작이지만, 명시적으로 작성한 재귀 알고리즘은 아닙니다.
각 계층에서는 다음 두 가지 국소적인 사실만 처리합니다:
Service changed
Fiber dependencies changed
새로운 Service 변화는 다음 계층을 계속 트리거하고, 결국 시스템에는 더 이상 의존성 변화가 없는 안정 상태가 만들어집니다.
따라서 더 정확히 말하면, Cordis는 Service 변경 이벤트를 중심으로 국소적인 reconcile을 수행하며, 의존성 체인은 여러 번의 국소 상태 전환을 통해 자연스럽게 전파됩니다.
현재 내장된(vendored) 구현의 notify()는 Registry에 있는 Fiber를 순회한 뒤 inject와 isolation scope를 기준으로 영향을 받는 대상을 필터링합니다. 의미는 의존 이름에 따른 국소 갱신이지만, 구현에서는 완전한 역방향 의존성 그래프를 미리 유지할 필요가 없습니다.[2]
언로드는 즉시 일어나는가
Service가 제거되면 의존 Fiber는 즉시 라이프사이클 전환을 시작하지만, 언로드 과정 자체는 비동기일 수 있습니다.
Fiber는 다음 값을 저장합니다:
이는 현재 실행 중인 load / unload 전환을 나타냅니다.[1]
epoch가 변경되면 _setEpoch()는 목표 epoch를 갱신합니다. 이때 inertia가 이미 있으면 두 번째 라이프사이클 작업을 동시에 시작하지 않습니다.
예를 들어 B가 언로드되는 중인데 serviceA가 다시 나타나면:
B UNLOADING
serviceA appears
이때 B의 목표 epoch는 다시 active epoch가 되지만, _setEpoch()은 inertia가 이미 존재하는 것을 확인하고 _reload()를 동시에 실행하지 않습니다.
현재 _unload()가 끝나면 목표 epoch를 다시 확인합니다:
따라서 실제 과정은 다음과 같습니다:
이렇게 하면 하나의 Plugin이 load와 unload를 동시에 실행하는 일을 방지할 수 있습니다.
여기서 epoch는 Fiber가 인식하는 ‘현재 의존성 세계’의 버전 식별자로 이해할 수 있습니다.
목표 의존성 환경이 바뀔 때만 라이프사이클을 다시 조정해야 합니다.
Effect의 해체 순서
Fiber는 자체 _disposables를 관리합니다.
DisposableList.clear()는 disposer를 등록 순서의 역순으로 반환하므로, 나중에 등록된 Effect가 먼저 teardown에 들어갑니다.[1]
하지만 Fiber의 _unload()는 최상위 disposer들을 Promise.all로 실행해 비동기 정리를 수행합니다. 즉 최상위 Effect의 호출은 역순으로 시작되지만, 비동기 완료 순서에는 전역적인 직렬 보장이 없습니다.
단일 ctx.effect() 내부에서 수집한 disposer는 역순으로 하나씩 실행됩니다.
DeepSeek Harness의 Cordis Primer도 이 점을 특히 강조합니다. 일부 자원 사이에 엄격한 teardown 순서가 필요하다면, 같은 Effect에 함께 넣고 그 Effect가 철회 순서를 직접 정의해야 합니다.[7]
이 제약은 매우 현실적입니다. 프레임워크는 라이프사이클 소유권을 관리하지만, 두 독립 자원 사이의 업무상 순서를 기본적으로 추측해서는 안 됩니다.
Context는 언제 다시 계산되는가
Cordis에는 모든 플러그인을 주기적으로 스캔하는 백그라운드 루프가 없습니다.
핵심 트리거는 다음과 같은 구조 변화에서 옵니다:
Service registered
Service removed
Service implementation changes
plugin config changes
plugin restart / dispose
Service를 예로 들면:
ctx.provide()
↓
reflect.notify()
↓
affected fibers _checkImpl()
↓
_refresh()
↓
compute dependency epoch
↓
_setEpoch()
epoch가 바뀌지 않았다면:
라이프사이클에는 아무 일도 일어나지 않습니다.
INACTIVE에서 active로 전환되면:
PENDING → LOADING → ACTIVE
active에서 INACTIVE로 전환되거나 Provider identity가 바뀌면:
ACTIVE → UNLOADING
Provider identity가 바뀌면 언로드가 끝난 후 새로운 epoch를 기준으로 다시 로드됩니다.
따라서 reconcile의 최소 단위는 Fiber이며, 트리거는 Fiber가 선언한 의존성이 동작하는 환경의 변화입니다.
DeepSeek Harness는 왜 이 계층이 필요한가
DeepSeek Harness의 현재 아키텍처는 거의 모든 주요 기능을 플러그인으로 설치합니다.[6]
공식 아키텍처 문서는 모델 어댑터, Tool Registry, Session Log, Agent Loop 자체가 모두 플러그인이며, 모든 확장이 패치하도록 요구하는 privileged kernel은 없다고 직접 설명합니다.[6]
즉, DeepSeek Harness의 실행 구조는 다음에 더 가깝습니다:
Agent Loop 자체는 다음에 의존할 수 있습니다:
Session
LLM
Tools
SystemPrompt
특정 Provider가 교체되면, 그에 의존하는 Plugin Fiber는 동일한 epoch 메커니즘을 따라 다시 조정될 수 있습니다.
따라서 Everything is a Plugin에 필요한 것은 통일된 Loader만이 아닙니다. 진짜 어려운 부분은 다음과 같습니다:
插件出现和消失
+
Service 出现和消失
+
依赖关系变化
+
副作用完整撤销
+
异步生命周期不互相竞争
Cordis가 바로 이 런타임 의미를 제공합니다.
Spring의 관점에서 이해하기
Spring에 익숙하다면 몇 가지 개념을 제한적으로 대응시킬 수 있습니다:
이 대응은 출발점을 잡기 위한 것이며, 그대로 동일시할 수는 없습니다.
Spring의 일반적인 ApplicationContext는 refresh 이후 구조가 비교적 안정적입니다. Bean 의존성은 주로 생성 시점에 해석되며, 런타임 중에 Bean 하나를 삭제해 모든 하위 Bean을 자동으로 무효화하고, 의존성이 복구된 뒤 다시 활성화하는 것은 통상적인 BeanFactory 라이프사이클 모델이 아닙니다.
Cordis는 이런 동적 구조 변화를 정상적인 실행 경로로 삼습니다.
따라서 Cordis의 핵심은 ‘Spring보다 DI 컨테이너가 하나 더 있다’는 데 있지 않습니다. DI와 라이프사이클 reconcile을 연결한 데 있습니다.
이 모델의 비용
동적 조합에는 대가가 따릅니다.
먼저 Plugin 코드는 의존성을 정확히 선언해야 합니다. Service를 암묵적으로 읽으면 Runtime의 의존성 인식이 깨집니다.
다음으로 모든 외부 등록은 Effect ownership에 들어가야 합니다. Context를 우회해 전역 구조에 상태를 직접 기록하면 자동으로 철회할 수 없는 자원이 남습니다.
다시 Plugin은 반복해서 load / unload할 수 있어야 합니다. 초기화 함수를 더 이상 기본적으로 한 번만 실행하는 것으로 가정할 수 없습니다.
마지막으로 동적 의존성은 상태기 복잡도를 높입니다. PENDING / LOADING / ACTIVE / UNLOADING / FAILED, 비동기 cleanup, 재진입, HMR는 모두 엄밀하게 처리해야 합니다.
따라서 Cordis는 런타임 조합이 실제로 필요한 시스템에 적합합니다. 능력이 고정된 소형 Agent의 경우 모든 모듈을 동적 플러그인으로 추상화하는 것은 이해 비용만 높입니다.
DeepSeek Harness의 제품 목표에는 플러그인 교체, 구성 조합, HMR, 격리된 Scope, 폭넓은 확장 지점이 포함되므로, 이 복잡성이 명확한 이득을 제공합니다.
다음 글의 Demo에서는 Cordis의 핵심 의미를 아주 작은 구현으로 압축합니다. Service, Dependency, Effect, Fiber, reconcile이죠. 실제로 실행해 보면 A → B → C 의존성 체인이 어떻게 무효화되고, 이후 A가 복구되면서 각 계층이 다시 활성화되는지 관찰할 수 있습니다.
참고 자료
[1] Cordis Fiber 소스: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts
[2] Cordis Reflect / Service 해석: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts
[3] Cordis Context 소스: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/context.ts
[4] Cordis Registry 소스: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/registry.ts
[5] Cordis Service 소스: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/service.ts
[6] DeepSeek Harness 아키텍처: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md
[7] DeepSeek Harness Cordis 프라이머: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-primer.md