@telorun/kernel 0.85.0 → 0.87.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.
@@ -28,8 +28,9 @@ import {
28
28
  type Tracer,
29
29
  } from "@telorun/sdk";
30
30
  import { RuntimeError } from "@telorun/sdk";
31
- import { evalPathCovers } from "@telorun/analyzer";
31
+ import { celResourceReads, evalPathCovers } from "@telorun/analyzer";
32
32
  import { effectOwnerOf, executeReturnedChain } from "./effect-scope.js";
33
+ import { impactClosure, reverseTopologicalOrder } from "./resource-edges.js";
33
34
  import {
34
35
  REDACTED,
35
36
  redactSensitive,
@@ -536,6 +537,25 @@ export class EvaluationContext implements IEvaluationContext {
536
537
  */
537
538
  private readonly withheldResources = new Set<string>();
538
539
 
540
+ /**
541
+ * Keys whose instance was REMOVED BY AN UNWIND — the fact a dispatch that
542
+ * misses actually needs, as opposed to the context-wide state that only
543
+ * approximates it.
544
+ *
545
+ * A miss because the resource went away is the runtime withdrawing, not a
546
+ * manifest defect, and the two want opposite follow-ups (see `invoke`). Both
547
+ * paths that withdraw one go through `unwindEach`, the single removal site, so
548
+ * recording it there covers a shutdown AND a reconciliation — where a check
549
+ * against `state` would have converted the first and missed the second, since
550
+ * `unwindResources` deliberately leaves the state untouched. The second is the
551
+ * one that fires on every watch-session save.
552
+ *
553
+ * Cleared when the key is registered again: after a reconcile the name means a
554
+ * live resource, and a stale entry would report a genuine missing-resource
555
+ * defect as a cancellation.
556
+ */
557
+ private readonly unwoundResources = new Set<string>();
558
+
539
559
  /** Resources discarded after a failed `init()` and re-queued for creation.
540
560
  * Their re-creation is not progress — see the create sub-phase. */
541
561
  private readonly recreatedResources = new Set<string>();
@@ -571,6 +591,7 @@ export class EvaluationContext implements IEvaluationContext {
571
591
  owner: EvaluationContext,
572
592
  ): void | (() => void) {
573
593
  this.resourceInstances.set(name, { resource, instance });
594
+ this.unwoundResources.delete(name);
574
595
  this.borrowedResources.add(name);
575
596
  this.declaredManifests.set(name, resource);
576
597
  const unmirror = owner.mirrorPublications(resource.metadata.name as string, (props) =>
@@ -636,10 +657,32 @@ export class EvaluationContext implements IEvaluationContext {
636
657
 
637
658
  /** Per-resource dependency names, captured at create() time — BEFORE Phase-5
638
659
  * injection swaps refs for live instances, so the walk sees plain objects and
639
- * cannot wander into a controller's (possibly cyclic) object graph. Read only
640
- * when init fails, to attribute each failure to its cause. */
660
+ * cannot wander into a controller's (possibly cyclic) object graph.
661
+ *
662
+ * Read twice, and RETAINED for the context's lifetime because of the second
663
+ * reader: to attribute an init failure to its cause, and to order teardown
664
+ * (`teardownOrder`) so a consumer's inverses run while the resources it holds
665
+ * are still alive. */
641
666
  private readonly resourceDependencies = new Map<string, string[]>();
642
667
 
668
+ /**
669
+ * Names resolved by NAME during initialization, rather than through a
670
+ * declared reference slot — so a resource somebody may be holding, with no
671
+ * edge recording who.
672
+ *
673
+ * The set is of TARGETS, not of pairs: the door that records
674
+ * (`ModuleContext.getInstance`) is reached as `ctx.moduleContext`, which every
675
+ * resource of the module shares, so there is no caller to attribute the read
676
+ * to. That is enough for the one decision that depends on it — see
677
+ * {@link impactedBy}.
678
+ */
679
+ protected readonly opaquelyRead = new Set<string>();
680
+
681
+ /** Record a by-name resolution. Called by the recording door only. */
682
+ protected recordOpaqueRead(name: string): void {
683
+ this.opaquelyRead.add(name);
684
+ }
685
+
643
686
  /**
644
687
  * Optional hook called between create() and init() for each resource.
645
688
  * Set by the kernel to inject live instances into reference fields.
@@ -876,6 +919,32 @@ export class EvaluationContext implements IEvaluationContext {
876
919
  this.declaredManifests.set(name, resource);
877
920
  }
878
921
 
922
+ /**
923
+ * Forget a declaration entirely — the inverse of {@link registerManifest}.
924
+ *
925
+ * Reconciliation's other half: {@link unwindResources} disposes the INSTANCE,
926
+ * and this clears everything keyed by the name so the same name can be
927
+ * declared again. Without it `registerManifest` refuses with
928
+ * `ERR_DUPLICATE_RESOURCE`, which is the right answer for a manifest
929
+ * declaring one name twice and the wrong one for a second load of the same
930
+ * manifest.
931
+ *
932
+ * Every per-name record goes, not just the declaration: a resource left in
933
+ * `withheldResources` would be skipped by the init loop for the life of the
934
+ * kernel, and a stale `createdInstances` entry would have the loop initialize
935
+ * the object built from the PREVIOUS declaration.
936
+ */
937
+ deregisterManifest(name: string): void {
938
+ this.declaredManifests.delete(name);
939
+ this.resourceDependencies.delete(name);
940
+ this.createdInstances.delete(name);
941
+ this.withheldResources.delete(name);
942
+ this.recreatedResources.delete(name);
943
+ this.opaquelyRead.delete(name);
944
+ const pending = this.pendingResources.findIndex((r) => r.metadata?.name === name);
945
+ if (pending >= 0) this.pendingResources.splice(pending, 1);
946
+ }
947
+
879
948
  /**
880
949
  * The manifest a name was DECLARED with, resolved scope-local first and then
881
950
  * up the enclosing chain — the order `getInstance` and the CEL `resources`
@@ -1048,7 +1117,12 @@ export class EvaluationContext implements IEvaluationContext {
1048
1117
  if (!this.recreatedResources.has(name)) progress = true;
1049
1118
  const createdRes = created.resource;
1050
1119
  const refs = collectResourceRefs(createdRes);
1051
- this.resourceDependencies.set(name, localDependencyNames(refs));
1120
+ // `resource` rather than `createdRes`: the registered declaration
1121
+ // still holds its expressions, while the created copy holds the
1122
+ // values they were expanded to.
1123
+ this.resourceDependencies.set(name, [
1124
+ ...new Set([...localDependencyNames(refs), ...celResourceReads(resource)]),
1125
+ ]);
1052
1126
  const payload: Record<string, unknown> = {
1053
1127
  resource: {
1054
1128
  kind: createdRes.kind,
@@ -1163,10 +1237,11 @@ export class EvaluationContext implements IEvaluationContext {
1163
1237
  // so it resolves fine from here.
1164
1238
  await this.publishSnapshot(name);
1165
1239
  this.resourceInstances.set(name, { resource, instance });
1240
+ // Live again: a reconcile re-initializes what it unwound, and leaving
1241
+ // the mark would report a genuine missing-resource defect at this name
1242
+ // as a cancellation for the rest of the process.
1243
+ this.unwoundResources.delete(name);
1166
1244
  this.createdInstances.delete(name);
1167
- // Read only on failure, and this one succeeded — drop it rather than
1168
- // holding a dep-name array per resource for the context's lifetime.
1169
- this.resourceDependencies.delete(name);
1170
1245
  errors.delete(name);
1171
1246
  progress = true;
1172
1247
  await this.emit(`${resource.kind}.${resource.metadata.name}.Initialized`, {
@@ -1365,16 +1440,33 @@ export class EvaluationContext implements IEvaluationContext {
1365
1440
  }
1366
1441
 
1367
1442
  /**
1368
- * Cascade teardown depth-first through the tree:
1369
- * 1. Tear down child contexts in reverse registration order.
1370
- * 2. Tear down own resource instances in reverse registration order,
1371
- * emitting a Teardown event for each via the injected emit callback.
1443
+ * Cascade teardown through the tree:
1444
+ * 1. Tear down own resource instances in {@link teardownOrder}, emitting a
1445
+ * Teardown event for each via the injected emit callback.
1446
+ * 2. Sweep any child context still standing, in {@link childTeardownOrder}.
1447
+ *
1448
+ * **Own resources go FIRST, and the child sweep is a backstop.** A child
1449
+ * context that belongs to a resource is torn down by that resource's own
1450
+ * inverse — an import's `init()` returns `child.teardownResources()`, and so
1451
+ * does a template's — so it already unwinds at its owner's position in step 1,
1452
+ * which is the position the edges put it at. Running a child cascade ahead of
1453
+ * step 1 tore every imported library down before any resource of THIS context,
1454
+ * so an app resource holding `!ref Alias.name` unwound after its provider was
1455
+ * already gone, and the owner's inverse then found nothing left to do.
1456
+ *
1457
+ * Sweeping afterwards rather than not at all is what still reclaims a context
1458
+ * no inverse claims: a `lifecycle: shared` library, which is spawned under the
1459
+ * root and deliberately gives no importer a claim on it, and an import whose
1460
+ * `init()` never ran to register one. `teardownResources` is idempotent, so a
1461
+ * context already taken down in step 1 costs the sweep nothing.
1372
1462
  */
1373
1463
  // eslint-disable-next-line @typescript-eslint/member-ordering
1374
1464
  async teardownResources(): Promise<void> {
1375
1465
  this.state = "Draining";
1376
- const failures: Array<{ resource: string; error: unknown }> = [];
1466
+ const failures = await this.unwindEach(this.teardownOrder());
1377
1467
 
1468
+ // The backstop: whatever no resource's inverse claimed. Everything an import
1469
+ // or a template owns is already down, so this is a no-op for it.
1378
1470
  for (const child of this.childTeardownOrder()) {
1379
1471
  try {
1380
1472
  await child.teardownResources();
@@ -1383,7 +1475,59 @@ export class EvaluationContext implements IEvaluationContext {
1383
1475
  }
1384
1476
  }
1385
1477
 
1386
- for (const [key, { resource, instance }] of this.teardownOrder()) {
1478
+ this.state = "Teardown";
1479
+ this.raiseTeardownFailures(failures);
1480
+ }
1481
+
1482
+ /**
1483
+ * Unwind SOME of this context's resources and leave the rest running.
1484
+ *
1485
+ * The reconciliation half of teardown: a host that has decided which
1486
+ * declarations moved unwinds exactly {@link impactedBy}'s answer, then
1487
+ * re-registers and re-initializes. Ordering is {@link teardownOrder}
1488
+ * restricted to the selection, so a consumer still unwinds before what it
1489
+ * holds — and the selection being closed under holders is what makes that
1490
+ * true of the resources left standing as well, since none of them holds
1491
+ * anything in it.
1492
+ *
1493
+ * The context keeps its state: it is neither draining nor torn down, and no
1494
+ * child context is swept, because a child belonging to an unwound import goes
1495
+ * down with that import's own inverse exactly as it does at teardown.
1496
+ *
1497
+ * A name with no live instance is skipped rather than reported — a resource
1498
+ * that failed to initialize has nothing to unwind, and a host asking for it is
1499
+ * asking about a declaration, not about an instance.
1500
+ */
1501
+ // eslint-disable-next-line @typescript-eslint/member-ordering
1502
+ async unwindResources(names: ReadonlySet<string>): Promise<void> {
1503
+ const selected = this.teardownOrder().filter(([, entry]) =>
1504
+ names.has(entry.resource.metadata.name as string),
1505
+ );
1506
+ this.raiseTeardownFailures(await this.unwindEach(selected));
1507
+ }
1508
+
1509
+ private raiseTeardownFailures(failures: Array<{ resource: string; error: unknown }>): void {
1510
+ if (failures.length === 0) return;
1511
+ throw new RuntimeError(
1512
+ "ERR_TEARDOWN_FAILED",
1513
+ `${failures.length} resource(s) failed during teardown`,
1514
+ failures.map(({ resource, error }) => ({
1515
+ severity: "error" as const,
1516
+ message: error instanceof Error ? error.message : String(error),
1517
+ resource,
1518
+ })),
1519
+ );
1520
+ }
1521
+
1522
+ /** Unwind the given entries in the order supplied, aggregating what refused.
1523
+ * Failures are returned rather than thrown so one refusing inverse cannot
1524
+ * strand the resources after it — the log sinks above all. */
1525
+ private async unwindEach(
1526
+ entries: Array<readonly [string, { resource: any; instance: any }]>,
1527
+ ): Promise<Array<{ resource: string; error: unknown }>> {
1528
+ const failures: Array<{ resource: string; error: unknown }> = [];
1529
+
1530
+ for (const [key, { resource, instance }] of entries) {
1387
1531
  const label = `${resource.kind}.${resource.metadata.name}`;
1388
1532
  // A reading belongs to the run that produced it. The WeakMap would drop it
1389
1533
  // with the instance anyway; clearing here also covers an instance something
@@ -1436,34 +1580,32 @@ export class EvaluationContext implements IEvaluationContext {
1436
1580
  failures.push({ resource: `${label} (Teardown event)`, error: err });
1437
1581
  }
1438
1582
  this.resourceInstances.delete(key);
1583
+ // The single removal site, so it is also where "this went away because the
1584
+ // runtime withdrew it" is recorded — the fact `invoke` reports a miss by.
1585
+ this.unwoundResources.add(key);
1586
+ // A torn-down resource must stop being readable: a CEL expansion that still
1587
+ // found its reading would bake a value nothing is serving any more.
1588
+ this.clearPublishedReading(resource.metadata.name as string);
1439
1589
  }
1440
1590
 
1441
- this.state = "Teardown";
1442
-
1443
- if (failures.length > 0) {
1444
- throw new RuntimeError(
1445
- "ERR_TEARDOWN_FAILED",
1446
- `${failures.length} resource(s) failed during teardown`,
1447
- failures.map(({ resource, error }) => ({
1448
- severity: "error" as const,
1449
- message: error instanceof Error ? error.message : String(error),
1450
- resource,
1451
- })),
1452
- );
1453
- }
1591
+ return failures;
1454
1592
  }
1455
1593
 
1456
1594
  /**
1457
- * Child contexts in teardown order: ascending `teardownPriority` (default 0),
1458
- * with the base reverse-registration order preserved within each tier.
1595
+ * Child contexts for the backstop sweep: ascending `teardownPriority`
1596
+ * (default 0), reverse registration within a tier.
1597
+ *
1598
+ * What reaches the sweep is a context no resource's inverse claimed, which in
1599
+ * practice is the `lifecycle: shared` libraries. Those are spawned under the
1600
+ * ROOT and give no importer a claim, so nothing but this orders them — and
1601
+ * reverse registration alone does not, since a singleton is registered when
1602
+ * the FIRST import reaches it, which for an import declared inside another
1603
+ * library is after that library's own context. `TEARDOWN_LAST` on the context
1604
+ * is what keeps a singleton alive until the libraries borrowing it have gone.
1459
1605
  *
1460
- * The same rule `teardownOrder` applies to resource instances, and for the
1461
- * same reason: reverse registration is reverse init order in the happy path,
1462
- * but a node that must reliably outlive the rest has to say so rather than
1463
- * depend on when it happened to be created. A `lifecycle: shared` library is
1464
- * registered when the FIRST import reaches it — which, for an import declared
1465
- * inside another library, is after that library's own context — so reverse
1466
- * registration would tear the singleton down while a borrower still holds it.
1606
+ * Its protection now stops at library-against-library. Every resource of the
1607
+ * context that owns the sweep has already unwound by the time it runs, which
1608
+ * is the ordering the sweep used to invert.
1467
1609
  */
1468
1610
  private childTeardownOrder(): IEvaluationContext[] {
1469
1611
  return [...this.children]
@@ -1476,31 +1618,120 @@ export class EvaluationContext implements IEvaluationContext {
1476
1618
  }
1477
1619
 
1478
1620
  /**
1479
- * Resource instances in teardown order: ascending `teardownPriority`, with the
1480
- * base reverse-insertion order preserved within each priority tier.
1621
+ * Resource instances in teardown order: ascending `teardownPriority` as a hard
1622
+ * tier, and within a tier a consumer before every resource it holds
1623
+ * ({@link reverseTopologicalOrder} over the create-time edges), with reverse
1624
+ * insertion as the tiebreak.
1481
1625
  *
1482
- * The base order is reverse *insertion*, which is reverse init order in the
1483
- * happy path but the init loop is a multi-pass retry, so a resource that
1484
- * failed its first pass lands later in the map than its topological rank
1485
- * implies. That makes the dependency graph an unreliable way to say "last".
1486
- * A resource that must reliably outlive the rest declares it directly via
1487
- * `teardownPriority` (log sinks set `TEARDOWN_LAST`), so the generic teardown
1488
- * path orders by a declared number rather than sniffing any one subsystem's
1489
- * instance shape.
1626
+ * `teardownPriority` is a TIER rather than another edge because it is the
1627
+ * author's statement about an edge nothing captured: a log sink is reached
1628
+ * through `ctx.log` rather than through a ref slot, so no walk of the manifest
1629
+ * can find the resources that will log on the way down. Letting topology
1630
+ * reorder across tiers would let one discovered edge override a declaration
1631
+ * made precisely because the edges are not all discoverable.
1490
1632
  */
1491
- private teardownOrder(): Array<[string, { resource: any; instance: any }]> {
1633
+ private teardownOrder(): Array<readonly [string, { resource: any; instance: any }]> {
1492
1634
  // A borrowed instance is torn down by the scope that declared it, never
1493
1635
  // here — see `borrowedResources`.
1494
1636
  const entries = [...this.resourceInstances.entries()]
1495
1637
  .filter(([name]) => !this.borrowedResources.has(name))
1496
1638
  .reverse();
1497
- // Stable sort by priority (default 0); Array.prototype.sort is stable, so
1498
- // the reverse-insertion order survives within each tier.
1499
- return entries.sort(
1500
- ([, a], [, b]) =>
1501
- ((a.instance as { teardownPriority?: number })?.teardownPriority ?? 0) -
1502
- ((b.instance as { teardownPriority?: number })?.teardownPriority ?? 0),
1503
- );
1639
+ const tiers = new Map<number, Array<readonly [string, { resource: any; instance: any }]>>();
1640
+ for (const entry of entries) {
1641
+ const priority = (entry[1].instance as { teardownPriority?: number })?.teardownPriority ?? 0;
1642
+ const tier = tiers.get(priority);
1643
+ if (tier) tier.push(entry);
1644
+ else tiers.set(priority, [entry]);
1645
+ }
1646
+ return [...tiers.keys()]
1647
+ .sort((a, b) => a - b)
1648
+ .flatMap((priority) =>
1649
+ reverseTopologicalOrder(
1650
+ tiers.get(priority)!,
1651
+ (value) => value.resource.metadata.name as string,
1652
+ (name) => this.resourceDependencies.get(name),
1653
+ ),
1654
+ );
1655
+ }
1656
+
1657
+ /**
1658
+ * Every resource of this context that becomes invalid when `names` do — the
1659
+ * named resources plus everything that transitively holds one
1660
+ * ({@link impactClosure} over the same create-time edges teardown reads).
1661
+ *
1662
+ * What a reconciliation unwinds. A cross-module reference projects onto the
1663
+ * local `Telo.Import` (`localDependencyNames`), so a change inside an
1664
+ * imported library reaches this context as its import being impacted, and the
1665
+ * library goes down with that import's own inverse — which is why this
1666
+ * answers for one context rather than walking the tree.
1667
+ *
1668
+ * **`opaque` is what the answer cannot cover.** A name resolved by NAME during
1669
+ * initialization ({@link opaquelyRead}) may be held by a resource no edge
1670
+ * names, so a closure reaching one is not an answer at all. Those names are
1671
+ * reported rather than absorbed: expanding the set to "every resource here"
1672
+ * would sweep in the module document, which is not a resource a caller can
1673
+ * unwind and re-register, and would present a whole-context rebuild as a
1674
+ * narrowing. The caller escalates, and can say which resource forced it.
1675
+ */
1676
+ // eslint-disable-next-line @typescript-eslint/member-ordering
1677
+ impactedBy(names: Iterable<string>): { impacted: Set<string>; opaque: string[] } {
1678
+ const impacted = impactClosure(names, this.resourceDependencies);
1679
+ const opaque = [...impacted].filter((name) => this.opaquelyRead.has(name));
1680
+ return { impacted, opaque };
1681
+ }
1682
+
1683
+ /** Whether this resource's `run()` has been dispatched. A rebuilt resource
1684
+ * that had been started is one nothing will start again — boot targets run
1685
+ * once — so a caller reconciling has to escalate rather than leave it
1686
+ * constructed and idle. */
1687
+ wasStarted(name: string): boolean {
1688
+ const instance = this.resourceInstances.get(name)?.instance;
1689
+ return instance !== undefined && startedInstances.has(instance);
1690
+ }
1691
+
1692
+ /** Drop a resource's published reading. A context with no `resources` scope of
1693
+ * its own has nothing to drop; `ModuleContext` overrides. */
1694
+ clearPublishedReading(name: string): void {
1695
+ void name;
1696
+ }
1697
+
1698
+ /** The declaration registered under `name` in THIS context, without the
1699
+ * walk up the enclosing chain a name lookup does. */
1700
+ declaredManifestFor(name: string): ResourceManifest | undefined {
1701
+ return this.declaredManifests.get(name);
1702
+ }
1703
+
1704
+ /** Replace a declaration in place, without queueing the resource for
1705
+ * creation. For a survivor of a reconciliation: its declaration is
1706
+ * content-identical by construction, but the object carries fresh loader
1707
+ * stamps (`metadata.sourceLine` above all), and a diagnostic anchored on the
1708
+ * stale one points at a pre-edit line. */
1709
+ refreshManifest(name: string, resource: ResourceManifest): void {
1710
+ if (this.declaredManifests.has(name)) this.declaredManifests.set(name, resource);
1711
+ }
1712
+
1713
+ /** Re-open an initialized context for another initialization pass.
1714
+ *
1715
+ * The transition is the context's own, not a field a caller assigns: while it
1716
+ * is open, a reference to a resource that has not been rebuilt yet must
1717
+ * produce the deferral the init loop retries on rather than a hard
1718
+ * not-found, and leaving it open after a failed pass turns every later
1719
+ * lookup into that deferral with no pass coming. */
1720
+ reopenForInitialization(): void {
1721
+ if (this.state === "Initialized") this.state = "Validated";
1722
+ }
1723
+
1724
+ /** Close a pass opened by {@link reopenForInitialization} that did not reach
1725
+ * the end of `initializeResources`. */
1726
+ closeInitialization(): void {
1727
+ if (this.state === "Validated") this.state = "Initialized";
1728
+ }
1729
+
1730
+ /** Names a resource resolved by NAME while this context was initializing, so
1731
+ * no edge records who is holding them. Read by {@link impactedBy}; exposed so
1732
+ * a host can report why a reconciliation could not be narrowed. */
1733
+ opaqueReads(): ReadonlySet<string> {
1734
+ return this.opaquelyRead;
1504
1735
  }
1505
1736
 
1506
1737
  transientChild(context: Record<string, any>): EvaluationContext {
@@ -1528,6 +1759,52 @@ export class EvaluationContext implements IEvaluationContext {
1528
1759
  const entry = this.resourceInstances.get(name);
1529
1760
 
1530
1761
  if (!entry) {
1762
+ // A MISS ON A RESOURCE THE RUNTIME WITHDREW is the runtime going away, not
1763
+ // a manifest defect, and the two want opposite follow-ups. An unwind
1764
+ // removes each instance as it goes, so work still in flight — a detached
1765
+ // task the kernel waited for and then abandoned, above all — finds an
1766
+ // emptying map and would otherwise be told its target does not exist. That
1767
+ // verdict is durable where the withdrawal is not: a durable run records it
1768
+ // as `failed`, which is terminal, so one ordinary Ctrl-C leaves a run id
1769
+ // nothing will ever pick up again — from the one feature whose whole
1770
+ // purpose is surviving that.
1771
+ //
1772
+ // Reported as a cancellation because that is what it is, and because every
1773
+ // consumer already handles one correctly: a durable body leaves the run
1774
+ // `running` for the resumer, and a step's retry budget is not spent
1775
+ // re-issuing a call the runtime has no intention of answering.
1776
+ //
1777
+ // Keyed on `unwoundResources` — the recorded FACT — rather than on this
1778
+ // context's state, which is only a proxy for it and misses the
1779
+ // reconciliation path entirely: `unwindResources` withdraws an instance and
1780
+ // deliberately leaves the state alone, and that is the path a watch session
1781
+ // takes on every save. Only the MISS is converted; a resource still in the
1782
+ // map is dispatched as before, because a teardown-time flush is legitimate
1783
+ // work and refusing it would break shutdown to protect it.
1784
+ if (this.unwoundResources.has(name)) {
1785
+ const reason =
1786
+ this.state === "Draining" || this.state === "Teardown"
1787
+ ? "the runtime is shutting down"
1788
+ : "the resource was unwound while the runtime reconciled";
1789
+ // The SPAN payload, not an ad-hoc object: every consumer of this event
1790
+ // reads `ref.kind` (the debug UI's kind facet) and `outcome` (its graph
1791
+ // nodes and outcome tally) off the trace shape, so a payload carrying
1792
+ // neither is an event that fires and is invisible — which is worse than
1793
+ // no event, and precisely the cancellation an operator is looking for
1794
+ // during a watch-session save. No span ids: this refusal happens before
1795
+ // any span is opened, and `tracePayload` omits an undefined one.
1796
+ await this.emit(
1797
+ `${name}.InvokeCancelled`,
1798
+ this.tracePayload(kind, name, undefined, undefined, undefined, "invoke", "end", "cancelled", {
1799
+ reason,
1800
+ }),
1801
+ );
1802
+ throw new RuntimeError(
1803
+ "ERR_INVOKE_CANCELLED",
1804
+ `Invoke ${kind}.${name} was cancelled: ${reason} and the resource has already ` +
1805
+ `been torn down.`,
1806
+ );
1807
+ }
1531
1808
  throw new RuntimeError(
1532
1809
  "ERR_RESOURCE_NOT_FOUND",
1533
1810
  `Resource not found for invocation: ${kind}.${name}. Available resources: ${[...this.resourceInstances.keys()].join(", ")}`,