pi-better-subagents 0.1.13 → 0.1.15
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.
- package/health-observation.ts +32 -29
- package/index.ts +14 -5
- package/package.json +1 -1
- package/registry.ts +9 -0
- package/shared-navigator.ts +33 -32
- package/shared-render-scheduler.ts +47 -0
- package/shared-stall-detector.ts +69 -0
- package/widget.mjs +1 -1
package/health-observation.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { DEFAULT_MAX_READ_BYTES, readAppendedLines, type LogCursor } from "./log
|
|
|
15
15
|
import { logPathFor } from "./registry.ts";
|
|
16
16
|
import type { RunStatus } from "./registry.ts";
|
|
17
17
|
import { loadConfig, type SubagentConfig } from "./config.ts";
|
|
18
|
+
import { observeStall } from "./shared-stall-detector.ts";
|
|
18
19
|
|
|
19
20
|
// ---- thresholds -----------------------------------------------------------
|
|
20
21
|
|
|
@@ -42,6 +43,13 @@ function positiveMs(n: unknown, fallback: number): number {
|
|
|
42
43
|
return Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function envDuration(name: string): number | undefined {
|
|
47
|
+
const raw = process.env[name];
|
|
48
|
+
if (!raw || raw.trim() === "") return undefined;
|
|
49
|
+
const value = Number(raw);
|
|
50
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
/** Merge partial thresholds / config keys onto defaults. */
|
|
46
54
|
export function resolveHealthThresholds(
|
|
47
55
|
partial?: Partial<HealthThresholds> | Pick<
|
|
@@ -63,7 +71,11 @@ export function resolveHealthThresholds(
|
|
|
63
71
|
|
|
64
72
|
/** Load thresholds from extension config.json (best-effort). */
|
|
65
73
|
export function loadHealthThresholdsFromConfig(config: SubagentConfig = loadConfig()): HealthThresholds {
|
|
66
|
-
return resolveHealthThresholds(
|
|
74
|
+
return resolveHealthThresholds({
|
|
75
|
+
...config,
|
|
76
|
+
healthQuietMs: config.healthQuietMs ?? envDuration("PI_BETTER_STALL_QUIET_MS"),
|
|
77
|
+
healthStaleMs: config.healthStaleMs ?? envDuration("PI_BETTER_STALL_MS"),
|
|
78
|
+
});
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
// ---- event facts ----------------------------------------------------------
|
|
@@ -703,37 +715,28 @@ export function observeRunHealth(input: ObserveRunHealthInput): HealthObservatio
|
|
|
703
715
|
|| modelState === "retrying"
|
|
704
716
|
|| modelState === "error";
|
|
705
717
|
|
|
706
|
-
|
|
707
|
-
if (
|
|
708
|
-
input.status === "failed"
|
|
718
|
+
const terminal = input.status === "failed"
|
|
709
719
|
|| input.status === "completed"
|
|
710
720
|
|| input.status === "killed"
|
|
711
721
|
|| input.status === "lost"
|
|
712
|
-
|| input.status === "exited"
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
} else if (meaningfulAgeMs >= thresholds.staleMs) {
|
|
731
|
-
activity = "stale";
|
|
732
|
-
} else if (meaningfulAgeMs >= thresholds.quietMs) {
|
|
733
|
-
activity = "quiet";
|
|
734
|
-
} else {
|
|
735
|
-
activity = "healthy";
|
|
736
|
-
}
|
|
722
|
+
|| input.status === "exited";
|
|
723
|
+
const stall = observeStall({
|
|
724
|
+
now,
|
|
725
|
+
lastProgressAt: facts.lastMeaningfulAt,
|
|
726
|
+
startedAt: input.startedAt,
|
|
727
|
+
// Terminal work and explicit model/tool/compaction phases can be quiet,
|
|
728
|
+
// but neither is an unexplained stalled worker.
|
|
729
|
+
exempt: terminal || explainedByPhase,
|
|
730
|
+
thresholds: { quietMs: thresholds.quietMs, stallMs: thresholds.staleMs },
|
|
731
|
+
});
|
|
732
|
+
const activity: ActivityHealth = stall.state === "stalled"
|
|
733
|
+
? "stale"
|
|
734
|
+
: stall.state === "quiet"
|
|
735
|
+
? "quiet"
|
|
736
|
+
// Legacy metadata without any timestamp remains conservative.
|
|
737
|
+
: stall.state === "unknown" && !terminal && !explainedByPhase
|
|
738
|
+
? "stale"
|
|
739
|
+
: "healthy";
|
|
737
740
|
|
|
738
741
|
const process: ProcessObservation = {
|
|
739
742
|
liveness: processLiveness(input.status, input.process?.supervised),
|
package/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
writeMeta,
|
|
48
48
|
readMeta,
|
|
49
49
|
listMetas,
|
|
50
|
+
onMetaChanged,
|
|
50
51
|
effectiveStatus,
|
|
51
52
|
ownedByThisParent,
|
|
52
53
|
navigatorVisibleRuns,
|
|
@@ -287,7 +288,7 @@ function spendFor(id: string, now: number): { usage: Usage; tool: string | null
|
|
|
287
288
|
|
|
288
289
|
/**
|
|
289
290
|
* Observe health for a widget/navigator row. Best-effort; never throws into the tick.
|
|
290
|
-
* Full log parse is gated by size/mtime so
|
|
291
|
+
* Full log parse is gated by size/mtime so event/detail refresh does not re-read and
|
|
291
292
|
* reparse every complete log when nothing changed (#67).
|
|
292
293
|
*
|
|
293
294
|
* When `displayStatus` is omitted, uses durable `meta.status`.
|
|
@@ -386,7 +387,7 @@ function stopTicker(): void {
|
|
|
386
387
|
// Reconciliation never kills anything; it only writes truth. The ticker
|
|
387
388
|
// exists only while current-parent running/orphaned work needs monitoring.
|
|
388
389
|
|
|
389
|
-
/** How often supervision is reconciled. Independent of
|
|
390
|
+
/** How often supervision is reconciled. Independent of TUI render scheduling. */
|
|
390
391
|
const HEALTH_TICK_MS = 15_000;
|
|
391
392
|
let healthTicker: ReturnType<typeof setInterval> | undefined;
|
|
392
393
|
/** ExtensionAPI retained so health transitions can deliver coordinator follow-ups (#65). */
|
|
@@ -710,7 +711,7 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
710
711
|
// One registry scan per rebuild: both the start times and the rows below are
|
|
711
712
|
// built from this snapshot.
|
|
712
713
|
const visible = sessionVisibleNavigatorRuns(now);
|
|
713
|
-
const
|
|
714
|
+
const metaById = new Map(visible.map((m) => [m.id, m]));
|
|
714
715
|
return navigatorRows(visible, now).map((row) => {
|
|
715
716
|
const bits = [];
|
|
716
717
|
if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
|
|
@@ -730,7 +731,14 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
730
731
|
elapsed: row.elapsed,
|
|
731
732
|
primary: bits.join(" · ") || "subagent run",
|
|
732
733
|
facts: row.healthFacts,
|
|
733
|
-
sortStartedAt:
|
|
734
|
+
sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
|
|
735
|
+
expiresAt: (() => {
|
|
736
|
+
const meta = metaById.get(row.id);
|
|
737
|
+
const endedAt = meta?.endedAt ?? meta?.lostAt;
|
|
738
|
+
return meta && isTerminalNavigatorStatus(effectiveStatus(meta)) && typeof endedAt === "number"
|
|
739
|
+
? endedAt + TERMINAL_NAVIGATOR_RETENTION_MS
|
|
740
|
+
: undefined;
|
|
741
|
+
})(),
|
|
734
742
|
};
|
|
735
743
|
});
|
|
736
744
|
}
|
|
@@ -756,7 +764,7 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
756
764
|
statusTone: statusTone(detail.status),
|
|
757
765
|
subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
|
|
758
766
|
metadata,
|
|
759
|
-
// The shared navigator refreshes this provider
|
|
767
|
+
// The shared navigator refreshes this provider on a coarse deadline while the
|
|
760
768
|
// detail overlay is open. Read the selected logical tail rows each
|
|
761
769
|
// time so the evidence behaves like `tail -f`, not a single parsed
|
|
762
770
|
// activity/result snapshot.
|
|
@@ -779,6 +787,7 @@ function ensureSubagentProvider(): void {
|
|
|
779
787
|
const outcome = navigatorCloseRun(id) as { action: string; id: string; status?: string };
|
|
780
788
|
return { ...outcome, providerId: "subagents" };
|
|
781
789
|
},
|
|
790
|
+
onVisibleChanged: onMetaChanged,
|
|
782
791
|
};
|
|
783
792
|
unregisterSubagentProvider = registerBackgroundWorkProvider(provider);
|
|
784
793
|
}
|
package/package.json
CHANGED
package/registry.ts
CHANGED
|
@@ -148,6 +148,7 @@ function metaPathFor(id: string): string {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
let seq = 0;
|
|
151
|
+
const metaChangedListeners = new Set<() => void>();
|
|
151
152
|
/** Monotonic, readable, collision-free run id: `sa_<base36-time>_<seq>`. */
|
|
152
153
|
export function nextRunId(): string {
|
|
153
154
|
seq += 1;
|
|
@@ -157,6 +158,14 @@ export function nextRunId(): string {
|
|
|
157
158
|
export function writeMeta(meta: RunMeta): void {
|
|
158
159
|
mkdirSync(runDir(meta.id), { recursive: true });
|
|
159
160
|
writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
|
|
161
|
+
for (const listener of metaChangedListeners) {
|
|
162
|
+
try { listener(); } catch { /* best effort */ }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function onMetaChanged(listener: () => void): () => void {
|
|
167
|
+
metaChangedListeners.add(listener);
|
|
168
|
+
return () => metaChangedListeners.delete(listener);
|
|
160
169
|
}
|
|
161
170
|
|
|
162
171
|
export function readMeta(id: string): RunMeta | undefined {
|
package/shared-navigator.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { createRenderScheduler, type RenderScheduler } from "./shared-render-scheduler.ts";
|
|
3
4
|
|
|
4
5
|
export default function navigatorExtension(): void {
|
|
5
6
|
// Internal shared package. Pi may scan its symlink in the extension directory
|
|
@@ -25,6 +26,7 @@ export type BackgroundWorkRow = {
|
|
|
25
26
|
secondary?: string;
|
|
26
27
|
facts?: string[];
|
|
27
28
|
sortStartedAt: number;
|
|
29
|
+
expiresAt?: number;
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
export type BackgroundWorkDetail = {
|
|
@@ -79,7 +81,7 @@ type NavigatorState = {
|
|
|
79
81
|
mainListFocused?: boolean;
|
|
80
82
|
mainListCloseArm?: { id: string; armedAt: number };
|
|
81
83
|
mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
|
|
82
|
-
|
|
84
|
+
mainListDeadlineScheduler?: RenderScheduler;
|
|
83
85
|
detailOverlayRows?: number;
|
|
84
86
|
dispose?: () => void;
|
|
85
87
|
};
|
|
@@ -91,15 +93,10 @@ const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
|
|
|
91
93
|
export const NAVIGATOR_STATUS_KEY = "background-work-nav";
|
|
92
94
|
export const CLOSE_CONFIRM_STATUS_KEY = "background-work-close";
|
|
93
95
|
export const MAIN_LIST_WIDGET_KEY = "background-work-list";
|
|
94
|
-
export const DETAIL_TICK_MS =
|
|
96
|
+
export const DETAIL_TICK_MS = 10_000;
|
|
95
97
|
export const CLOSE_ARM_MS = 3000;
|
|
96
98
|
export const DEFAULT_LOG_TAIL_ROWS = 10;
|
|
97
99
|
export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
|
|
98
|
-
// Row rebuild cadence. Each tick asks every provider for rows, which stats and
|
|
99
|
-
// parses run logs, so this is a per-provider I/O cadence and not a paint rate:
|
|
100
|
-
// the widget repaints from render() whenever the TUI asks. 1 Hz matches
|
|
101
|
-
// DETAIL_TICK_MS and the elapsed/spinner resolution the rows can actually show.
|
|
102
|
-
const MAIN_LIST_TICK_MS = 1000;
|
|
103
100
|
const MAIN_LIST_FALLBACK_WIDTH = 100;
|
|
104
101
|
const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
|
|
105
102
|
const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
|
|
@@ -173,9 +170,10 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
173
170
|
try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
174
171
|
s.mainListWidgetInstalled = false;
|
|
175
172
|
s.mainListRequestRender = undefined;
|
|
173
|
+
s.mainListDeadlineScheduler?.dispose();
|
|
174
|
+
s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
|
|
176
175
|
installNavigatorEditor(ctx.ui as any, deps);
|
|
177
176
|
s.lastHint = undefined;
|
|
178
|
-
startMainListWidget(ctx);
|
|
179
177
|
refreshBackgroundWorkNavigator(ctx);
|
|
180
178
|
refreshMainListWidget();
|
|
181
179
|
}
|
|
@@ -193,6 +191,8 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
193
191
|
}
|
|
194
192
|
s.lastHint = undefined;
|
|
195
193
|
s.lastMainListLines = undefined;
|
|
194
|
+
s.mainListDeadlineScheduler?.dispose();
|
|
195
|
+
s.mainListDeadlineScheduler = undefined;
|
|
196
196
|
s.mainListWidgetInstalled = false;
|
|
197
197
|
s.mainListRequestRender = undefined;
|
|
198
198
|
s.detailOverlayRows = undefined;
|
|
@@ -226,17 +226,7 @@ function visibleCount(): number {
|
|
|
226
226
|
}, 0);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
function startMainListWidget(ctx: ExtensionContext): void {
|
|
230
|
-
const s = state();
|
|
231
|
-
if (s.mainListTimer || !isNavigatorUiAvailable(ctx)) return;
|
|
232
|
-
s.mainListTimer = setInterval(() => refreshMainListWidget(), MAIN_LIST_TICK_MS);
|
|
233
|
-
s.mainListTimer.unref?.();
|
|
234
|
-
}
|
|
235
|
-
|
|
236
229
|
function stopMainListWidget(): void {
|
|
237
|
-
const s = state();
|
|
238
|
-
if (s.mainListTimer) clearInterval(s.mainListTimer);
|
|
239
|
-
s.mainListTimer = undefined;
|
|
240
230
|
clearMainListCloseArm();
|
|
241
231
|
}
|
|
242
232
|
|
|
@@ -254,7 +244,16 @@ function refreshMainListWidget(): void {
|
|
|
254
244
|
const ctx = s.uiCtx;
|
|
255
245
|
const deps = s.deps;
|
|
256
246
|
if (!ctx || !deps || !isNavigatorUiAvailable(ctx)) return;
|
|
257
|
-
const
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const rows = listRows(now);
|
|
249
|
+
s.mainListDeadlineScheduler?.cancel();
|
|
250
|
+
const nextExpiry = rows.reduce<number | undefined>((next, row) => {
|
|
251
|
+
if (row.expiresAt === undefined || row.expiresAt <= now) return next;
|
|
252
|
+
return next === undefined ? row.expiresAt : Math.min(next, row.expiresAt);
|
|
253
|
+
}, undefined);
|
|
254
|
+
if (nextExpiry !== undefined) {
|
|
255
|
+
s.mainListDeadlineScheduler?.schedule(nextExpiry - now);
|
|
256
|
+
}
|
|
258
257
|
syncMainListSelection(rows);
|
|
259
258
|
s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
|
|
260
259
|
selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
|
|
@@ -462,7 +461,7 @@ function statusGlyph(row: InternalRow): string {
|
|
|
462
461
|
|
|
463
462
|
function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; color: string } {
|
|
464
463
|
if (row.statusTone !== "running") return { glyph: statusGlyph(row), color: toneColor(row.statusTone, row.status) };
|
|
465
|
-
const frame = Math.floor(now /
|
|
464
|
+
const frame = Math.floor(now / DETAIL_TICK_MS) % RUNNING_DOT_FRAMES.length;
|
|
466
465
|
return { glyph: RUNNING_DOT_GLYPH, color: RUNNING_DOT_FRAMES[frame]! };
|
|
467
466
|
}
|
|
468
467
|
|
|
@@ -676,7 +675,6 @@ function createOverlayComponent(
|
|
|
676
675
|
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
677
676
|
if (idx >= 0) overlayState.selected = idx;
|
|
678
677
|
}
|
|
679
|
-
let detailTimer: ReturnType<typeof setInterval> | undefined;
|
|
680
678
|
let closeArm: { id: string; armedAt: number } | undefined;
|
|
681
679
|
let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
|
|
682
680
|
let closed = false;
|
|
@@ -687,6 +685,13 @@ function createOverlayComponent(
|
|
|
687
685
|
try { tui.requestRender(); } catch { /* ignore */ }
|
|
688
686
|
}
|
|
689
687
|
|
|
688
|
+
const detailScheduler = createRenderScheduler(() => {
|
|
689
|
+
if (!detailId || mode !== "detail" || closed) return;
|
|
690
|
+
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
691
|
+
requestRender();
|
|
692
|
+
startDetailTimer();
|
|
693
|
+
});
|
|
694
|
+
|
|
690
695
|
function refreshRows(): void {
|
|
691
696
|
const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
|
|
692
697
|
overlayState.rows = listRows();
|
|
@@ -704,18 +709,14 @@ function createOverlayComponent(
|
|
|
704
709
|
}
|
|
705
710
|
|
|
706
711
|
function stopDetailTimer(): void {
|
|
707
|
-
|
|
708
|
-
detailTimer = undefined;
|
|
712
|
+
detailScheduler.cancel();
|
|
709
713
|
}
|
|
710
714
|
|
|
711
715
|
function startDetailTimer(): void {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
requestRender();
|
|
717
|
-
}, DETAIL_TICK_MS);
|
|
718
|
-
detailTimer.unref?.();
|
|
716
|
+
detailScheduler.cancel();
|
|
717
|
+
if (detail?.status === "running" || detail?.status === "orphaned") {
|
|
718
|
+
detailScheduler.schedule(DETAIL_TICK_MS);
|
|
719
|
+
}
|
|
719
720
|
}
|
|
720
721
|
|
|
721
722
|
function selectedRow(): InternalRow | undefined {
|
|
@@ -759,7 +760,7 @@ function createOverlayComponent(
|
|
|
759
760
|
if (closed) return;
|
|
760
761
|
closed = true;
|
|
761
762
|
clearCloseArm();
|
|
762
|
-
|
|
763
|
+
detailScheduler.dispose();
|
|
763
764
|
try { done(null); } catch { /* ignore */ }
|
|
764
765
|
}
|
|
765
766
|
|
|
@@ -842,7 +843,7 @@ function createOverlayComponent(
|
|
|
842
843
|
invalidate() {},
|
|
843
844
|
dispose() {
|
|
844
845
|
clearCloseArm();
|
|
845
|
-
|
|
846
|
+
detailScheduler.dispose();
|
|
846
847
|
},
|
|
847
848
|
};
|
|
848
849
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Generated from packages/render-scheduler/index.ts. Do not edit directly.
|
|
2
|
+
export interface RenderScheduler {
|
|
3
|
+
request(): void;
|
|
4
|
+
schedule(delayMs: number): void;
|
|
5
|
+
cancel(): void;
|
|
6
|
+
dispose(): void;
|
|
7
|
+
pending(): boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Schedule TUI paints as replaceable one-shot deadlines. Callers reschedule
|
|
12
|
+
* only while visible state still changes; static and hidden UI stays idle.
|
|
13
|
+
*/
|
|
14
|
+
export function createRenderScheduler(requestRender: () => void): RenderScheduler {
|
|
15
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
16
|
+
let disposed = false;
|
|
17
|
+
|
|
18
|
+
const cancel = (): void => {
|
|
19
|
+
if (timer) clearTimeout(timer);
|
|
20
|
+
timer = undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
request() {
|
|
25
|
+
if (disposed) return;
|
|
26
|
+
cancel();
|
|
27
|
+
requestRender();
|
|
28
|
+
},
|
|
29
|
+
schedule(delayMs: number) {
|
|
30
|
+
if (disposed) return;
|
|
31
|
+
cancel();
|
|
32
|
+
timer = setTimeout(() => {
|
|
33
|
+
timer = undefined;
|
|
34
|
+
if (!disposed) requestRender();
|
|
35
|
+
}, Math.max(0, delayMs));
|
|
36
|
+
timer.unref?.();
|
|
37
|
+
},
|
|
38
|
+
cancel,
|
|
39
|
+
dispose() {
|
|
40
|
+
disposed = true;
|
|
41
|
+
cancel();
|
|
42
|
+
},
|
|
43
|
+
pending() {
|
|
44
|
+
return timer !== undefined;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Generated from packages/stall-detector/index.ts. Do not edit directly.
|
|
2
|
+
export interface StallThresholds {
|
|
3
|
+
/** Age of observable progress before work becomes quiet. */
|
|
4
|
+
quietMs: number;
|
|
5
|
+
/** Age of observable progress before work becomes stalled. */
|
|
6
|
+
stallMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type StallState = "healthy" | "quiet" | "stalled" | "unknown";
|
|
10
|
+
|
|
11
|
+
export interface StallObservation {
|
|
12
|
+
state: StallState;
|
|
13
|
+
/** Most recent meaningful evidence, falling back to the start time. */
|
|
14
|
+
observedAt?: number;
|
|
15
|
+
ageMs?: number;
|
|
16
|
+
thresholds: StallThresholds;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ObserveStallInput {
|
|
20
|
+
now: number;
|
|
21
|
+
/** Explicit meaningful progress, never a timer tick or metadata write. */
|
|
22
|
+
lastProgressAt?: number;
|
|
23
|
+
/** Fallback anchor for work that has not produced progress yet. */
|
|
24
|
+
startedAt?: number;
|
|
25
|
+
/** Known active phases that explain a lack of observable progress. */
|
|
26
|
+
exempt?: boolean;
|
|
27
|
+
thresholds?: Partial<StallThresholds>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_STALL_THRESHOLDS: Readonly<StallThresholds> = Object.freeze({
|
|
31
|
+
quietMs: 60_000,
|
|
32
|
+
stallMs: 5 * 60_000,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function positiveMs(value: unknown, fallback: number): number {
|
|
36
|
+
const parsed = Number(value);
|
|
37
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize caller config and ensure quiet never exceeds the stall threshold. */
|
|
41
|
+
export function resolveStallThresholds(partial?: Partial<StallThresholds> | null): StallThresholds {
|
|
42
|
+
const quietMs = positiveMs(partial?.quietMs, DEFAULT_STALL_THRESHOLDS.quietMs);
|
|
43
|
+
const stallMs = positiveMs(partial?.stallMs, DEFAULT_STALL_THRESHOLDS.stallMs);
|
|
44
|
+
return { quietMs: Math.min(quietMs, stallMs), stallMs: Math.max(quietMs, stallMs) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Classify the age of observable progress. This is deliberately observational:
|
|
49
|
+
* it does not claim a live process is broken and never performs recovery.
|
|
50
|
+
*/
|
|
51
|
+
export function observeStall(input: ObserveStallInput): StallObservation {
|
|
52
|
+
const thresholds = resolveStallThresholds(input.thresholds);
|
|
53
|
+
const observedAt = input.lastProgressAt ?? input.startedAt;
|
|
54
|
+
if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) {
|
|
55
|
+
return { state: "unknown", thresholds };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ageMs = Math.max(0, input.now - observedAt);
|
|
59
|
+
if (input.exempt) {
|
|
60
|
+
return { state: ageMs >= thresholds.quietMs ? "quiet" : "healthy", observedAt, ageMs, thresholds };
|
|
61
|
+
}
|
|
62
|
+
if (ageMs >= thresholds.stallMs) {
|
|
63
|
+
return { state: "stalled", observedAt, ageMs, thresholds };
|
|
64
|
+
}
|
|
65
|
+
if (ageMs >= thresholds.quietMs) {
|
|
66
|
+
return { state: "quiet", observedAt, ageMs, thresholds };
|
|
67
|
+
}
|
|
68
|
+
return { state: "healthy", observedAt, ageMs, thresholds };
|
|
69
|
+
}
|
package/widget.mjs
CHANGED
|
@@ -216,7 +216,7 @@ export function isSpendCacheFresh(cached, now, logSize, ttlMs = SPEND_REFRESH_MS
|
|
|
216
216
|
/**
|
|
217
217
|
* Whether a cached health-log parse is still valid for the widget tick.
|
|
218
218
|
* Invalidates on log size or mtime change so growth/rewrite re-extracts, while
|
|
219
|
-
* unchanged logs skip
|
|
219
|
+
* unchanged logs skip synchronous full-log reparses across repeated renders.
|
|
220
220
|
*
|
|
221
221
|
* @param {{ logSize?: number, mtimeMs?: number }|null|undefined} cached
|
|
222
222
|
* @param {number} logSize
|