@telorun/kernel 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,8 @@ import {
10
10
  type InstanceFactory,
11
11
  type InvokeContext,
12
12
  type LifecycleState,
13
+ type OpenSpan,
14
+ type OpenSpanOptions,
13
15
  type PreInitHook,
14
16
  type ResourceDefinition,
15
17
  type ResourceInstance,
@@ -347,10 +349,13 @@ export class EvaluationContext implements IEvaluationContext {
347
349
  if (this.resourceInstances.has(name)) continue;
348
350
  try {
349
351
  if (this.preInitHook) {
350
- this.preInitHook(resource, (n, alias) =>
351
- alias && alias !== "Self"
352
- ? this.resolveImportedInstance(alias, n)
353
- : this.resourceInstances.get(n)?.instance,
352
+ this.preInitHook(
353
+ resource,
354
+ (n, alias) =>
355
+ alias && alias !== "Self"
356
+ ? this.resolveImportedInstance(alias, n)
357
+ : this.resourceInstances.get(n)?.instance,
358
+ (n) => this.hasManifest(n) && !this.resourceInstances.has(n),
354
359
  );
355
360
  }
356
361
  if (instance.init) await instance.init(ctx);
@@ -464,11 +469,17 @@ export class EvaluationContext implements IEvaluationContext {
464
469
  // Propagate injection hook: extend getInstance to also resolve parent singleton instances.
465
470
  if (parent.preInitHook) {
466
471
  const parentHook = parent.preInitHook;
467
- child.preInitHook = (resource, childGetInstance) => {
468
- parentHook(resource, (name, alias) =>
469
- alias && alias !== "Self"
470
- ? parent.resolveImportedInstance(alias, name)
471
- : childGetInstance(name) ?? parent.resourceInstances.get(name)?.instance,
472
+ child.preInitHook = (resource, childGetInstance, childIsPending) => {
473
+ parentHook(
474
+ resource,
475
+ (name, alias) =>
476
+ alias && alias !== "Self"
477
+ ? parent.resolveImportedInstance(alias, name)
478
+ : childGetInstance(name) ?? parent.resourceInstances.get(name)?.instance,
479
+ // A scoped ref resolves against the scope's own resources or an already-inited
480
+ // outer one; only a scope-local dependency can still be pending. The outer is
481
+ // live by the time a scope opens, so the child's own predicate suffices.
482
+ childIsPending,
472
483
  );
473
484
  };
474
485
  }
@@ -604,6 +615,50 @@ export class EvaluationContext implements IEvaluationContext {
604
615
  return this.runInvoke(kind, name, instance, inputs, ctx);
605
616
  }
606
617
 
618
+ /**
619
+ * Build the structured trace payload every capability dispatch emits. The
620
+ * event *name* stays human-meaningful (`<name>.Invoked`) for bus subscribers;
621
+ * everything a debug consumer needs to rebuild the call tree rides here, so the
622
+ * consumer never parses the dotted name. `spanId`/`parentSpanId` are the
623
+ * tracer's `invocationId`/`parentInvocationId` (present only while tracing);
624
+ * `ref` carries the kind+name the name no longer encodes; `detail` is the
625
+ * per-capability data (inputs/outputs, error fields, cancellation reason).
626
+ */
627
+ /**
628
+ * A redacted snapshot of the CEL root scope a debug consumer should see for a
629
+ * trace — `variables`, masked `secrets`, resource `snapshots`, `ports`. Attached
630
+ * to a trace's *root* span so the consumer can inspect what data the execution
631
+ * could reference (beyond its own inputs/outputs). Only a `ModuleContext` owns a
632
+ * root scope; child scopes return undefined. Host `env` is deliberately omitted
633
+ * (it is the raw process environment — too broad/sensitive to dump).
634
+ */
635
+ protected traceRootScope(): Record<string, unknown> | undefined {
636
+ return undefined;
637
+ }
638
+
639
+ protected tracePayload(
640
+ kind: string,
641
+ name: string,
642
+ spanId: number | undefined,
643
+ parentSpanId: number | undefined,
644
+ traceId: string | undefined,
645
+ capability: "invoke" | "run" | "provide" | "request",
646
+ phase: "start" | "end",
647
+ outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
648
+ detail: Record<string, unknown>,
649
+ ): Record<string, unknown> {
650
+ return {
651
+ traceId,
652
+ spanId,
653
+ parentSpanId,
654
+ capability,
655
+ phase,
656
+ ...(outcome !== undefined ? { outcome } : {}),
657
+ ref: { kind, name },
658
+ ...detail,
659
+ };
660
+ }
661
+
607
662
  private async runInvoke<TInputs>(
608
663
  kind: string,
609
664
  name: string,
@@ -619,33 +674,60 @@ export class EvaluationContext implements IEvaluationContext {
619
674
  const token = baseCtx.cancellation;
620
675
 
621
676
  // Tracing gate (a debug consumer is attached): mint a monotonic id for this
622
- // invocation, parent it to the ambient one, and ride both on every event's
623
- // `metadata` so the consumer can rebuild the call tree. Off by default —
624
- // `tracedCtx` stays `baseCtx` and the fast `=== ambient` skip is preserved.
677
+ // invocation, parent it to the ambient one, and ride both in every event's
678
+ // payload so the consumer can rebuild the call tree. Off by default —
679
+ // `invokeCtx` stays `baseCtx` and the fast `=== ambient` skip is preserved.
625
680
  const tracing = this.tracer?.enabled === true;
626
681
  const invocationId = tracing ? this.tracer!.next() : undefined;
627
682
  // Parent precedence mirrors the token: an explicit seed `ctx` wins, then the
628
683
  // ambient (ALS) invocation. A caller threading its own `ctx.invocationId` thus
629
684
  // has it honored as the parent, not silently dropped for the ALS value.
630
685
  const parentInvocationId = ctx?.invocationId ?? ambient?.invocationId;
631
- const meta = tracing ? { invocationId, parentInvocationId } : undefined;
686
+ // Inherit the trace from the parent (explicit ctx wins, then ambient); mint a
687
+ // fresh one only at a root. Carried on every span so an OTel exporter groups
688
+ // the trace without walking the parent chain.
689
+ const traceId = tracing
690
+ ? (ctx?.traceId ?? ambient?.traceId ?? this.tracer!.newTraceId())
691
+ : undefined;
692
+ // Capture the root CEL scope once, on the trace's root span's terminal event.
693
+ const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
694
+ const span = (
695
+ phase: "start" | "end",
696
+ outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
697
+ detail: Record<string, unknown>,
698
+ ) =>
699
+ this.tracePayload(
700
+ kind,
701
+ name,
702
+ invocationId,
703
+ parentInvocationId,
704
+ traceId,
705
+ "invoke",
706
+ phase,
707
+ outcome,
708
+ phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
709
+ );
632
710
  // When tracing, a fresh context carries the new id down the tree so nested
633
711
  // invokes read it as their parent; it is never `=== ambient`, so the call
634
712
  // always (re)establishes the ALS scope.
635
713
  const invokeCtx: InvokeContext = tracing
636
- ? { cancellation: token, invocationId, parentInvocationId }
714
+ ? { cancellation: token, invocationId, parentInvocationId, traceId }
637
715
  : baseCtx;
638
716
 
639
717
  // Pre-dispatch gate: a sub-invoke reached after the tree was cancelled is
640
718
  // refused without ever touching the controller.
641
719
  if (token.isCancelled) {
642
- await this.emit(`${kind}.${name}.InvokeCancelled`, { inputs, reason: token.reason }, meta);
720
+ await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason: token.reason }));
643
721
  throw new RuntimeError(
644
722
  "ERR_INVOKE_CANCELLED",
645
723
  `Invoke ${kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
646
724
  );
647
725
  }
648
726
 
727
+ // Start span — only under tracing, so non-traced behaviour stays exactly
728
+ // one terminal event per call (subscribers to `<name>.Invoked` are unaffected).
729
+ if (tracing) await this.emit(`${name}.Invoking`, span("start", undefined, { inputs }));
730
+
649
731
  try {
650
732
  // Only (re)establish the ALS scope when the token differs from the ambient
651
733
  // one — nested invokes that inherited it skip the redundant `run`.
@@ -661,36 +743,34 @@ export class EvaluationContext implements IEvaluationContext {
661
743
  const outputs = await (invokeCtx === ambient
662
744
  ? call()
663
745
  : cancellationStore.run(invokeCtx, call));
664
- await this.emit(`${kind}.${name}.Invoked`, { inputs, outputs }, meta);
746
+ await this.emit(`${name}.Invoked`, span("end", "ok", { inputs, outputs }));
665
747
  return outputs;
666
748
  } catch (err) {
667
749
  // Cooperative mid-flight cancellation (`throwIfCancelled`) joins the same
668
750
  // observable event family rather than masquerading as a rejection/failure.
669
751
  if (isCancellationError(err)) {
670
752
  const reason = err instanceof Error ? err.message : String(err);
671
- await this.emit(`${kind}.${name}.InvokeCancelled`, { inputs, reason }, meta);
753
+ await this.emit(`${name}.InvokeCancelled`, span("end", "cancelled", { inputs, reason }));
672
754
  throw err;
673
755
  }
674
756
  if (isInvokeError(err)) {
675
- const payload = { inputs, code: err.code, message: err.message, data: err.data };
676
- await this.emit(`${kind}.${name}.InvokeRejected`, payload, meta);
757
+ const detail = { inputs, code: err.code, message: err.message, data: err.data };
758
+ await this.emit(`${name}.InvokeRejected`, span("end", "rejected", detail));
677
759
  const declaredCodes = this.getDeclaredThrowCodes(kind);
678
760
  if (declaredCodes && !declaredCodes.has(err.code)) {
679
- await this.emit(`${kind}.${name}.InvokeRejected.Undeclared`, payload, meta);
761
+ await this.emit(`${name}.InvokeRejected.Undeclared`, span("end", "rejected", detail));
680
762
  }
681
763
  throw err;
682
764
  }
683
765
  if (err instanceof Error) {
684
766
  await this.emit(
685
- `${kind}.${name}.InvokeFailed`,
686
- { inputs, name: err.name, message: err.message },
687
- meta,
767
+ `${name}.InvokeFailed`,
768
+ span("end", "failed", { inputs, name: err.name, message: err.message }),
688
769
  );
689
770
  } else {
690
771
  await this.emit(
691
- `${kind}.${name}.InvokeFailed`,
692
- { inputs, name: "UnknownError", message: String(err) },
693
- meta,
772
+ `${name}.InvokeFailed`,
773
+ span("end", "failed", { inputs, name: "UnknownError", message: String(err) }),
694
774
  );
695
775
  }
696
776
  // Already enriched at an inner invoke: keep the innermost (most
@@ -712,6 +792,26 @@ export class EvaluationContext implements IEvaluationContext {
712
792
  }
713
793
  }
714
794
 
795
+ /**
796
+ * The declared capability of a kind, or undefined if unknown. Definitions are
797
+ * keyed by their canonical `<module>.<Kind>`, but a resource carries the alias
798
+ * kind it was written with (`Http.Server`), so resolve the alias first.
799
+ * Best-effort: `resolveKind` exists only on a `ModuleContext` and throws for
800
+ * unqualified / ungated kinds, so guard and fall back to the raw kind.
801
+ */
802
+ /** Resolve an alias kind (`Http.Server`) to its canonical `<module>.<Kind>`.
803
+ * Only a `ModuleContext` carries the import-alias table; the base returns the
804
+ * kind unchanged. A typed seam (overridden in `ModuleContext`), matching the
805
+ * `traceRootScope()` pattern, rather than reaching across the boundary. */
806
+ protected resolveKindSafe(kind: string): string {
807
+ return kind;
808
+ }
809
+
810
+ private capabilityOf(kind: string): string | undefined {
811
+ const resolved = this.resolveKindSafe(kind);
812
+ return this.getDefinition?.(resolved)?.capability ?? this.getDefinition?.(kind)?.capability;
813
+ }
814
+
715
815
  private getDeclaredThrowCodes(kind: string): Set<string> | null {
716
816
  if (!this.getDefinition) return null;
717
817
  const def = this.getDefinition(kind);
@@ -730,28 +830,191 @@ export class EvaluationContext implements IEvaluationContext {
730
830
 
731
831
  async run(name: string, ctx?: InvokeContext): Promise<void> {
732
832
  const entry = this.resourceInstances.get(name);
733
- if (entry && typeof entry.instance.run === "function") {
734
- const ambient = cancellationStore.getStore();
735
- const invokeCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
736
- const token = invokeCtx.cancellation;
737
- // Refuse a target reached after the boot run was cancelled.
738
- if (token.isCancelled) {
739
- await this.emit(`${entry.resource.kind}.${name}.RunCancelled`, { reason: token.reason });
740
- throw new RuntimeError(
741
- "ERR_INVOKE_CANCELLED",
742
- `Run ${entry.resource.kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
833
+ if (!(entry && typeof entry.instance.run === "function")) {
834
+ throw new RuntimeError(
835
+ "ERR_RESOURCE_NOT_RUNNABLE",
836
+ `Resource ${name} is not runnable or not found. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,
837
+ );
838
+ }
839
+ return this.runInstance(entry.resource.kind as string, name, entry.instance, ctx);
840
+ }
841
+
842
+ /**
843
+ * Like run(), but the caller has already resolved the instance (e.g. a
844
+ * Phase-5-injected `!ref` boot target). Shares the single span-emitting path so
845
+ * a pre-resolved runnable is instrumented exactly like a by-name dispatch
846
+ * instead of escaping the chokepoint with a direct `instance.run()` call.
847
+ */
848
+ async runResolved(
849
+ kind: string,
850
+ name: string,
851
+ instance: ResourceInstance,
852
+ ctx?: InvokeContext,
853
+ ): Promise<void> {
854
+ if (typeof instance.run !== "function") {
855
+ throw new RuntimeError(
856
+ "ERR_RESOURCE_NOT_RUNNABLE",
857
+ `Resource ${kind}.${name} does not have a run method`,
858
+ );
859
+ }
860
+ return this.runInstance(kind, name, instance, ctx);
861
+ }
862
+
863
+ /**
864
+ * Open a trace span for an inbound boundary (an HTTP request). Mints a span
865
+ * that roots a fresh trace (or continues `opts.inbound`), emits its `start`,
866
+ * and returns a child context to thread into `invokeResolved` so the handler
867
+ * nests under it. A no-op pass-through when tracing is off.
868
+ */
869
+ async openSpan(base: InvokeContext | undefined, opts: OpenSpanOptions): Promise<OpenSpan> {
870
+ const ctx = base ?? UNCANCELLABLE_CONTEXT;
871
+ if (this.tracer?.enabled !== true) {
872
+ return { context: ctx, settle: async () => {} };
873
+ }
874
+ const spanId = this.tracer.next();
875
+ const traceId = opts.inbound?.traceId ?? this.tracer.newTraceId();
876
+ const parentSpanId = opts.inbound?.parentSpanId;
877
+ // A root request span (not continuing an upstream trace) carries the root scope.
878
+ const rootScope = parentSpanId === undefined ? this.traceRootScope() : undefined;
879
+ const detail = {
880
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
881
+ ...(opts.attributes !== undefined ? { attributes: opts.attributes } : {}),
882
+ };
883
+ const payload = (
884
+ phase: "start" | "end",
885
+ outcome: "ok" | "failed" | "rejected" | "cancelled" | undefined,
886
+ extra: Record<string, unknown> = {},
887
+ ) =>
888
+ this.tracePayload(
889
+ opts.ref.kind,
890
+ opts.ref.name,
891
+ spanId,
892
+ parentSpanId,
893
+ traceId,
894
+ "request",
895
+ phase,
896
+ outcome,
897
+ { ...detail, ...extra },
898
+ );
899
+
900
+ await this.emit(`${opts.ref.name}.Requesting`, payload("start", undefined));
901
+ const context: InvokeContext = {
902
+ cancellation: ctx.cancellation,
903
+ invocationId: spanId,
904
+ parentInvocationId: parentSpanId,
905
+ traceId,
906
+ };
907
+ let settled = false;
908
+ return {
909
+ context,
910
+ settle: async (outcome, extra) => {
911
+ if (settled) return;
912
+ settled = true;
913
+ await this.emit(
914
+ `${opts.ref.name}.Request`,
915
+ payload("end", outcome, rootScope ? { ...extra, context: rootScope } : extra),
743
916
  );
917
+ },
918
+ };
919
+ }
920
+
921
+ private async runInstance(
922
+ kind: string,
923
+ name: string,
924
+ instance: ResourceInstance,
925
+ ctx?: InvokeContext,
926
+ ): Promise<void> {
927
+ const ambient = cancellationStore.getStore();
928
+ const baseCtx = ctx ?? ambient ?? UNCANCELLABLE_CONTEXT;
929
+ const token = baseCtx.cancellation;
930
+
931
+ // A long-lived Service's `run()` is not a one-shot dispatch: it stays pending
932
+ // for the process lifetime, so wrapping it in the cancellation/trace ALS scope
933
+ // would leak that scope onto every async resource the service creates (e.g. an
934
+ // HTTP server's listening socket → every inbound request callback). Such a
935
+ // service must NOT establish an ambient: its token reaches it via the explicit
936
+ // `run(invokeCtx)` argument (how it observes shutdown), and its externally
937
+ // triggered work then starts with a clean ambient — separate traces, no
938
+ // inherited cancellation. Runnables (one-shot, e.g. `Run.Sequence`) keep the
939
+ // ALS scope so their steps nest and inherit cancellation.
940
+ const isService = this.capabilityOf(kind) === "Telo.Service";
941
+
942
+ // Span instrumentation mirrors `runInvoke` — minting an id here makes
943
+ // Runnables (a `Run.Sequence` boot target) appear in the trace and re-parents
944
+ // their nested invokes. A long-lived Service emits only the `start` span (its
945
+ // `run()` resolves at teardown), the "running" signal a debug consumer wants.
946
+ const tracing = this.tracer?.enabled === true;
947
+ const invocationId = tracing ? this.tracer!.next() : undefined;
948
+ const parentInvocationId = ctx?.invocationId ?? ambient?.invocationId;
949
+ const traceId = tracing
950
+ ? (ctx?.traceId ?? ambient?.traceId ?? this.tracer!.newTraceId())
951
+ : undefined;
952
+ const rootScope = tracing && parentInvocationId === undefined ? this.traceRootScope() : undefined;
953
+ const span = (
954
+ phase: "start" | "end",
955
+ outcome: "ok" | "failed" | "cancelled" | undefined,
956
+ detail: Record<string, unknown>,
957
+ ) =>
958
+ this.tracePayload(
959
+ kind,
960
+ name,
961
+ invocationId,
962
+ parentInvocationId,
963
+ traceId,
964
+ "run",
965
+ phase,
966
+ outcome,
967
+ phase === "end" && rootScope ? { ...detail, context: rootScope } : detail,
968
+ );
969
+ const invokeCtx: InvokeContext = tracing
970
+ ? { cancellation: token, invocationId, parentInvocationId, traceId }
971
+ : baseCtx;
972
+
973
+ // Refuse a target reached after the boot run was cancelled.
974
+ if (token.isCancelled) {
975
+ await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason: token.reason }));
976
+ throw new RuntimeError(
977
+ "ERR_INVOKE_CANCELLED",
978
+ `Run ${kind}.${name} was cancelled${token.reason ? `: ${token.reason}` : ""}`,
979
+ );
980
+ }
981
+
982
+ if (tracing) await this.emit(`${name}.Running`, span("start", undefined, {}));
983
+
984
+ try {
985
+ // Runnable: run inside the ALS scope so nested invokes inherit the token and
986
+ // trace id (skip the redundant `run` when the token is already ambient).
987
+ // Service: call directly with the explicit context and NO ambient scope, so
988
+ // its long-lived async work does not capture this scope.
989
+ const call = () => (instance.run as (c?: InvokeContext) => Promise<void>)(invokeCtx);
990
+ await (isService || invokeCtx === ambient ? call() : cancellationStore.run(invokeCtx, call));
991
+ await this.emit(`${name}.Run`, span("end", "ok", {}));
992
+ } catch (err) {
993
+ if (isCancellationError(err)) {
994
+ const reason = err instanceof Error ? err.message : String(err);
995
+ await this.emit(`${name}.RunCancelled`, span("end", "cancelled", { reason }));
996
+ throw err;
744
997
  }
745
- // Run inside the scope so the runnable's nested invokes inherit the token,
746
- // and pass it explicitly so long-lived targets can observe cancellation.
747
- // Skip the redundant `run` when the token is already the ambient one.
748
- const call = () => (entry.instance.run as (c?: InvokeContext) => Promise<void>)(invokeCtx);
749
- return invokeCtx === ambient ? call() : cancellationStore.run(invokeCtx, call);
998
+ const detail =
999
+ err instanceof Error
1000
+ ? { name: err.name, message: err.message }
1001
+ : { name: "UnknownError", message: String(err) };
1002
+ await this.emit(`${name}.RunFailed`, span("end", "failed", detail));
1003
+ throw err;
750
1004
  }
751
- throw new RuntimeError(
752
- "ERR_RESOURCE_NOT_RUNNABLE",
753
- `Resource ${name} is not runnable or not found. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,
754
- );
1005
+ }
1006
+
1007
+ /**
1008
+ * Run `fn` detached from the caller's cancellation/trace scope. The current
1009
+ * ambient `InvokeContext` (a request's token + span) is replaced with the
1010
+ * uncancellable root, so request-scope teardown cannot abort the work and it
1011
+ * does not nest under the request's trace. This is the bare scope primitive —
1012
+ * tracking/draining a detached task is the owning resource's concern (the
1013
+ * per-resource `ResourceContext` records the task and drains it in the
1014
+ * resource's teardown).
1015
+ */
1016
+ runDetached<T>(fn: () => Promise<T>): Promise<T> {
1017
+ return cancellationStore.run(UNCANCELLABLE_CONTEXT, fn);
755
1018
  }
756
1019
 
757
1020
  /**
package/src/events.ts CHANGED
@@ -70,6 +70,11 @@ export class EventBus {
70
70
  }
71
71
 
72
72
  async emit(event: string, payload?: any, metadata?: any): Promise<void> {
73
+ // O(1) idle short-circuit: with no subscriber at all — the common case when
74
+ // no debug consumer is attached — emitting costs a single integer compare,
75
+ // no map walk and no allocation. This is what keeps routing every dispatch
76
+ // through the instrumented chokepoint effectively free when nobody listens.
77
+ if (this.handlers.size === 0) return;
73
78
  const handlers: EventHandler[] = [];
74
79
  for (const [pattern, set] of this.handlers.entries()) {
75
80
  if (!this.matchesPattern(pattern, event)) {
package/src/kernel.ts CHANGED
@@ -132,6 +132,9 @@ export class Kernel implements IKernel {
132
132
  // SIGINT (before runTargets) still has a source to cancel, which the run then
133
133
  // observes via the pre-dispatch gate.
134
134
  private _bootCancellation?: CancellationSource;
135
+ // Root application name — labels the boot `targets` trace span so the app
136
+ // appears as the trace root with its targets nested beneath.
137
+ private _appName?: string;
135
138
 
136
139
  readonly stdin: NodeJS.ReadableStream;
137
140
  readonly stdout: NodeJS.WritableStream;
@@ -327,8 +330,8 @@ export class Kernel implements IKernel {
327
330
  await this.loadBuiltinDefinitions();
328
331
 
329
332
  // Phase 5: attach injection hook — fires between create() and init() for every resource
330
- this.rootContext.preInitHook = (resource, getInstance) =>
331
- this._injectDependencies(resource, getInstance);
333
+ this.rootContext.preInitHook = (resource, getInstance, isPending) =>
334
+ this._injectDependencies(resource, getInstance, isPending);
332
335
 
333
336
  // Expose definition lookup so invoke()/invokeResolved() can check thrown
334
337
  // InvokeError.code against the declared throw union (rule 9). Propagates
@@ -511,6 +514,7 @@ export class Kernel implements IKernel {
511
514
  this.rootContext.setTargets(rawTargets as BootTarget[]);
512
515
  if (manifest.kind === "Telo.Application") {
513
516
  rootApplicationManifest = manifest;
517
+ this._appName = (manifest.metadata as { name?: string } | undefined)?.name;
514
518
  }
515
519
  }
516
520
  this.rootContext.registerManifest(manifest);
@@ -638,7 +642,7 @@ export class Kernel implements IKernel {
638
642
  this._targetsRan = true;
639
643
 
640
644
  await this.eventBus.emit("Kernel.Starting", {});
641
- await this.rootContext.runTargets(this.bootCancellation.context);
645
+ await this.rootContext.runTargets(this.bootCancellation.context, this._appName);
642
646
  await this.eventBus.emit("Kernel.Started", {});
643
647
  }
644
648
 
@@ -1013,6 +1017,17 @@ export class Kernel implements IKernel {
1013
1017
  const instance = await controller.create(processedResource, ctx);
1014
1018
  if (!instance) return null;
1015
1019
 
1020
+ // Fold the resource's fire-and-forget drain into its own teardown: tearing
1021
+ // the resource down drains the background tasks it spawned (the kernel just
1022
+ // calls teardown() — it tracks no tasks itself). A drain with no pending
1023
+ // tasks is a no-op, so this is safe for every resource.
1024
+ const ownerCtx = ctx as ResourceContextImpl;
1025
+ const originalTeardown = instance.teardown?.bind(instance);
1026
+ instance.teardown = async () => {
1027
+ await ownerCtx.drainDetached();
1028
+ if (originalTeardown) await originalTeardown();
1029
+ };
1030
+
1016
1031
  if (!runtime.length) return { instance, ctx, resource: processedResource };
1017
1032
 
1018
1033
  // Override invoke in-place so all lifecycle methods (init/invoke/teardown/snapshot)
@@ -1039,10 +1054,11 @@ export class Kernel implements IKernel {
1039
1054
  private _injectDependencies(
1040
1055
  resource: ResourceManifest,
1041
1056
  getInstance: (name: string, alias?: string) => ResourceInstance | undefined,
1057
+ isPending?: (name: string) => boolean,
1042
1058
  ): void {
1043
1059
  this.registry.iterateFieldEntries(
1044
1060
  resource,
1045
- (fieldPath) => injectAtPath(resource, fieldPath, getInstance),
1061
+ (fieldPath) => injectAtPath(resource, fieldPath, getInstance, isPending),
1046
1062
  (fieldPath) => {
1047
1063
  const val = (resource as Record<string, unknown>)[fieldPath];
1048
1064
  if (Array.isArray(val)) {