tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21

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 (90) hide show
  1. package/bin/cli.js +1979 -783
  2. package/package.json +4 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
  4. package/packages/contracts/src/daemon-events.ts +12 -12
  5. package/packages/contracts/src/daemon-wire.ts +30 -0
  6. package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
  7. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  8. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  9. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
  10. package/packages/daemon/dist/command-center/server.js +7 -0
  11. package/packages/daemon/dist/doctor.js +62 -0
  12. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
  13. package/packages/daemon/dist/lib/app-config.js +4 -2
  14. package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +24 -0
  16. package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
  17. package/packages/daemon/dist/lib/headless-daemon.js +1 -0
  18. package/packages/daemon/dist/lib/log-sanitize.js +199 -0
  19. package/packages/daemon/dist/lib/log.js +123 -13
  20. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  21. package/packages/daemon/dist/lib/soak-verdict.js +472 -0
  22. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  23. package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
  24. package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
  25. package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
  26. package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
  27. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  28. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  29. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  30. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  31. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  32. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
  33. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  34. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  35. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  36. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  37. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  38. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  39. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  40. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  41. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  42. package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
  43. package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
  44. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  45. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  46. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
  47. package/packages/daemon/src/command-center/server.ts +8 -0
  48. package/packages/daemon/src/doctor.ts +70 -0
  49. package/packages/daemon/src/lib/app-config.ts +11 -3
  50. package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +30 -0
  52. package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
  53. package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
  54. package/packages/daemon/src/lib/headless-daemon.ts +1 -0
  55. package/packages/daemon/src/lib/log-sanitize.ts +248 -0
  56. package/packages/daemon/src/lib/log.ts +165 -14
  57. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  58. package/packages/daemon/src/lib/soak-verdict.ts +785 -0
  59. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  60. package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
  61. package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
  62. package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
  63. package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
  64. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  65. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  66. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  67. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  68. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  69. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
  71. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  72. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
  73. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
  74. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
  75. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
  76. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  78. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  80. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  81. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  82. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  83. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  84. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  85. package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
  86. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  87. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
  88. package/packages/tmux-bridge/src/index.ts +2 -0
  89. package/packages/tmux-bridge/src/runner.test.ts +41 -0
  90. package/packages/tmux-bridge/src/runner.ts +41 -2
@@ -0,0 +1,785 @@
1
+ import {
2
+ MEMORY_KEYS,
3
+ RESOURCE_KEYS,
4
+ type DiagnosticsResult,
5
+ type DiagnosticsDelta,
6
+ } from "./soak-diagnostics.ts";
7
+
8
+ /**
9
+ * Pure analysis for the daemon soak harness (`packages/daemon/scripts/soak-daemon.mjs`).
10
+ *
11
+ * The harness records one JSONL sample per interval; this module turns the
12
+ * sample series into a summary and a verdict against explicit thresholds. It
13
+ * has no io and no dependencies so the harness can import it directly and the
14
+ * numbers it prints are the numbers the colocated tests pin down.
15
+ */
16
+
17
+ export interface SoakSample {
18
+ readonly diagnostics?: DiagnosticsResult;
19
+ readonly diagnosticDelta?: DiagnosticsDelta | null;
20
+ /** Milliseconds since the epoch when the sample was taken. */
21
+ readonly at: number;
22
+ /** Seconds since the soak started (the linear-fit abscissa). */
23
+ readonly elapsedSeconds: number;
24
+ /** Daemon resident set size in KiB (`ps -o rss`). */
25
+ readonly rssKiB: number | null;
26
+ /** Daemon cumulative CPU seconds (`ps -o time`) at sample time. */
27
+ readonly cpuSeconds: number | null;
28
+ /** CPU seconds consumed during the interval that ended with this sample. */
29
+ readonly cpuDeltaSeconds: number | null;
30
+ /** Open file descriptors / handles of the daemon process. */
31
+ readonly openFds: number | null;
32
+ /** tmux children the daemon spawned during the interval. */
33
+ readonly tmuxSpawns: number;
34
+ /** Interval length in seconds (spawn-rate denominator). */
35
+ readonly intervalSeconds: number;
36
+ readonly pingRttMs: Percentiles;
37
+ readonly receiptLatencyMs: Percentiles;
38
+ /** Interaction-observer gap warnings logged during the interval. */
39
+ readonly observerGapWarnings: number;
40
+ /** Daemon pid/instance changes observed so far (cumulative). */
41
+ readonly daemonRestarts: number;
42
+ /** Receipt waiters that failed or timed out during the interval. */
43
+ readonly receiptFailures: number;
44
+ /** Events-client disconnects that were not scheduled reconnects (interval). */
45
+ readonly unexpectedDisconnects: number;
46
+ }
47
+
48
+ export interface Percentiles {
49
+ readonly count: number;
50
+ readonly p50: number | null;
51
+ readonly max: number | null;
52
+ /** Logarithmic bucket index → count; p50 is an upper bound; ≤5% or 1ms error below overflow (which uses max). */
53
+ readonly buckets?: Readonly<Record<string, number>>;
54
+ }
55
+
56
+ export interface SoakThresholds {
57
+ /** Fitted RSS slope bound, MiB per hour. */
58
+ readonly maxRssGrowthMiBPerHour: number;
59
+ /** |last fd count − first fd count| bound. */
60
+ readonly maxFdDrift: number;
61
+ /** Fitted fd slope bound, descriptors per hour. */
62
+ readonly maxFdGrowthPerHour: number;
63
+ readonly maxPingRttP50Ms: number;
64
+ readonly maxReceiptLatencyP50Ms: number;
65
+ readonly maxObserverGapWarnings: number;
66
+ readonly maxDaemonRestarts: number;
67
+ readonly maxReceiptFailures: number;
68
+ readonly maxUnexpectedDisconnects: number;
69
+ /** Fewer samples than this cannot support a growth verdict. */
70
+ readonly minSamplesForFit: number;
71
+ }
72
+
73
+ export const DEFAULT_SOAK_THRESHOLDS: SoakThresholds = {
74
+ maxRssGrowthMiBPerHour: 8,
75
+ maxFdDrift: 16,
76
+ maxFdGrowthPerHour: 32,
77
+ maxPingRttP50Ms: 50,
78
+ maxReceiptLatencyP50Ms: 3_000,
79
+ maxObserverGapWarnings: 0,
80
+ maxDaemonRestarts: 0,
81
+ maxReceiptFailures: 0,
82
+ maxUnexpectedDisconnects: 0,
83
+ minSamplesForFit: 3,
84
+ };
85
+
86
+ export interface LinearFit {
87
+ readonly slope: number;
88
+ readonly intercept: number;
89
+ /** Coefficient of determination; 0 when y is constant. */
90
+ readonly r2: number;
91
+ readonly points: number;
92
+ }
93
+
94
+ /** Ordinary least squares over `(x, y)` pairs; `null` with fewer than two distinct x. */
95
+ export function linearFit(points: readonly { x: number; y: number }[]): LinearFit | null {
96
+ const n = points.length;
97
+ if (n < 2) return null;
98
+ let sumX = 0;
99
+ let sumY = 0;
100
+ for (const { x, y } of points) {
101
+ sumX += x;
102
+ sumY += y;
103
+ }
104
+ const meanX = sumX / n;
105
+ const meanY = sumY / n;
106
+ let sxx = 0;
107
+ let sxy = 0;
108
+ let syy = 0;
109
+ for (const { x, y } of points) {
110
+ const dx = x - meanX;
111
+ const dy = y - meanY;
112
+ sxx += dx * dx;
113
+ sxy += dx * dy;
114
+ syy += dy * dy;
115
+ }
116
+ if (sxx === 0) return null;
117
+ const slope = sxy / sxx;
118
+ const intercept = meanY - slope * meanX;
119
+ const r2 = syy === 0 ? 0 : (sxy * sxy) / (sxx * syy);
120
+ return { slope, intercept, r2, points: n };
121
+ }
122
+
123
+ /** Nearest-rank percentile of a numeric series; `null` for an empty series. */
124
+ export function percentile(values: readonly number[], fraction: number): number | null {
125
+ if (values.length === 0) return null;
126
+ const sorted = [...values].sort((a, b) => a - b);
127
+ const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
128
+ return sorted[rank]!;
129
+ }
130
+
131
+ /** At most 228 buckets for probes capped at 60 seconds, plus an overflow bucket. */
132
+ export function percentiles(values: readonly number[]): Percentiles {
133
+ const buckets: Record<string, number> = {};
134
+ let max: number | null = null;
135
+ for (const value of values) {
136
+ if (!Number.isFinite(value) || value < 0) continue;
137
+ max = Math.max(max ?? 0, value);
138
+ const index = value <= 1 ? 0 : Math.min(227, Math.ceil(Math.log(value) / Math.log(1.05)));
139
+ buckets[index] = (buckets[index] ?? 0) + 1;
140
+ }
141
+ return mergePercentiles([
142
+ {
143
+ count: Object.values(buckets).reduce((a, b) => a + b, 0),
144
+ p50: null,
145
+ max,
146
+ buckets,
147
+ },
148
+ ]);
149
+ }
150
+
151
+ /**
152
+ * Fit a per-sample metric against elapsed time and report the slope per hour.
153
+ * Samples without the metric are skipped.
154
+ */
155
+ export function growthPerHour(
156
+ samples: readonly SoakSample[],
157
+ pick: (sample: SoakSample) => number | null,
158
+ ): LinearFit | null {
159
+ const points: { x: number; y: number }[] = [];
160
+ for (const sample of samples) {
161
+ const value = pick(sample);
162
+ if (value === null || !Number.isFinite(value)) continue;
163
+ points.push({ x: sample.elapsedSeconds / 3_600, y: value });
164
+ }
165
+ return linearFit(points);
166
+ }
167
+
168
+ export interface SoakCheck {
169
+ readonly id: string;
170
+ readonly ok: boolean | null;
171
+ readonly observed: number | null;
172
+ readonly bound: number;
173
+ readonly detail: string;
174
+ }
175
+
176
+ export interface SoakSummary {
177
+ readonly diagnostics: ReturnType<typeof summarizeDiagnostics>;
178
+ readonly samples: number;
179
+ readonly durationSeconds: number;
180
+ readonly rssStartMiB: number | null;
181
+ readonly rssEndMiB: number | null;
182
+ readonly rssMaxMiB: number | null;
183
+ readonly rssGrowthMiBPerHour: number | null;
184
+ readonly rssFitR2: number | null;
185
+ readonly fdStart: number | null;
186
+ readonly fdEnd: number | null;
187
+ readonly fdMax: number | null;
188
+ readonly fdGrowthPerHour: number | null;
189
+ readonly cpuSecondsTotal: number | null;
190
+ readonly cpuPercentMean: number | null;
191
+ readonly tmuxSpawnsTotal: number;
192
+ readonly tmuxSpawnsPerMinute: number | null;
193
+ readonly pingRttMs: Percentiles;
194
+ readonly receiptLatencyMs: Percentiles;
195
+ readonly observerGapWarnings: number;
196
+ readonly daemonRestarts: number;
197
+ readonly receiptFailures: number;
198
+ readonly unexpectedDisconnects: number;
199
+ }
200
+
201
+ export interface SoakVerdict {
202
+ readonly verdict: "pass" | "fail" | "inconclusive";
203
+ readonly summary: SoakSummary;
204
+ readonly checks: readonly SoakCheck[];
205
+ }
206
+
207
+ const KIB_PER_MIB = 1024;
208
+
209
+ function firstValue(
210
+ samples: readonly SoakSample[],
211
+ pick: (sample: SoakSample) => number | null,
212
+ ): number | null {
213
+ for (const sample of samples) {
214
+ const value = pick(sample);
215
+ if (value !== null) return value;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ function lastValue(
221
+ samples: readonly SoakSample[],
222
+ pick: (sample: SoakSample) => number | null,
223
+ ): number | null {
224
+ for (let index = samples.length - 1; index >= 0; index -= 1) {
225
+ const value = pick(samples[index]!);
226
+ if (value !== null) return value;
227
+ }
228
+ return null;
229
+ }
230
+
231
+ function maxValue(
232
+ samples: readonly SoakSample[],
233
+ pick: (sample: SoakSample) => number | null,
234
+ ): number | null {
235
+ let max: number | null = null;
236
+ for (const sample of samples) {
237
+ const value = pick(sample);
238
+ if (value === null) continue;
239
+ if (max === null || value > max) max = value;
240
+ }
241
+ return max;
242
+ }
243
+
244
+ /** Merge bounded histograms, never a median of medians. Legacy p50 is unmeasured. */
245
+ export function mergePercentiles(records: readonly Percentiles[]): Percentiles {
246
+ const buckets: Record<string, number> = {};
247
+ let count = 0;
248
+ let max: number | null = null;
249
+ let missing = false;
250
+ for (const record of records) {
251
+ count += record.count;
252
+ if (record.count && !record.buckets) missing = true;
253
+ if (record.max !== null) max = Math.max(max ?? 0, record.max);
254
+ for (const [key, value] of Object.entries(record.buckets ?? {}))
255
+ buckets[key] = (buckets[key] ?? 0) + value;
256
+ }
257
+ let cumulative = 0;
258
+ let p50: number | null = null;
259
+ if (!missing && count) {
260
+ for (const key of Object.keys(buckets)
261
+ .map(Number)
262
+ .sort((a, b) => a - b)) {
263
+ cumulative += buckets[key]!;
264
+ if (cumulative >= Math.ceil(count / 2)) {
265
+ p50 = key === 227 ? max : Math.min(max ?? Infinity, 1.05 ** key);
266
+ break;
267
+ }
268
+ }
269
+ }
270
+ return { count, p50, max, buckets };
271
+ }
272
+
273
+ /** Run-level evidence must be supplied explicitly; absent evidence cannot qualify. */
274
+ export interface SoakEvidence {
275
+ readonly requestedSeconds: number;
276
+ readonly observedSeconds: number;
277
+ readonly completed: boolean;
278
+ readonly failures: Readonly<Record<string, number>>;
279
+ readonly loadCompleted: boolean;
280
+ readonly telemetryComplete: boolean;
281
+ readonly logCoverageComplete: boolean;
282
+ readonly reconnectAttempts: number;
283
+ readonly reconnectAcknowledged: number;
284
+ readonly shutdownClean: boolean;
285
+ readonly recordRetired: boolean;
286
+ readonly cleanupComplete: boolean;
287
+ readonly resourceTrendPolicyComplete: boolean;
288
+ readonly warmupSeconds: number;
289
+ readonly trailingSeconds: number;
290
+ }
291
+
292
+ export function soakTrends(
293
+ samples: readonly SoakSample[],
294
+ warmupSeconds: number,
295
+ trailingSeconds: number,
296
+ ) {
297
+ const end = samples.at(-1)?.elapsedSeconds ?? 0;
298
+ const fit = (window: readonly SoakSample[]) => ({
299
+ memoryMiBPerHour: Object.fromEntries(
300
+ MEMORY_KEYS.map((key) => [
301
+ key,
302
+ growthPerHour(window, (s) => {
303
+ const value = s.diagnostics?.sample?.memory[key];
304
+ return value === undefined ? null : value / 1024 ** 2;
305
+ }),
306
+ ]),
307
+ ),
308
+ activeResourcesPerHour: Object.fromEntries(
309
+ RESOURCE_KEYS.map((key) => [
310
+ key,
311
+ growthPerHour(window, (s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
312
+ ]),
313
+ ),
314
+ diagnosticCpuPercentPerHour: growthPerHour(window, (s) =>
315
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null,
316
+ ),
317
+ eventLoopUtilizationPerHour: growthPerHour(window, (s) =>
318
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null,
319
+ ),
320
+ rssMiBPerHour: growthPerHour(window, (s) => (s.rssKiB === null ? null : s.rssKiB / 1024)),
321
+ cpuPercentPerHour: growthPerHour(window, (s) =>
322
+ s.cpuDeltaSeconds === null || s.intervalSeconds <= 0
323
+ ? null
324
+ : (100 * s.cpuDeltaSeconds) / s.intervalSeconds,
325
+ ),
326
+ spawnsPerMinutePerHour: growthPerHour(window, (s) =>
327
+ s.intervalSeconds <= 0 ? null : (60 * s.tmuxSpawns) / s.intervalSeconds,
328
+ ),
329
+ });
330
+ return {
331
+ whole: fit(samples),
332
+ afterWarmup: fit(samples.filter((s) => s.elapsedSeconds >= warmupSeconds)),
333
+ trailing: fit(
334
+ samples.filter((s) => s.elapsedSeconds >= Math.max(warmupSeconds, end - trailingSeconds)),
335
+ ),
336
+ };
337
+ }
338
+
339
+ /** Descriptive only: heap changes can reflect GC and do not prove retention. */
340
+ export function summarizeDiagnostics(samples: readonly SoakSample[]) {
341
+ const describe = (pick: (s: SoakSample) => number | null) => ({
342
+ start: firstValue(samples, pick),
343
+ end: lastValue(samples, pick),
344
+ max: maxValue(samples, pick),
345
+ measuredSamples: samples.filter((s) => pick(s) !== null).length,
346
+ });
347
+ return {
348
+ validSamples: samples.filter((s) => s.diagnostics?.status === "ok").length,
349
+ unsupportedResourceSamples: samples.filter(
350
+ (s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources === null,
351
+ ).length,
352
+ memoryBytes: Object.fromEntries(
353
+ MEMORY_KEYS.map((key) => [key, describe((s) => s.diagnostics?.sample?.memory[key] ?? null)]),
354
+ ),
355
+ activeResources: Object.fromEntries(
356
+ RESOURCE_KEYS.map((key) => [
357
+ key,
358
+ describe((s) => s.diagnostics?.sample?.activeResources?.[key] ?? null),
359
+ ]),
360
+ ),
361
+ cpuPercent: describe((s) =>
362
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.cpuPercent : null,
363
+ ),
364
+ eventLoopUtilization: describe((s) =>
365
+ s.diagnosticDelta?.status === "ok" ? s.diagnosticDelta.eventLoopUtilization : null,
366
+ ),
367
+ };
368
+ }
369
+
370
+ export function summarizeSoak(samples: readonly SoakSample[]): SoakSummary {
371
+ const rss = (sample: SoakSample): number | null =>
372
+ sample.rssKiB === null ? null : sample.rssKiB / KIB_PER_MIB;
373
+ const fds = (sample: SoakSample): number | null => sample.openFds;
374
+ const rssFit = growthPerHour(samples, rss);
375
+ const fdFit = growthPerHour(samples, fds);
376
+ const durationSeconds =
377
+ samples.length === 0
378
+ ? 0
379
+ : samples[samples.length - 1]!.elapsedSeconds - samples[0]!.elapsedSeconds;
380
+ let cpuTotal: number | null = null;
381
+ let intervalTotal = 0;
382
+ let spawnsTotal = 0;
383
+ let gaps = 0;
384
+ let receiptFailures = 0;
385
+ let disconnects = 0;
386
+ for (const sample of samples) {
387
+ if (sample.cpuDeltaSeconds !== null) cpuTotal = (cpuTotal ?? 0) + sample.cpuDeltaSeconds;
388
+ intervalTotal += sample.intervalSeconds;
389
+ spawnsTotal += sample.tmuxSpawns;
390
+ gaps += sample.observerGapWarnings;
391
+ receiptFailures += sample.receiptFailures;
392
+ disconnects += sample.unexpectedDisconnects;
393
+ }
394
+ return {
395
+ diagnostics: summarizeDiagnostics(samples),
396
+ samples: samples.length,
397
+ durationSeconds,
398
+ rssStartMiB: firstValue(samples, rss),
399
+ rssEndMiB: lastValue(samples, rss),
400
+ rssMaxMiB: maxValue(samples, rss),
401
+ rssGrowthMiBPerHour: rssFit?.slope ?? null,
402
+ rssFitR2: rssFit?.r2 ?? null,
403
+ fdStart: firstValue(samples, fds),
404
+ fdEnd: lastValue(samples, fds),
405
+ fdMax: maxValue(samples, fds),
406
+ fdGrowthPerHour: fdFit?.slope ?? null,
407
+ cpuSecondsTotal: cpuTotal,
408
+ cpuPercentMean:
409
+ cpuTotal === null || intervalTotal === 0 ? null : (cpuTotal / intervalTotal) * 100,
410
+ tmuxSpawnsTotal: spawnsTotal,
411
+ tmuxSpawnsPerMinute: intervalTotal === 0 ? null : spawnsTotal / (intervalTotal / 60),
412
+ pingRttMs: mergePercentiles(samples.map((sample) => sample.pingRttMs)),
413
+ receiptLatencyMs: mergePercentiles(samples.map((sample) => sample.receiptLatencyMs)),
414
+ observerGapWarnings: gaps,
415
+ daemonRestarts: lastValue(samples, (sample) => sample.daemonRestarts) ?? 0,
416
+ receiptFailures,
417
+ unexpectedDisconnects: disconnects,
418
+ };
419
+ }
420
+
421
+ function boundCheck(
422
+ id: string,
423
+ observed: number | null,
424
+ bound: number,
425
+ detail: string,
426
+ options: { absolute?: boolean } = {},
427
+ ): SoakCheck {
428
+ if (observed === null)
429
+ return { id, ok: null, observed, bound, detail: `${detail}: not measured` };
430
+ const value = options.absolute ? Math.abs(observed) : observed;
431
+ return { id, ok: value <= bound, observed, bound, detail };
432
+ }
433
+
434
+ /**
435
+ * Evaluate a sample series. A check is `ok: null` when the run could not
436
+ * measure it (too few samples for a fit, a metric never collected); the
437
+ * overall verdict is then `inconclusive` unless some other check failed.
438
+ */
439
+ export function evaluateSoak(
440
+ samples: readonly SoakSample[],
441
+ thresholds: SoakThresholds = DEFAULT_SOAK_THRESHOLDS,
442
+ evidence?: SoakEvidence,
443
+ ): SoakVerdict {
444
+ const summary = summarizeSoak(samples);
445
+ const enoughForFit =
446
+ samples.length >= thresholds.minSamplesForFit &&
447
+ (!evidence ||
448
+ samples.filter((sample) => sample.elapsedSeconds >= evidence.warmupSeconds).length >=
449
+ thresholds.minSamplesForFit);
450
+ const checks: SoakCheck[] = [
451
+ enoughForFit
452
+ ? boundCheck(
453
+ "rss-growth",
454
+ summary.rssGrowthMiBPerHour,
455
+ thresholds.maxRssGrowthMiBPerHour,
456
+ `fitted RSS slope MiB/h over ${samples.length} samples (r2 ${summary.rssFitR2?.toFixed(2) ?? "n/a"})`,
457
+ )
458
+ : {
459
+ id: "rss-growth",
460
+ ok: null,
461
+ observed: summary.rssGrowthMiBPerHour,
462
+ bound: thresholds.maxRssGrowthMiBPerHour,
463
+ detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
464
+ },
465
+ boundCheck(
466
+ "fd-drift",
467
+ summary.fdStart === null || summary.fdEnd === null ? null : summary.fdEnd - summary.fdStart,
468
+ thresholds.maxFdDrift,
469
+ "open fd count end minus start",
470
+ { absolute: true },
471
+ ),
472
+ enoughForFit
473
+ ? boundCheck(
474
+ "fd-growth",
475
+ summary.fdGrowthPerHour,
476
+ thresholds.maxFdGrowthPerHour,
477
+ "fitted fd slope per hour",
478
+ )
479
+ : {
480
+ id: "fd-growth",
481
+ ok: null,
482
+ observed: summary.fdGrowthPerHour,
483
+ bound: thresholds.maxFdGrowthPerHour,
484
+ detail: `needs ${thresholds.minSamplesForFit} samples and, when declared, post-warmup coverage; have ${samples.length} total`,
485
+ },
486
+ boundCheck(
487
+ "ping-rtt-p50",
488
+ summary.pingRttMs.p50,
489
+ thresholds.maxPingRttP50Ms,
490
+ "WebSocket transport control RTT p50 upper bound ms (not semantic handler latency)",
491
+ ),
492
+ boundCheck(
493
+ "receipt-latency-p50",
494
+ summary.receiptLatencyMs.p50,
495
+ thresholds.maxReceiptLatencyP50Ms,
496
+ "wait agent-status receipt latency p50 upper bound ms after the flip",
497
+ ),
498
+ boundCheck(
499
+ "observer-gaps",
500
+ summary.observerGapWarnings,
501
+ thresholds.maxObserverGapWarnings,
502
+ "interaction observer gap warnings",
503
+ ),
504
+ boundCheck(
505
+ "daemon-restarts",
506
+ summary.daemonRestarts,
507
+ thresholds.maxDaemonRestarts,
508
+ "daemon pid or instance changes",
509
+ ),
510
+ boundCheck(
511
+ "receipt-failures",
512
+ summary.receiptFailures,
513
+ thresholds.maxReceiptFailures,
514
+ "receipt waiters that failed or timed out",
515
+ ),
516
+ boundCheck(
517
+ "unexpected-disconnects",
518
+ summary.unexpectedDisconnects,
519
+ thresholds.maxUnexpectedDisconnects,
520
+ "events-client drops outside scheduled reconnects",
521
+ ),
522
+ ];
523
+ const coverage = (id: string, complete: boolean | undefined, detail: string) =>
524
+ checks.push({
525
+ id,
526
+ ok: complete === true ? true : null,
527
+ observed: complete === true ? 1 : null,
528
+ bound: 1,
529
+ detail,
530
+ });
531
+ coverage(
532
+ "duration-complete",
533
+ evidence?.completed && evidence.observedSeconds >= evidence.requestedSeconds,
534
+ "requested duration reached without interruption",
535
+ );
536
+ coverage(
537
+ "telemetry-complete",
538
+ evidence?.telemetryComplete &&
539
+ samples.every((s) =>
540
+ [s.rssKiB, s.cpuSeconds, s.openFds].every(
541
+ (value) => value !== null && Number.isFinite(value),
542
+ ),
543
+ ),
544
+ "all required samples and metrics present",
545
+ );
546
+ coverage(
547
+ "diagnostics-coverage",
548
+ samples.length > 0 &&
549
+ samples.every(
550
+ (s) => s.diagnostics?.status === "ok" && s.diagnostics.sample.activeResources !== null,
551
+ ),
552
+ "owner diagnostics and supported resource counts required at every sample; missing/malformed/404 remain inconclusive",
553
+ );
554
+ coverage(
555
+ "diagnostics-deltas",
556
+ samples.length > 1 &&
557
+ samples.every((s, i) =>
558
+ i === 0
559
+ ? s.diagnosticDelta?.status === "missing-baseline"
560
+ : s.diagnosticDelta?.status === "ok" && s.diagnosticDelta.eventLoopUtilization !== null,
561
+ ),
562
+ "adjacent monotonic cumulative counters required after first baseline",
563
+ );
564
+ checks.push(
565
+ boundCheck(
566
+ "diagnostics-identity",
567
+ samples.filter(
568
+ (s) =>
569
+ s.diagnostics?.status === "identity-mismatch" ||
570
+ s.diagnosticDelta?.status === "identity-mismatch",
571
+ ).length,
572
+ 0,
573
+ "diagnostics must match original daemon identity and PID",
574
+ ),
575
+ );
576
+ coverage(
577
+ "load-complete",
578
+ evidence?.loadCompleted,
579
+ "each declared workload completed at least once",
580
+ );
581
+ coverage(
582
+ "log-coverage",
583
+ evidence?.logCoverageComplete,
584
+ "continuous log stream, bookmark, no gap or replay ambiguity",
585
+ );
586
+ coverage("run-evidence", evidence !== undefined, "explicit run and teardown evidence supplied");
587
+ coverage(
588
+ "resource-trend-policy",
589
+ evidence?.resourceTrendPolicyComplete,
590
+ "heap/resources and CPU/spawn trend acceptance policy awaiting calibration and review",
591
+ );
592
+ if (evidence) {
593
+ const requiredFailures = [
594
+ "health",
595
+ "flip",
596
+ "promotion",
597
+ "send",
598
+ "receipt",
599
+ "daemonMissing",
600
+ "daemonExit",
601
+ "ack",
602
+ "pingDeadline",
603
+ "loop",
604
+ "sample",
605
+ ];
606
+ for (const name of new Set([...requiredFailures, ...Object.keys(evidence.failures)])) {
607
+ const count = evidence.failures[name];
608
+ checks.push(
609
+ boundCheck(
610
+ name,
611
+ typeof count === "number" && Number.isFinite(count) && count >= 0 ? count : null,
612
+ 0,
613
+ "explicit run failure count",
614
+ ),
615
+ );
616
+ }
617
+ coverage(
618
+ "reconnect-coverage",
619
+ evidence.reconnectAttempts > 1 &&
620
+ evidence.reconnectAttempts === evidence.reconnectAcknowledged,
621
+ "initial subscription and at least one reconnect acknowledged",
622
+ );
623
+ for (const [id, ok] of [
624
+ ["shutdown-clean", evidence.shutdownClean],
625
+ ["record-retired", evidence.recordRetired],
626
+ ["cleanup-complete", evidence.cleanupComplete],
627
+ ] as const)
628
+ checks.push({ id, ok, observed: ok ? 0 : 1, bound: 0, detail: "observed teardown outcome" });
629
+ const trends = soakTrends(samples, evidence.warmupSeconds, evidence.trailingSeconds);
630
+ for (const [window, metrics] of Object.entries(trends)) {
631
+ if (window === "whole") continue;
632
+ const fit = metrics.rssMiBPerHour;
633
+ checks.push(
634
+ boundCheck(
635
+ `rss-${window}`,
636
+ fit && fit.points >= thresholds.minSamplesForFit ? fit.slope : null,
637
+ thresholds.maxRssGrowthMiBPerHour,
638
+ "predeclared window RSS slope MiB/h",
639
+ ),
640
+ );
641
+ }
642
+ }
643
+ const failed = checks.some((check) => check.ok === false);
644
+ const unmeasured = checks.some((check) => check.ok === null);
645
+ return {
646
+ verdict: failed ? "fail" : unmeasured ? "inconclusive" : "pass",
647
+ summary,
648
+ checks,
649
+ };
650
+ }
651
+
652
+ /** Parse `ps -o time` output (`mm:ss.cc`, `hh:mm:ss`, `dd-hh:mm:ss`) into seconds. */
653
+ export function parsePsTime(raw: string): number | null {
654
+ const text = raw.trim();
655
+ if (!text) return null;
656
+ let days = 0;
657
+ let rest = text;
658
+ const dayMatch = /^(\d+)-(.+)$/u.exec(rest);
659
+ if (dayMatch) {
660
+ days = Number(dayMatch[1]);
661
+ rest = dayMatch[2]!;
662
+ }
663
+ const parts = rest.split(":");
664
+ if (parts.length < 2 || parts.length > 3 || parts.some((part) => !/^\d+(?:\.\d+)?$/u.test(part)))
665
+ return null;
666
+ let seconds = 0;
667
+ for (const part of parts) seconds = seconds * 60 + Number(part);
668
+ return days * 86_400 + seconds;
669
+ }
670
+
671
+ /** Parse a duration flag: `30m`, `24h`, `90s`, `1500ms`, or a bare millisecond count. */
672
+ export function parseDurationMs(raw: string | number): number {
673
+ if (typeof raw === "number") return raw;
674
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\s*$/u.exec(raw);
675
+ if (!match) throw new Error(`Invalid duration: ${raw}`);
676
+ const value = Number(match[1]);
677
+ const unit = match[2] ?? "ms";
678
+ const factor =
679
+ unit === "ms"
680
+ ? 1
681
+ : unit === "s"
682
+ ? 1_000
683
+ : unit === "m"
684
+ ? 60_000
685
+ : unit === "h"
686
+ ? 3_600_000
687
+ : 86_400_000;
688
+ return Math.round(value * factor);
689
+ }
690
+
691
+ /**
692
+ * Attribute counting-shim records (one per spawn, arguments joined by 0x01) to
693
+ * their first tmux command word, skipping socket/option prefixes.
694
+ */
695
+ export function countSpawnsByCommand(records: readonly string[]): Record<string, number> {
696
+ const counts: Record<string, number> = {};
697
+ for (const line of records) {
698
+ if (!line) continue;
699
+ const words = line.split("\u0001");
700
+ let index = 0;
701
+ while (index < words.length && words[index]!.startsWith("-")) {
702
+ index += words[index] === "-u" || words[index] === "-C" || words[index] === "-v" ? 1 : 2;
703
+ }
704
+ const command = words[index] || "(none)";
705
+ counts[command] = (counts[command] ?? 0) + 1;
706
+ }
707
+ return counts;
708
+ }
709
+
710
+ /** Fixed-width text table for the terminal summary. */
711
+ export function formatSoakReport(result: SoakVerdict): string {
712
+ const { summary, checks } = result;
713
+ const num = (value: number | null, digits = 1): string =>
714
+ value === null ? "n/a" : value.toFixed(digits);
715
+ const rows: [string, string][] = [
716
+ ["samples", `${summary.samples} over ${num(summary.durationSeconds / 60)} min`],
717
+ [
718
+ "rss MiB",
719
+ `start ${num(summary.rssStartMiB)} end ${num(summary.rssEndMiB)} max ${num(summary.rssMaxMiB)} slope ${num(summary.rssGrowthMiBPerHour, 2)}/h`,
720
+ ],
721
+ [
722
+ "open fds",
723
+ `start ${num(summary.fdStart, 0)} end ${num(summary.fdEnd, 0)} max ${num(summary.fdMax, 0)} slope ${num(summary.fdGrowthPerHour, 2)}/h`,
724
+ ],
725
+ ["cpu", `${num(summary.cpuSecondsTotal)} s total ${num(summary.cpuPercentMean, 2)} % mean`],
726
+ ["tmux spawns", `${summary.tmuxSpawnsTotal} total ${num(summary.tmuxSpawnsPerMinute, 2)}/min`],
727
+ [
728
+ "ping rtt ms",
729
+ `p50 <= ${num(summary.pingRttMs.p50, 2)} max ${num(summary.pingRttMs.max, 2)} n ${summary.pingRttMs.count}`,
730
+ ],
731
+ [
732
+ "receipt ms",
733
+ `p50 <= ${num(summary.receiptLatencyMs.p50, 0)} max ${num(summary.receiptLatencyMs.max, 0)} n ${summary.receiptLatencyMs.count}`,
734
+ ],
735
+ [
736
+ "owner diagnostics",
737
+ `${summary.diagnostics.validSamples}/${summary.samples} valid; resources unsupported ${summary.diagnostics.unsupportedResourceSamples}`,
738
+ ],
739
+ [
740
+ "heap used MiB",
741
+ `start ${num(summary.diagnostics.memoryBytes.heapUsed?.start === null || summary.diagnostics.memoryBytes.heapUsed?.start === undefined ? null : summary.diagnostics.memoryBytes.heapUsed.start / 1024 ** 2)} end ${num(summary.diagnostics.memoryBytes.heapUsed?.end === null || summary.diagnostics.memoryBytes.heapUsed?.end === undefined ? null : summary.diagnostics.memoryBytes.heapUsed.end / 1024 ** 2)} (descriptive; GC-sensitive)`,
742
+ ],
743
+ [
744
+ "diagnostic CPU",
745
+ `${num(summary.diagnostics.cpuPercent.start)} → ${num(summary.diagnostics.cpuPercent.end)} % interval; policy pending`,
746
+ ],
747
+ ["observer gaps", String(summary.observerGapWarnings)],
748
+ ["daemon restarts", String(summary.daemonRestarts)],
749
+ ["receipt failures", String(summary.receiptFailures)],
750
+ ["unexpected disconnects", String(summary.unexpectedDisconnects)],
751
+ ];
752
+ const width = Math.max(...rows.map(([label]) => label.length), ...checks.map((c) => c.id.length));
753
+ const lines = rows.map(([label, value]) => `${label.padEnd(width)} ${value}`);
754
+ lines.push("");
755
+ for (const check of checks) {
756
+ const mark = check.ok === null ? "----" : check.ok ? "ok " : "FAIL";
757
+ lines.push(
758
+ `${mark} ${check.id.padEnd(width)} observed ${num(check.observed, 2)} bound ${check.bound} ${check.detail}`,
759
+ );
760
+ }
761
+ lines.push("");
762
+ lines.push(`verdict: ${result.verdict.toUpperCase()}`);
763
+ return lines.join("\n");
764
+ }
765
+
766
+ /** A revision acknowledges exactly the interest set sent in that subscribe. */
767
+ export function validSoakAck(frame: unknown, revision: number): boolean {
768
+ if (!frame || typeof frame !== "object") return false;
769
+ const ack = frame as Record<string, unknown>;
770
+ return (
771
+ ack.type === "resource.interests-ack" &&
772
+ ack.interestRevision === revision &&
773
+ Array.isArray(ack.unavailableInterests) &&
774
+ ack.unavailableInterests.length === 0
775
+ );
776
+ }
777
+
778
+ /** Unsolicited/stale control payloads cannot discharge the current probe. */
779
+ export function correlatedPongRtt(
780
+ probe: { readonly id: string; readonly at: number },
781
+ payload: string,
782
+ now: number,
783
+ ): number | null {
784
+ return payload === probe.id && Number.isFinite(now) && now >= probe.at ? now - probe.at : null;
785
+ }