Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
637 changes: 637 additions & 0 deletions packages/agent-core-v2/src/_base/di/cascadeEngine.ts

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions packages/agent-core-v2/src/_base/di/dependencyGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* `di` domain — persistent dependency graph (L2 substrate).
*
* Edges are recorded when a service's constructor dependencies are resolved
* and removed when the consumer is torn down, so the graph always mirrors the
* live container. Instance edges bind a consumer to its dependency's
* generation (the dependency changes → the consumer is torn down and rebuilt);
* collection edges (Phase 3) are recorded for introspection but never join a
* cascade contagion set.
*/

import type { ServiceIdentifier } from './instantiation';

export type DependencyEdgeKind = 'instance' | 'collection';

export interface DependencyEdge {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly consumer: ServiceIdentifier<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly dependency: ServiceIdentifier<any>;
readonly kind: DependencyEdgeKind;
}

export class DependencyGraph {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly _tokenByInstance = new Map<object, ServiceIdentifier<any>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly _instanceByToken = new Map<ServiceIdentifier<any>, object>();
/** consumer instance → (dependency token → edge kind) */
private readonly _out = new Map<object, Map<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ServiceIdentifier<any>,
DependencyEdgeKind
>>();
/** dependency token → (consumer instance → edge kind) */
private readonly _in = new Map<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ServiceIdentifier<any>,
Map<object, DependencyEdgeKind>
>();

/** Register a materialized service instance so its edges can be tracked. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
addInstance(instance: object, token: ServiceIdentifier<any>): void {
this._tokenByInstance.set(instance, token);
this._instanceByToken.set(token, instance);
}

/** Drop a consumer: its outbound edges and token mapping (inbound token edges stay). */
removeInstance(instance: object): void {
const token = this._tokenByInstance.get(instance);
if (token !== undefined) {
this._tokenByInstance.delete(instance);
if (this._instanceByToken.get(token) === instance) {
this._instanceByToken.delete(token);
}
}
const out = this._out.get(instance);
if (out !== undefined) {
for (const dependency of out.keys()) {
this._in.get(dependency)?.delete(instance);
}
this._out.delete(instance);
}
}

addEdge(
consumerInstance: object,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dependency: ServiceIdentifier<any>,
kind: DependencyEdgeKind = 'instance',
): void {
let out = this._out.get(consumerInstance);
if (out === undefined) {
out = new Map();
this._out.set(consumerInstance, out);
}
out.set(dependency, kind);

let inbound = this._in.get(dependency);
if (inbound === undefined) {
inbound = new Map();
this._in.set(dependency, inbound);
}
inbound.set(consumerInstance, kind);
}

/**
* The contagion set: the changed tokens plus every token whose live instance
* transitively depends on them through instance edges.
*/
affectedSet(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
changed: Iterable<ServiceIdentifier<any>>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Set<ServiceIdentifier<any>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const affected = new Set<ServiceIdentifier<any>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const queue: ServiceIdentifier<any>[] = [];
for (const token of changed) {
if (!affected.has(token)) {
affected.add(token);
queue.push(token);
}
}
while (queue.length > 0) {
const token = queue.pop()!;
const inbound = this._in.get(token);
if (inbound === undefined) continue;
for (const [consumerInstance, kind] of inbound) {
if (kind !== 'instance') continue;
const consumerToken = this._tokenByInstance.get(consumerInstance);
if (consumerToken === undefined || affected.has(consumerToken)) continue;
affected.add(consumerToken);
queue.push(consumerToken);
}
}
return affected;
}

/** Dependencies-first order over the given token subset (instance edges only). */
topoOrder(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tokens: Iterable<ServiceIdentifier<any>>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ServiceIdentifier<any>[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subset = new Set<ServiceIdentifier<any>>(tokens);
// token → in-subset dependencies
const dependencies = new Map<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ServiceIdentifier<any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Set<ServiceIdentifier<any>>
>();
for (const token of subset) {
dependencies.set(token, this._dependenciesOf(token, subset));
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ordered: ServiceIdentifier<any>[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const permanent = new Set<ServiceIdentifier<any>>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const visit = (token: ServiceIdentifier<any>): void => {
if (permanent.has(token)) return;
permanent.add(token);
for (const dependency of dependencies.get(token) ?? []) {
visit(dependency);
}
ordered.push(token);
};
for (const token of subset) {
visit(token);
}
return ordered;
}

/** Dependents-first order (teardown order) over the given token subset. */
reverseTopoOrder(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tokens: Iterable<ServiceIdentifier<any>>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ServiceIdentifier<any>[] {
return this.topoOrder(tokens).toReversed();
}

/** Instance-edge cycle check over the live graph; returns the cycle path or null. */
findCycle(): string[] | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state = new Map<ServiceIdentifier<any>, 'visiting' | 'done'>();
const path: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const visit = (token: ServiceIdentifier<any>): string[] | null => {
state.set(token, 'visiting');
path.push(token.toString());
for (const dependency of this._dependenciesOf(token, undefined)) {
const mark = state.get(dependency);
if (mark === 'done') continue;
if (mark === 'visiting') {
const start = path.indexOf(dependency.toString());
return [...path.slice(start), dependency.toString()];
}
const cycle = visit(dependency);
if (cycle !== null) return cycle;
}
path.pop();
state.set(token, 'done');
return null;
};
for (const token of this._instanceByToken.keys()) {
if (state.has(token)) continue;
const cycle = visit(token);
if (cycle !== null) return cycle;
}
return null;
}

/** Introspection: every live edge (both kinds), token-level. */
edges(): DependencyEdge[] {
const edges: DependencyEdge[] = [];
for (const [consumerInstance, out] of this._out) {
const consumer = this._tokenByInstance.get(consumerInstance);
if (consumer === undefined) continue;
for (const [dependency, kind] of out) {
edges.push({ consumer, dependency, kind });
}
}
return edges;
}

clear(): void {
this._tokenByInstance.clear();
this._instanceByToken.clear();
this._out.clear();
this._in.clear();
}

/** In-subset instance-edge dependencies of a token's live instance. */
private _dependenciesOf(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
token: ServiceIdentifier<any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
subset: Set<ServiceIdentifier<any>> | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Set<ServiceIdentifier<any>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = new Set<ServiceIdentifier<any>>();
const instance = this._instanceByToken.get(token);
if (instance === undefined) return result;
const out = this._out.get(instance);
if (out === undefined) return result;
for (const [dependency, kind] of out) {
if (kind !== 'instance') continue;
if (subset !== undefined && !subset.has(dependency)) continue;
result.add(dependency);
}
return result;
}
}
16 changes: 16 additions & 0 deletions packages/agent-core-v2/src/_base/di/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,19 @@ export class CyclicDependencyError extends Error {
this.name = 'CyclicDependencyError';
}
}

/**
* Raised when a resolution hits a token inside an in-flight cascade
* transaction's contagion set. The async resolution path suspends instead
* (see `CascadeEngine.resolveWhenAvailable`); the sync path cannot suspend,
* so it fails fast with this error.
*/
export class CascadeConflictError extends Error {
constructor(
readonly token: string,
readonly detail: string,
) {
super(`Cascade conflict resolving '${token}': ${detail}`);
this.name = 'CascadeConflictError';
}
}
35 changes: 33 additions & 2 deletions packages/agent-core-v2/src/_base/di/instantiation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* `di` domain — service identifiers, `createDecorator`, and the `IInstantiationService` contract.
*/

import type { SyncDescriptor0 } from './descriptors';
import type { DisposableStore } from './lifecycle';
import type { SyncDescriptor, SyncDescriptor0 } from './descriptors';
import type { DisposableStore, IDisposable } from './lifecycle';
import type { ServiceCollection } from './serviceCollection';

// eslint-disable-next-line @typescript-eslint/no-namespace
Expand Down Expand Up @@ -109,6 +109,26 @@ export interface ServicesAccessor {
get<T>(id: ServiceIdentifier<T>): T;
}

export interface ProvideOptions {
/** Cascade-line metadata (L4): a pinned unit never joins a cascade. */
readonly pinned?: boolean;
/**
* `eager` (default): the unit activates as soon as its dependencies are
* satisfied. `ondemand`: it materializes at first resolution (a cascade-torn
* unit always rebuilds regardless).
*/
readonly activation?: 'eager' | 'ondemand';
}

/**
* Handle to one `provide` registration: it is an entry in the provider's
* ledger, so disposing the handle unprovides the token. (Grows into the full
* FiberHandle — thenable / state / update — in Phase 3.)
*/
export interface ProvideHandle extends IDisposable {
readonly uid: number;
}

export interface IInstantiationService {
readonly _serviceBrand: undefined;

Expand All @@ -129,6 +149,17 @@ export interface IInstantiationService {
...args: GetLeadingNonServiceArgs<ConstructorParameters<Ctor>>
): R;
createChild(services: ServiceCollection, store?: DisposableStore): IInstantiationService;
/**
* Register (or replace) a token at runtime. Replacing retires the previous
* materialized instance before the new generation becomes visible.
*/
provide<T>(
id: ServiceIdentifier<T>,
instanceOrDescriptor: T | SyncDescriptor<T>,
options?: ProvideOptions,
): ProvideHandle;
/** Remove a token, retiring its materialized instance. No-op when absent. */
unprovide<T>(id: ServiceIdentifier<T>): void;
dispose(): void;
}

Expand Down
Loading
Loading