pi-crew 0.9.50 → 0.9.51

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.
@@ -0,0 +1,96 @@
1
+ import { logInternalError } from "../utils/internal-error.ts";
2
+ import { RenderScheduler } from "./render-scheduler.ts";
3
+ import { runEventBusAsRenderScheduler } from "./run-event-bus.ts";
4
+
5
+ export interface OverlaySchedulerHandle {
6
+ /** Force a debounced re-render fan-out (e.g. theme change, dashboard interaction). */
7
+ schedule(): void;
8
+ /** Unregister this overlay. Disposes the shared scheduler when the last overlay leaves (ref-count). */
9
+ dispose(): void;
10
+ }
11
+
12
+ /**
13
+ * C3 (Option G): the 3 overlays (widget + sidebar + dashboard) each used to
14
+ * instantiate an identical RenderScheduler (same run-event-bus channels,
15
+ * debounce 75ms, fallback 750ms) → 9 subscriptions + 9 live timers + 3×
16
+ * per-event schedule() CPU during active runs. This module owns ONE shared
17
+ * RenderScheduler (module singleton) whose `render` and `onInvalidate`
18
+ * fan out to all registered overlays, with per-callback try/catch isolation
19
+ * + ref-counted disposal (the underlying scheduler is disposed when the last
20
+ * overlay leaves, and a future register recreates it).
21
+ */
22
+
23
+ const CHANNELS = ["run:state", "worker:lifecycle", "ui:invalidate"] as const;
24
+
25
+ interface SharedOverlayState {
26
+ readonly scheduler: RenderScheduler;
27
+ readonly renderers: Set<() => void>;
28
+ readonly invalidators: Set<(payload: unknown) => void>;
29
+ }
30
+
31
+ let shared: SharedOverlayState | undefined;
32
+
33
+ function ensureShared(): SharedOverlayState {
34
+ if (shared) return shared;
35
+ const renderers = new Set<() => void>();
36
+ const invalidators = new Set<(payload: unknown) => void>();
37
+ const scheduler = new RenderScheduler(
38
+ runEventBusAsRenderScheduler([...CHANNELS]),
39
+ () => {
40
+ // Fan-out: each registered overlay's render runs in its own
41
+ // try/catch so one failing component cannot break the others.
42
+ for (const render of renderers) {
43
+ try {
44
+ render();
45
+ } catch (error) {
46
+ logInternalError("shared-overlay-scheduler.render", error);
47
+ }
48
+ }
49
+ },
50
+ {
51
+ debounceMs: 75,
52
+ fallbackMs: 750,
53
+ events: [...CHANNELS],
54
+ onInvalidate: (payload) => {
55
+ for (const invalidate of invalidators) {
56
+ try {
57
+ invalidate(payload);
58
+ } catch (error) {
59
+ logInternalError("shared-overlay-scheduler.invalidate", error);
60
+ }
61
+ }
62
+ },
63
+ },
64
+ );
65
+ shared = { scheduler, renderers, invalidators };
66
+ return shared;
67
+ }
68
+
69
+ /**
70
+ * Register an overlay's render (+ optional onInvalidate) with the shared
71
+ * scheduler. Returns a handle that lets the overlay force a debounced
72
+ * fan-out (schedule) and unregister itself (dispose). The underlying shared
73
+ * scheduler is created lazily on first register and disposed when the last
74
+ * overlay disposes.
75
+ */
76
+ export function registerOverlayScheduler(render: () => void, onInvalidate?: (payload: unknown) => void): OverlaySchedulerHandle {
77
+ const state = ensureShared();
78
+ state.renderers.add(render);
79
+ if (onInvalidate) state.invalidators.add(onInvalidate);
80
+ return {
81
+ schedule: () => {
82
+ state.scheduler.schedule();
83
+ },
84
+ dispose: () => {
85
+ state.renderers.delete(render);
86
+ if (onInvalidate) state.invalidators.delete(onInvalidate);
87
+ // Ref-count: when the last overlay leaves, dispose the underlying
88
+ // scheduler (clears subscriptions + timers) and reset the module
89
+ // variable so a future register recreates a fresh instance.
90
+ if (state.renderers.size === 0) {
91
+ state.scheduler.dispose();
92
+ if (shared === state) shared = undefined;
93
+ }
94
+ },
95
+ };
96
+ }
@@ -12,8 +12,8 @@ import type { ManifestCache } from "../../runtime/manifest-cache.ts";
12
12
  import type { TeamRunManifest } from "../../state/types.ts";
13
13
  import { truncate } from "../../utils/visual.ts";
14
14
  import { requestRender, requestRenderTarget, setExtensionWidget } from "../pi-ui-compat.ts";
15
- import { RenderScheduler } from "../render-scheduler.ts";
16
- import { runEventBusAsRenderScheduler } from "../run-event-bus.ts";
15
+ import type { OverlaySchedulerHandle } from "../shared-overlay-scheduler.ts";
16
+ import { registerOverlayScheduler } from "../shared-overlay-scheduler.ts";
17
17
  import type { RunSnapshotCache } from "../snapshot-types.ts";
18
18
  import { spinnerBucket, spinnerFrame } from "../spinner.ts";
19
19
  import type { CrewTheme } from "../theme-adapter.ts";
@@ -64,6 +64,15 @@ const LEGACY_WIDGET_KEY = "pi-crew";
64
64
  const WIDGET_KEY = "pi-crew-active";
65
65
  const STATUS_KEY = "pi-crew";
66
66
 
67
+ /**
68
+ * C4 — short-TTL safety net for the buildSignature() result cache. Combined
69
+ * with invalidate-on-write (onInvalidate clears the cache on every real
70
+ * event), this prevents redundant O(runs×agents) signature recomputation
71
+ * during a burst of host-driven renders within one state snapshot while
72
+ * ensuring the signature is always recomputed after genuine data changes.
73
+ */
74
+ const SIGNATURE_CACHE_TTL_MS = 100;
75
+
67
76
  // ── Terminal resize handling (T-2) ────────────────────────────────────
68
77
  // On a terminal resize the widget's cached render width goes stale until the
69
78
  // next invalidate; a mid-run resize could briefly paint a frame at the old
@@ -104,13 +113,16 @@ class CrewWidgetComponent implements WidgetComponent {
104
113
  private readonly model: CrewWidgetModel;
105
114
  private theme: CrewTheme;
106
115
  private cacheSignature = "";
116
+ /** C4 — invalidate-on-write cache for the buildSignature() result. */
117
+ private cachedBuildSignature = "";
118
+ private cachedBuildSignatureAt = 0;
107
119
  private cachedWidth = 0;
108
120
  private cachedLines: string[] = [];
109
121
  private cachedBaseLines: string[] = [];
110
122
  private cachedTheme: CrewTheme;
111
123
  private readonly tui: unknown;
112
124
  private readonly unsubscribeTheme: () => void;
113
- private readonly renderScheduler: RenderScheduler;
125
+ private readonly schedulerHandle: OverlaySchedulerHandle;
114
126
 
115
127
  constructor(model: CrewWidgetModel, themeLike: unknown, tui?: unknown) {
116
128
  this.model = model;
@@ -130,13 +142,18 @@ class CrewWidgetComponent implements WidgetComponent {
130
142
  // subscribing independently a single event triggered up to 9 callbacks
131
143
  // and ~150 invalidates/sec under load. The scheduler collapses bursts
132
144
  // into one debounced invalidate.
133
- this.renderScheduler = new RenderScheduler(
134
- runEventBusAsRenderScheduler(["run:state", "worker:lifecycle", "ui:invalidate"]),
145
+ this.schedulerHandle = registerOverlayScheduler(
135
146
  () => this.invalidate(),
136
- {
137
- debounceMs: 75,
138
- fallbackMs: 750,
139
- events: ["run:state", "worker:lifecycle", "ui:invalidate"],
147
+ () => {
148
+ // C4 invalidate-on-write: drop the cached buildSignature()
149
+ // result immediately when a real event arrives (run:state /
150
+ // worker:lifecycle / ui:invalidate). The prior blind-TTL
151
+ // attempt (reverted in 619a0cd) held a stale signature
152
+ // within the window and skipped re-render on genuine state
153
+ // changes. Clearing here + in invalidate() guarantees the
154
+ // next render tick always recomputes from fresh data.
155
+ this.cachedBuildSignature = "";
156
+ this.cachedBuildSignatureAt = 0;
140
157
  },
141
158
  );
142
159
  }
@@ -190,6 +207,8 @@ class CrewWidgetComponent implements WidgetComponent {
190
207
  this.cacheSignature = "";
191
208
  this.cachedBaseLines = [];
192
209
  this.cachedLines = [];
210
+ this.cachedBuildSignature = "";
211
+ this.cachedBuildSignatureAt = 0;
193
212
  }
194
213
 
195
214
  /** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
@@ -199,13 +218,28 @@ class CrewWidgetComponent implements WidgetComponent {
199
218
 
200
219
  dispose(): void {
201
220
  this.unsubscribeTheme();
202
- this.renderScheduler.dispose();
221
+ this.schedulerHandle.dispose();
203
222
  if (activeResizeTarget === this) activeResizeTarget = undefined;
204
223
  }
205
224
 
206
225
  render(width: number): string[] {
207
226
  const runs = activeWidgetRuns(this.model.cwd, this.model.manifestCache, this.model.snapshotCache, this.model.preloadManifests);
208
- const signature = `${this.buildSignature(runs)}:${this.model.notificationCount ?? 0}`;
227
+ // C4: invalidate-on-write signature cache. buildSignature() is
228
+ // O(runs×agents) string work called on every ~160ms host-driven render
229
+ // tick. Within one state snapshot (no event arrived → cache not cleared
230
+ // by onInvalidate) the result is identical, so we reuse the cached value
231
+ // to skip redundant recomputation. A short TTL acts as a safety net for
232
+ // the unlikely case where a real event is missed.
233
+ const now = Date.now();
234
+ let sigBase: string;
235
+ if (this.cachedBuildSignature !== "" && now - this.cachedBuildSignatureAt < SIGNATURE_CACHE_TTL_MS) {
236
+ sigBase = this.cachedBuildSignature;
237
+ } else {
238
+ sigBase = this.buildSignature(runs);
239
+ this.cachedBuildSignature = sigBase;
240
+ this.cachedBuildSignatureAt = now;
241
+ }
242
+ const signature = `${sigBase}:${this.model.notificationCount ?? 0}`;
209
243
  const runningGlyph = spinnerFrame("widget-header");
210
244
 
211
245
  if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {