@descryy/runtime-orchestrator 0.2.1 → 0.4.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.
Files changed (46) hide show
  1. package/LICENSE +6 -0
  2. package/dist/attach-to-running-jvm-process.d.ts +45 -98
  3. package/dist/attach-to-running-jvm-process.d.ts.map +1 -1
  4. package/dist/attach-to-running-jvm-process.js +206 -136
  5. package/dist/attach-to-running-jvm-process.js.map +1 -1
  6. package/dist/attach-to-running-node-process.d.ts +45 -103
  7. package/dist/attach-to-running-node-process.d.ts.map +1 -1
  8. package/dist/attach-to-running-node-process.js +90 -115
  9. package/dist/attach-to-running-node-process.js.map +1 -1
  10. package/dist/attach-to-running-process.d.ts +32 -70
  11. package/dist/attach-to-running-process.d.ts.map +1 -1
  12. package/dist/attach-to-running-process.js +26 -54
  13. package/dist/attach-to-running-process.js.map +1 -1
  14. package/dist/cdp-client.d.ts +8 -18
  15. package/dist/cdp-client.d.ts.map +1 -1
  16. package/dist/cdp-client.js +9 -22
  17. package/dist/cdp-client.js.map +1 -1
  18. package/dist/cgroup-partial-restriction.d.ts +42 -89
  19. package/dist/cgroup-partial-restriction.d.ts.map +1 -1
  20. package/dist/cgroup-partial-restriction.js +44 -91
  21. package/dist/cgroup-partial-restriction.js.map +1 -1
  22. package/dist/collector-version.d.ts +3 -8
  23. package/dist/collector-version.d.ts.map +1 -1
  24. package/dist/collector-version.js +3 -8
  25. package/dist/collector-version.js.map +1 -1
  26. package/dist/instrumented-execution.d.ts +30 -73
  27. package/dist/instrumented-execution.d.ts.map +1 -1
  28. package/dist/instrumented-execution.js +121 -251
  29. package/dist/instrumented-execution.js.map +1 -1
  30. package/dist/jvm-agent/build.d.ts +29 -53
  31. package/dist/jvm-agent/build.d.ts.map +1 -1
  32. package/dist/jvm-agent/build.js +34 -61
  33. package/dist/jvm-agent/build.js.map +1 -1
  34. package/dist/polling-output-source.d.ts +8 -19
  35. package/dist/polling-output-source.d.ts.map +1 -1
  36. package/dist/polling-output-source.js +14 -31
  37. package/dist/polling-output-source.js.map +1 -1
  38. package/dist/profile-backend-observation.d.ts +26 -53
  39. package/dist/profile-backend-observation.d.ts.map +1 -1
  40. package/dist/profile-backend-observation.js +26 -55
  41. package/dist/profile-backend-observation.js.map +1 -1
  42. package/dist/respawn-and-supervise.d.ts +39 -72
  43. package/dist/respawn-and-supervise.d.ts.map +1 -1
  44. package/dist/respawn-and-supervise.js +43 -84
  45. package/dist/respawn-and-supervise.js.map +1 -1
  46. package/package.json +14 -9
@@ -1,30 +1,14 @@
1
1
  /**
2
- * **The assembly nothing else performed.**
2
+ * The assembly wiring the runtime's pieces together in production code (previously
3
+ * only connected ad hoc by tests). Runs an `ExecutionController` over a `services`
4
+ * configuration; wraps each spawned process in a live `ProcessOutputSource`
5
+ * (polls the buffer, yields each line once); builds a `CollectorContext` with a
6
+ * real `resolveSourceRoot` closed over the execution's own processes; starts,
7
+ * drains and stops the adapter's collectors; writes every emitted item through
8
+ * `EvidenceStore` so redaction runs (plan §28).
3
9
  *
4
- * Every piece of the runtime existed and none of them were wired together
5
- * in production code: the controller spawned services, `RuntimeAdapter`
6
- * could build collectors, `resolveServiceRootForOrigin` could map an origin
7
- * to a source root, and `EvidenceStore` could persist. Only tests ever
8
- * connected them, each in its own way, which meant the thing the product
9
- * actually does had no single implementation to be right or wrong.
10
- *
11
- * This module is that implementation. It:
12
- *
13
- * 1. runs an `ExecutionController` over a `services` configuration;
14
- * 2. wraps each spawned process in a **live** `ProcessOutputSource` that
15
- * polls the accumulated buffer and yields each line exactly once;
16
- * 3. builds a `CollectorContext` carrying a real `resolveSourceRoot`,
17
- * closed over the execution's own processes — the channel RT-033 added
18
- * and RT-029 built the lookup for, which until now nothing populated;
19
- * 4. starts the adapter's collectors, drains them, stops them;
20
- * 5. writes every emitted item through `EvidenceStore`, so redaction runs
21
- * rather than being assumed (plan §28).
22
- *
23
- * **What it deliberately does not do.** It does not decide whether a
24
- * finding exists, does not correlate to graph nodes, and does not drive a
25
- * browser. Those are separate stages with separate contracts, and folding
26
- * them in here would make the one place that assembles the runtime also the
27
- * place that judges it.
10
+ * Deliberately does not decide whether a finding exists, correlate to graph
11
+ * nodes, or drive a browser separate stages, separate contracts.
28
12
  */
29
13
  import { delimiter } from "node:path";
30
14
  import { resolveServiceRootForOrigin } from "@descryy/runtime-contracts";
@@ -36,13 +20,9 @@ import { createPollingProcessOutputSource } from "./polling-output-source.js";
36
20
  import { COLLECTOR_VERSION } from "./collector-version.js";
37
21
  /**
38
22
  * Builds the origin → source-root lookup a collector reads through
39
- * `CollectorContext.resolveSourceRoot`.
40
- *
41
- * This is the piece that had been missing at both ends: RT-029 wrote the
42
- * lookup and nothing called it; RT-033 added the channel and nothing filled
43
- * it. It closes over the execution's *own* processes, which is what makes
44
- * the port→service join possible at all — under ephemeral ports the
45
- * configuration cannot know the port, so only the running process can say.
23
+ * `CollectorContext.resolveSourceRoot`. Closes over the execution's own
24
+ * processes, which is what makes the port→service join possible at all — under
25
+ * ephemeral ports only the running process knows its own port.
46
26
  */
47
27
  export function createSourceRootResolver(processes, configuration) {
48
28
  return (origin) => resolveServiceRootForOrigin(origin, { processes, configuration });
@@ -66,9 +46,8 @@ export function applyOutboundLaunch(execution, launch) {
66
46
  const prepend = new Set(launch.prependToExisting ?? []);
67
47
  const env = { ...service.env };
68
48
  for (const [key, value] of Object.entries(launch.env ?? {})) {
69
- // A variable the target already relies on is extended, not replaced --
70
- // `PYTHONPATH` is the live case, and replacing it stops the application
71
- // finding its own modules, which is breaking it in order to watch it.
49
+ // extend, don't replace, a variable the target relies on (PYTHONPATH is the
50
+ // live case) -- replacing it breaks the app's own module resolution
72
51
  const existing = prepend.has(key) ? (service.env?.[key] ?? process.env[key] ?? "") : "";
73
52
  env[key] = existing === "" ? value : `${value}${delimiter}${existing}`;
74
53
  }
@@ -83,35 +62,19 @@ export function applyOutboundLaunch(execution, launch) {
83
62
  return { ...execution, configuration: { ...execution.configuration, services } };
84
63
  }
85
64
  export async function runInstrumentedExecution(input) {
86
- // **The outbound half, which nothing assembled before this.** The mechanism
87
- // -- an outbound call captured at its own call site, joined to the route it
88
- // reached in another process -- existed and was proven, but only a test
89
- // ever wired it: `runInstrumentedExecution` started
90
- // `createBackendCollectors` and nothing else, so no production path
91
- // observed an outbound call at all.
65
+ // outbound observation needs two things: a collector to read the marker lines,
66
+ // and a launch that installs client instrumentation before app code can capture
67
+ // an unpatched client. The launch is adapter-declared and language-specific; an
68
+ // adapter that declares nothing gets neither.
92
69
  //
93
- // It takes two things, not one. A collector to read the marker lines, and a
94
- // *launch* that puts the client instrumentation inside the target process
95
- // before any application code can capture an unpatched client. The second
96
- // is language-specific, so it is declared on the adapter and merely applied
97
- // here. An adapter that declares nothing gets neither -- the run proceeds,
98
- // no outbound collector is started, and nothing claims one was.
99
- // The budget clock starts here, not at `controller.run()`. The controller
100
- // arms its own timer for the same number a few statements later, so this
101
- // deadline is the marginally earlier — and therefore the conservative — of
102
- // the two; a caller who declared a bound never waits past it because this
103
- // function took some of it before the controller was even created.
70
+ // budget clock starts here, not at controller.run() -- marginally earlier than
71
+ // the controller's own timer, so a caller never waits past its declared bound
104
72
  const budgetStartedAt = Date.now();
105
73
  const outboundLaunch = input.adapter.outboundHttpLaunch?.() ?? null;
106
74
  const launchedExecution = outboundLaunch === null ? input.execution : applyOutboundLaunch(input.execution, outboundLaunch);
107
75
  const configuration = launchedExecution.configuration;
108
- /**
109
- * Every exit from this function reports the same struct, built from the one
110
- * clock. Written as a closure rather than repeated at each `return` because
111
- * there are four of them and the failure mode of the alternative — one
112
- * return that quietly reports a different number — is exactly the class of
113
- * bug this whole field exists to make visible.
114
- */
76
+ // closure, not repeated per-return: there are four returns, and one quietly
77
+ // reporting a different number is the bug this field exists to catch
115
78
  const budgetOutcome = (observedForMs) => {
116
79
  const elapsedMs = Date.now() - budgetStartedAt;
117
80
  return {
@@ -123,20 +86,30 @@ export async function runInstrumentedExecution(input) {
123
86
  };
124
87
  };
125
88
  const controller = ExecutionController.create(launchedExecution);
126
- // Closes §34's remaining "runtime self-observability" gap after RT-219:
127
- // `controller.run()` has exactly one throw path (`controller.ts`'s own
128
- // `spawnProcess` catch/rethrow, after it has already recorded the
129
- // failure in `serviceStartResults` and transitioned to `FAILED_START`) --
130
- // left unguarded here, that throw used to reject this whole function,
131
- // uncaught, the identical "harness machinery breaks, zero evidence
132
- // trail" shape RT-219 already fixed one layer up for `collector.start()`.
133
- // Caught the same way: written through the same `EvidenceStore`, tagged
134
- // `source: "harness"`. `controller.execution` and
135
- // `controller.serviceStartResults` are both real at this point (`run()`
136
- // mutates its private state, including a call to its own `#finish()`,
137
- // before it ever throws) so the run resolves with what actually happened
138
- // rather than rejecting the caller's own promise -- the same "resolves
139
- // normally" contract RT-219 already established.
89
+ // B7: the execution row, minted here and nowhere else in production.
90
+ // `EvidenceStore.recordExecution` was complete and tested since the
91
+ // `executions` table was added -- schema, redaction, environment capture,
92
+ // upsert -- but this repo's and descry-core's own sources together held
93
+ // exactly two callers of it outside its own tests, both in
94
+ // evidence-store's test suite. Every evidence row this run is about to
95
+ // write carries `controller.execution.executionId`, so the parent row
96
+ // must exist before the first of them does; `controller.execution` is
97
+ // real the moment `create()` returns (it mints the id synchronously),
98
+ // which is before `controller.run()` -- and therefore before this
99
+ // function's own first `store.write()`, in the catch block just below.
100
+ input.store.recordExecution({
101
+ executionId: controller.execution.executionId,
102
+ application: controller.execution.application,
103
+ repository: controller.execution.repository,
104
+ commit: controller.execution.commit,
105
+ configuration: controller.execution.configuration,
106
+ });
107
+ // RT-219: controller.run()'s one throw path (spawnProcess catch/rethrow, after
108
+ // recording the failure in serviceStartResults) used to reject this whole
109
+ // function uncaught -- zero evidence trail. Caught here and written through
110
+ // EvidenceStore tagged "harness", same "resolves normally" contract RT-219
111
+ // established for collector.start(). controller.execution/serviceStartResults
112
+ // are both already real at this point (run() mutates state before it throws).
140
113
  let execution;
141
114
  try {
142
115
  execution = await controller.run(input.runOptions);
@@ -180,34 +153,23 @@ export async function runInstrumentedExecution(input) {
180
153
  budget: budgetOutcome(0),
181
154
  };
182
155
  }
183
- // One source per spawned service. `serviceName` is what joins a handle
184
- // back to its configuration entry, and a handle without one cannot be
185
- // attributed -- skipped rather than guessed at.
186
- // Declared before the sources close over it: each generator polls until
187
- // this flips, and the flip is what lets `stop()` finish rather than wait
188
- // forever on a source still watching a live buffer.
156
+ // a handle with no serviceName can't be attributed to a config entry -- skipped
157
+ // declared before the sources close over it: the flip lets stop() finish rather
158
+ // than wait forever on a source still watching a live buffer
189
159
  let finished = false;
190
160
  const sources = [];
191
- // A **second, independent** source per process, kept with the service name
192
- // that produced it.
193
- //
194
- // Not the same source object as the backend collector's, and this is the
195
- // whole subtlety: `createPollingProcessOutputSource` yields each line
196
- // exactly once, so two collectors sharing one source do not both see the
197
- // output -- they race for it, and the first consumer wins. Sharing it made
198
- // the backend log collector eat every `DESCRY_EXTERNAL_REQUEST` line and
199
- // the outbound collector report nothing, which reads exactly like a service
200
- // that made no outbound calls.
161
+ // second, independent source per process -- NOT shared with the backend
162
+ // collector's: createPollingProcessOutputSource yields each line exactly once,
163
+ // so sharing means two collectors race for it. Measured: sharing made the
164
+ // backend log collector eat every DESCRY_EXTERNAL_REQUEST line and the outbound
165
+ // collector report nothing, reading as "no outbound calls".
201
166
  const namedSources = [];
202
167
  for (const handle of execution.processes) {
203
168
  if (handle.serviceName === null)
204
169
  continue;
205
170
  const serviceName = handle.serviceName;
206
- // `serviceName` is stamped here, at construction, and this is the only
207
- // place it can be. The handle knows which declared service this process is;
208
- // by the time a collector's evidence reaches `correlateExecution` that
209
- // association is gone — a row in a store and no handle in scope — which is
210
- // what made the gap permanent rather than recoverable.
171
+ // stamped here, at construction -- by the time evidence reaches
172
+ // correlateExecution the handle is out of scope and this association is gone
211
173
  sources.push(createPollingProcessOutputSource({
212
174
  processId: handle.processId,
213
175
  serviceName,
@@ -225,14 +187,10 @@ export async function runInstrumentedExecution(input) {
225
187
  });
226
188
  }
227
189
  const backendCollectors = input.adapter.createBackendCollectors(sources);
228
- // Started only when the launch above actually installed instrumentation.
229
- // A collector reading a process that was never instrumented would report
230
- // "no outbound calls" for a service making them, which is the one answer
231
- // worse than declining to look.
232
- //
233
- // `createExternalRequestCollector` itself names no language; the only
234
- // language-specific value it needs is the adapter's own stack parser, which
235
- // is what turns a captured call site into a file and a line.
190
+ // only started when the launch above actually installed instrumentation --
191
+ // otherwise it would report "no outbound calls" for a service making them,
192
+ // worse than declining to look. createExternalRequestCollector names no
193
+ // language itself; the adapter's stack parser is the only language-specific bit.
236
194
  const outboundCollectors = outboundLaunch === null
237
195
  ? []
238
196
  : namedSources.map(({ source, serviceName }) => createExternalRequestCollector({
@@ -241,26 +199,14 @@ export async function runInstrumentedExecution(input) {
241
199
  stackTraceParser: input.adapter.stackTraceParser,
242
200
  }));
243
201
  const emitted = [];
244
- // `DEC-NEXT-fault-layer-empirical-confirmation.md`'s `dependency`/
245
- // `environment` mechanisms -- the producer side of the same shape RT-219
246
- // established for `automation` above: a structural fact the runtime
247
- // establishes about its own execution environment, tagged
248
- // `source: "dependency-check"` / `"environment-check"` rather than the
249
- // service's own domain source, so a reader can tell "the project's
250
- // declared versions don't match what actually ran" from an observation of
251
- // the application itself without inspecting payload text. Run once per
252
- // spawned service (each has its own `cwd`, and therefore potentially its
253
- // own lockfile/`engines.node`), unconditionally -- "cheap, always
254
- // available, no rerun needed" per the ruling -- but only ever emitted as
255
- // evidence when an actual mismatch is found, the same "silence must not
256
- // manufacture evidence" discipline `"harness"` already observes.
202
+ // dependency/environment version-mismatch checks (DEC-NEXT-fault-layer-empirical-
203
+ // confirmation.md): tagged "dependency-check"/"environment-check" rather than the
204
+ // service's own source, so a reader can tell version drift from an app
205
+ // observation. Run once per service (own cwd/lockfile/engines.node),
206
+ // unconditionally, but only emitted as evidence on an actual mismatch.
257
207
  //
258
- // The observed Node version is `execution.environmentMetadata`'s own
259
- // `nodeVersion` -- `process.version` of the controller process, captured
260
- // once by `captureEnvironmentMetadata` during `controller.run()` above,
261
- // never re-probed here. It is non-null whenever `validationError` is
262
- // null (the early return above already excluded the one case where it
263
- // would not be).
208
+ // observedNodeVersion is process.version captured once during controller.run();
209
+ // never re-probed here.
264
210
  const observedNodeVersion = execution.environmentMetadata?.nodeVersion ?? process.version;
265
211
  for (const handle of execution.processes) {
266
212
  if (handle.serviceName === null)
@@ -319,12 +265,9 @@ export async function runInstrumentedExecution(input) {
319
265
  }));
320
266
  }
321
267
  }
322
- // Opt-in InboundProxy wiring (RT-192, DEC-271) -- see `frontWithInboundProxy`'s
323
- // own doc for what this does and, just as important, what it does not
324
- // close. Built AFTER `controller.run()` has already returned, so every
325
- // service's own readiness answer already exists in
326
- // `controller.serviceStartResults` -- this loop reads that answer, it
327
- // never re-derives or re-checks it itself.
268
+ // opt-in InboundProxy wiring (RT-192, DEC-271), see frontWithInboundProxy's doc.
269
+ // Built after controller.run() returns, so readiness already exists in
270
+ // serviceStartResults -- this loop reads that answer, never re-derives it.
328
271
  const proxies = [];
329
272
  if (input.frontWithInboundProxy !== undefined) {
330
273
  for (const handle of execution.processes) {
@@ -335,11 +278,8 @@ export async function runInstrumentedExecution(input) {
335
278
  continue;
336
279
  const readinessOutcome = controller.serviceStartResults.find((result) => result.serviceName === serviceName && result.stage === "readiness");
337
280
  if (readinessOutcome?.succeeded !== true || handle.port === null) {
338
- // Requested but never proven live -- reported as a real
339
- // COLLECTOR_ERROR, the same shape every other unavailable collector
340
- // in this function gets, rather than a silent no-proxy fallback
341
- // ("we did not observe this" vs "there was nothing to observe",
342
- // plan §17, applies here too).
281
+ // requested but never proven live -- reported as COLLECTOR_ERROR, not a
282
+ // silent no-proxy fallback (plan §17: "not observed" vs "nothing there")
343
283
  emitted.push(input.store.write({
344
284
  executionId: execution.executionId,
345
285
  timestamp: new Date().toISOString(),
@@ -377,28 +317,19 @@ export async function runInstrumentedExecution(input) {
377
317
  const context = {
378
318
  executionId: execution.executionId,
379
319
  configuration,
380
- // Redaction runs here, on the way in -- EvidenceStore.write() is the
381
- // only path that sets redactionStatus, so nothing reaches the returned
382
- // array without having been through it (plan §28).
320
+ // redaction runs here, on the way in -- EvidenceStore.write() is the only
321
+ // path that sets redactionStatus (plan §28)
383
322
  emit: (evidenceInput) => {
384
323
  emitted.push(input.store.write({ ...evidenceInput, executionId: execution.executionId }));
385
324
  },
386
325
  resolveSourceRoot: createSourceRootResolver(execution.processes, configuration),
387
326
  };
388
- // RT-219 (runtime self-observability). `collector.start()`'s documented
389
- // failure path resolves `{available: false, reason}` -- the loop below
390
- // already reports that one, tagged with the domain source the collector
391
- // was going to observe. A collector that instead THROWS out of start()
392
- // is a different claim: not "I looked and could not," but the harness's
393
- // own machinery breaking before it ever got that far. Left unguarded,
394
- // that throw rejects this Promise.all and `runInstrumentedExecution`
395
- // aborts uncaught -- zero evidence trail for why, the exact "collector
396
- // failure mistaken for absence of failure" shape plan §17 forbids,
397
- // applied to the harness's own code instead of the application's.
398
- // Caught here and written through the same EvidenceStore, tagged
399
- // `source: "harness"` rather than the collector's domain source, so a
400
- // reader can tell "our own observer broke" from "the observed system
401
- // did" without inspecting payload text.
327
+ // RT-219: collector.start()'s documented failure path resolves {available:
328
+ // false, reason}, handled below. A collector that instead THROWS out of
329
+ // start() is a different claim -- the harness's own machinery breaking, not
330
+ // "I looked and could not" -- so it's caught here and tagged "harness" rather
331
+ // than the collector's domain source, instead of aborting this Promise.all
332
+ // uncaught with zero evidence trail.
402
333
  const startOutcomes = await Promise.all(collectors.map(async (collector) => {
403
334
  try {
404
335
  return { collector, result: await collector.start(context), threw: false };
@@ -429,14 +360,10 @@ export async function runInstrumentedExecution(input) {
429
360
  return { collector, result: { available: false, reason: `start() threw: ${detail}` }, threw: true };
430
361
  }
431
362
  }));
432
- // A thrown start() was already reported above, under `source: "harness"`
433
- // -- excluded here so it is not reported a second time under
434
- // `source: "backend-process"` by the loop below, which would collide
435
- // the two tags on what is really one failure.
363
+ // a thrown start() was already reported above under "harness" -- excluded here
364
+ // to avoid double-reporting the same failure under "backend-process"
436
365
  const unavailable = startOutcomes.filter((entry) => !entry.threw && !entry.result.available);
437
- // A collector that could not start is reported, never silently absent:
438
- // "we did not observe this" and "there was nothing to observe" are
439
- // different claims (plan §17).
366
+ // reported, never silently absent (plan §17: "not observed" vs "nothing there")
440
367
  for (const entry of unavailable) {
441
368
  emitted.push(input.store.write({
442
369
  executionId: execution.executionId,
@@ -459,78 +386,44 @@ export async function runInstrumentedExecution(input) {
459
386
  collectorVersion: COLLECTOR_VERSION,
460
387
  }));
461
388
  }
462
- // Every fronted proxy that actually started (a collector reported
463
- // `available: false` above never reaches here -- its port stayed null on
464
- // that codepath, matching `InboundProxy.port()`'s own contract).
389
+ // proxies with available:false above never reach here -- port stayed null
465
390
  const proxyPorts = {};
466
391
  for (const entry of proxies) {
467
392
  const port = entry.proxy.port();
468
393
  if (port !== null)
469
394
  proxyPorts[entry.serviceName] = port;
470
395
  }
471
- // **Everything from here to the teardown is wrapped, because a throw above
472
- // the teardown used to leak the whole execution.**
473
- //
474
- // `onReady` is caller code and is documented not to be caught — correctly,
475
- // since turning a caller's failed setup into a clean run would be the
476
- // silent-degradation failure this repo rules against. But "not caught" and
477
- // "not cleaned up" are two different promises and only the first was ever
478
- // made: when `onReady` threw, `finished = true`, the collector `stop()` loop
479
- // and `controller.stop()` were all skipped, so the polling output sources
480
- // kept polling a live buffer, the spawned child was never killed, and the
481
- // controller's timer held the event loop open. Measured: the call rejects in
482
- // 270ms with the caller's own error, and the process then never exits.
483
- //
484
- // The exception still propagates, uncaught by any handler that changes it,
485
- // to the same caller with the same stack — the documented contract is
486
- // untouched. Only the leak goes away.
396
+ // Wrapped because a throw above teardown used to leak the whole execution.
397
+ // onReady is caller code, documented not to be caught -- but "not caught" and
398
+ // "not cleaned up" are different promises: when onReady threw, finished stayed
399
+ // false, so collector.stop()/controller.stop() were skipped, sources kept
400
+ // polling, the spawned child was never killed, and the timer held the event
401
+ // loop open. Measured: rejects in 270ms, process then never exits. Exception
402
+ // still propagates unchanged to the caller; only the leak goes away.
487
403
  //
488
- // Found the expensive way: a worktree missing the fixture app's
489
- // `node_modules` made a test's `onReady` throw after a correctly bounded
490
- // ten-second readiness wait, and the suite then hung for twenty minutes.
491
- // `scripts/preflight.mjs` predicts that dependency gap and calls its worst
492
- // case "a readiness timeout that looks like a slow boot" — milder than what
493
- // actually happened, which is what makes this a defect rather than an
494
- // inconvenience: a guard whose stated worst case is better than reality
495
- // teaches people to trust it less than they should.
404
+ // Found the hard way: a worktree missing node_modules made a test's onReady
405
+ // throw after a correctly bounded readiness wait, then the suite hung for
406
+ // twenty minutes.
496
407
  let observationMs = 0;
497
408
  let bodyFailure = null;
498
409
  let teardownFailure = null;
499
410
  try {
500
411
  await input.onReady?.({ execution, proxyPorts });
501
- // **The observation window is bounded by the declared budget, not just by
502
- // `observeForMs`.** This line used to be an unconditional sleep for the
503
- // full requested window, which made `timeoutMs` an upper bound on how long
504
- // a service was allowed to live and no bound at all on how long the caller
505
- // was made to wait. The two numbers then diverged silently, and in the
506
- // worst direction: `ExecutionController`'s own timer had already killed
507
- // every process at the deadline, so the remainder of the window watched
508
- // dead processes and produced nothing. Measured — a 1500ms budget with a
509
- // 12000ms window returned after 12288ms with zero evidence.
510
- //
511
- // Clamped rather than aborted. There is real work left after this point
512
- // (draining collectors, reaping children) that owns OS resources and must
513
- // still run; cancelling it to hit the number exactly would leak the
514
- // processes the budget exists to bound. So the deadline decides how long we
515
- // *watch*, and the overshoot from teardown is reported rather than hidden —
516
- // `budget.exceeded` is what says the run finished past its own bound.
412
+ // observation window is bounded by the declared budget, not just observeForMs
413
+ // -- unconditionally sleeping the full window used to leave timeoutMs bounding
414
+ // only the service, not the wait; measured, a 1500ms budget with a 12000ms
415
+ // window returned after 12288ms with zero evidence (services dead the whole
416
+ // remainder). Clamped rather than aborted: teardown still owns OS resources
417
+ // and must run; the overshoot is reported via budget.exceeded, not hidden.
517
418
  const remainingMs = budgetStartedAt + configuration.timeoutMs - Date.now();
518
419
  observationMs = Math.max(0, Math.min(input.observeForMs, remainingMs));
519
420
  if (observationMs > 0) {
520
421
  await new Promise((resolve) => setTimeout(resolve, observationMs));
521
422
  }
522
- // A window that was cut short is a degradation, and rule 7 says every
523
- // fallback is labelled. `execution.state` already carries `TIMED_OUT` when
524
- // the controller's own timer fired, but that says the *services* were
525
- // stopped early — it does not say the observation was, and a run whose
526
- // budget expired before the controller ever armed (or after it had already
527
- // completed) leaves no trace in the state at all.
528
- //
529
- // Tagged `source: "harness"` with `COLLECTOR_ERROR`: this is the runtime
530
- // observing itself rather than any watched domain, and the event type's own
531
- // documented purpose is to keep "found nothing" apart from "stopped
532
- // looking". A truncated window is precisely the second one, so it needs no
533
- // new event type of its own.
423
+ // rule 7: cut-short window is a labelled degradation. execution.state's
424
+ // TIMED_OUT says the services stopped early, not that observation did --
425
+ // tagged "harness"/COLLECTOR_ERROR to keep "found nothing" apart from
426
+ // "stopped looking".
534
427
  if (observationMs < input.observeForMs) {
535
428
  emitted.push(input.store.write({
536
429
  executionId: execution.executionId,
@@ -558,35 +451,22 @@ export async function runInstrumentedExecution(input) {
558
451
  }
559
452
  }
560
453
  catch (error) {
561
- // Held, not handled. Rethrown below, unchanged, once the teardown in the
562
- // `finally` has run.
454
+ // held, not handled -- rethrown below, unchanged, once teardown has run
563
455
  bodyFailure = { error };
564
456
  }
565
457
  finally {
566
- // Order matters: the sources must be told the process is done before
567
- // stop() awaits their drain, or stop() waits forever on a generator
568
- // still polling a live buffer.
458
+ // order matters: sources must be told the process is done before stop()
459
+ // awaits their drain, or it waits forever on a generator polling a live buffer
569
460
  finished = true;
570
461
  try {
571
462
  await Promise.all(collectors.map((collector) => collector.stop()));
572
- // **`timeout()` when this function cut the window short, `stop()` when it
573
- // did not and the distinction is not left to whichever timer fires
574
- // first.**
575
- //
576
- // The clamp above ends the observation window at the deadline;
577
- // `ExecutionController`'s own timer fires a moment later. Whichever of the
578
- // two reached the controller first used to decide what the run *said* had
579
- // happened, and teardown regularly won: measured `TIMED_OUT` at 1534ms and
580
- // `COMPLETED` at 1515ms for the same case, with the second surfacing only
581
- // under a loaded full-suite run.
582
- //
583
- // That is the one field `descry-core`'s `observe_runtime` reads to choose
584
- // between `ok` and `timed_out`, so losing the race turned a disclosed
585
- // degradation into a silent one — a run that returns on time and reports
586
- // nothing was cut short. This function already knows: `observationMs <
587
- // input.observeForMs` is exactly the condition it wrote the truncation
588
- // evidence under. It says so directly rather than hoping to lose a race
589
- // it set up itself.
463
+ // timeout() vs stop() is decided here, not left to whichever of this
464
+ // function's clamp or the controller's own timer fires first -- measured
465
+ // TIMED_OUT at 1534ms and COMPLETED at 1515ms for the same case under load.
466
+ // descry-core's observe_runtime reads this to choose ok vs timed_out, so
467
+ // losing that race silently hid a real truncation; this function already
468
+ // knows the answer (observationMs < input.observeForMs) and states it
469
+ // directly instead.
590
470
  if (observationMs < input.observeForMs) {
591
471
  await controller.timeout();
592
472
  }
@@ -595,24 +475,16 @@ export async function runInstrumentedExecution(input) {
595
475
  }
596
476
  }
597
477
  catch (teardownError) {
598
- // On the ordinary path a teardown failure is the only failure there is,
599
- // so it propagates exactly as it always did — held here and rethrown
600
- // below rather than thrown from inside the `finally`, which is the one
601
- // place a throw really can discard an in-flight exception.
478
+ // held and rethrown below rather than thrown inside finally, which can
479
+ // discard an in-flight exception
602
480
  if (bodyFailure === null) {
603
481
  teardownFailure = { error: teardownError };
604
482
  }
605
483
  else {
606
- // When something already failed above, the caller's own error is the
607
- // actionable one and a teardown failure must not replace it — a plain
608
- // `finally` would silently substitute the second for the first, which
609
- // is the same class of mistake as the leak this block exists to fix.
610
- // So it is written to the evidence store instead of thrown, tagged
611
- // `harness` (RT-219) because it is the runtime's own machinery
612
- // breaking rather than anything about the observed application. It is
613
- // not in the returned array — there is no return on this path — but
614
- // the store is durable and is what `evidencePath` points at, so the
615
- // record survives the throw.
484
+ // caller's error is the actionable one; a teardown failure must not
485
+ // silently replace it -- written to the evidence store instead (tagged
486
+ // "harness", RT-219) rather than thrown. Not in the returned array, but
487
+ // the store is durable so the record survives the throw.
616
488
  input.store.write({
617
489
  executionId: execution.executionId,
618
490
  timestamp: new Date().toISOString(),
@@ -637,9 +509,7 @@ export async function runInstrumentedExecution(input) {
637
509
  }
638
510
  }
639
511
  }
640
- // Rethrown untouched — the same error object, the same stack, to the same
641
- // caller. The caller's own failure wins over a teardown failure when both
642
- // happened, because it is the one they can act on.
512
+ // rethrown untouched, same object/stack; caller's failure wins over teardown's
643
513
  if (bodyFailure !== null)
644
514
  throw bodyFailure.error;
645
515
  if (teardownFailure !== null)