pi-better-background-tasks 0.1.14 → 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/README.md +1 -0
- package/package.json +1 -1
- package/src/goal-provider.ts +7 -1
- package/src/navigator-provider.ts +15 -1
- package/src/registry.ts +9 -0
- package/src/runtime.ts +13 -0
- package/src/shared-navigator.ts +33 -32
- package/src/shared-render-scheduler.ts +47 -0
- package/src/shared-stall-detector.ts +69 -0
- package/src/stall.ts +42 -0
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
|
|
|
16
16
|
- Watch commands until success, failure, or timeout.
|
|
17
17
|
- Keep task metadata and logs available across reloads.
|
|
18
18
|
- Show active work in Pi's background-work navigator.
|
|
19
|
+
- Flag running tasks with no observable output or completed poll as stalled.
|
|
19
20
|
|
|
20
21
|
## Install
|
|
21
22
|
|
package/package.json
CHANGED
package/src/goal-provider.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { listMetas } from "./registry.js";
|
|
3
3
|
import type { BackgroundTaskMeta } from "./types.js";
|
|
4
4
|
import { isTerminalStatus } from "./types.js";
|
|
5
|
+
import { backgroundTaskProgressAt, observeBackgroundTaskStall } from "./stall.js";
|
|
5
6
|
|
|
6
7
|
const GOAL_READY_EVENT = "pi-better-goal:ready";
|
|
7
8
|
const GOAL_REGISTER_PROVIDER_EVENT = "pi-better-goal:register-provider";
|
|
@@ -92,11 +93,14 @@ function getGoalActivityOrigin(ctx: ExtensionContext) {
|
|
|
92
93
|
function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkItem {
|
|
93
94
|
const active = meta.status === "running";
|
|
94
95
|
const terminal = isTerminalStatus(meta.status);
|
|
95
|
-
const
|
|
96
|
+
const stall = observeBackgroundTaskStall(meta);
|
|
97
|
+
const unhealthy = active && stall.state === "stalled";
|
|
98
|
+
const attention = (terminal && meta.status !== "succeeded") || unhealthy;
|
|
96
99
|
const item: GoalBackgroundWorkItem = {
|
|
97
100
|
id: meta.id,
|
|
98
101
|
status: meta.status,
|
|
99
102
|
active,
|
|
103
|
+
unhealthy,
|
|
100
104
|
terminal,
|
|
101
105
|
attention,
|
|
102
106
|
startedAt: meta.startedAt,
|
|
@@ -108,6 +112,8 @@ function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkI
|
|
|
108
112
|
argv: meta.argv,
|
|
109
113
|
logPath: meta.logPath,
|
|
110
114
|
spawnPid: meta.spawnPid,
|
|
115
|
+
lastProgressAt: backgroundTaskProgressAt(meta),
|
|
116
|
+
stall: stall.state,
|
|
111
117
|
},
|
|
112
118
|
};
|
|
113
119
|
if (meta.name !== undefined) item.label = meta.name;
|
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
12
|
import { readLog } from "./logs.js";
|
|
13
|
-
import { listMetas, readMeta, writeMeta } from "./registry.js";
|
|
13
|
+
import { listMetas, onMetaChanged, readMeta, writeMeta } from "./registry.js";
|
|
14
14
|
import { stopTask } from "./runtime.js";
|
|
15
|
+
import { observeBackgroundTaskStall } from "./stall.js";
|
|
15
16
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
|
|
16
17
|
|
|
17
18
|
let unregister: (() => void) | undefined;
|
|
@@ -62,6 +63,7 @@ const provider: BackgroundWorkProvider = {
|
|
|
62
63
|
writeMeta(meta);
|
|
63
64
|
return { action: "dismissed", providerId: "background-tasks", id, status: meta.status };
|
|
64
65
|
},
|
|
66
|
+
onVisibleChanged: onMetaChanged,
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
function visibleMetas(now = Date.now()): BackgroundTaskMeta[] {
|
|
@@ -113,6 +115,9 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
|
|
|
113
115
|
secondary: secondaryLabel(meta),
|
|
114
116
|
facts: factsForMeta(meta, now),
|
|
115
117
|
sortStartedAt: meta.startedAt,
|
|
118
|
+
expiresAt: meta.status === "running" || meta.endedAt === undefined
|
|
119
|
+
? undefined
|
|
120
|
+
: meta.endedAt + TERMINAL_NAVIGATOR_RETENTION_MS,
|
|
116
121
|
};
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -131,6 +136,10 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
|
|
|
131
136
|
];
|
|
132
137
|
if (meta.deadlineAt) metadata.push({ label: "deadline", value: formatDuration(meta.deadlineAt - now) });
|
|
133
138
|
if (meta.lastCheckedAt) metadata.push({ label: "checked", value: `${formatDuration(now - meta.lastCheckedAt)} ago` });
|
|
139
|
+
if (meta.status === "running") {
|
|
140
|
+
const stall = observeBackgroundTaskStall(meta, now);
|
|
141
|
+
if (stall.state !== "healthy") metadata.push({ label: "activity", value: stall.state });
|
|
142
|
+
}
|
|
134
143
|
if (meta.lastExitCode !== undefined) metadata.push({ label: "exit", value: String(meta.lastExitCode) });
|
|
135
144
|
if (meta.error) metadata.push({ label: "error", value: meta.error });
|
|
136
145
|
if (meta.logDiscardedBytes) {
|
|
@@ -194,6 +203,11 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
|
|
|
194
203
|
|
|
195
204
|
function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
|
|
196
205
|
const facts: string[] = [];
|
|
206
|
+
if (meta.status === "running") {
|
|
207
|
+
const stall = observeBackgroundTaskStall(meta, now);
|
|
208
|
+
if (stall.state === "stalled") facts.push("stalled");
|
|
209
|
+
else if (stall.state === "quiet") facts.push("quiet");
|
|
210
|
+
}
|
|
197
211
|
if (meta.kind === "command_watch" && meta.intervalMs) facts.push(`every ${formatDuration(meta.intervalMs)}`);
|
|
198
212
|
if (meta.deadlineAt && meta.status === "running") facts.push(`${formatDuration(meta.deadlineAt - now)} left`);
|
|
199
213
|
if (meta.result && meta.status !== "running") {
|
package/src/registry.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { isTerminalStatus } from "./types.js";
|
|
|
6
6
|
|
|
7
7
|
let seq = 0;
|
|
8
8
|
const metaCache = new Map<string, BackgroundTaskMeta>();
|
|
9
|
+
const metaChangedListeners = new Set<() => void>();
|
|
9
10
|
|
|
10
11
|
export function baseDir(): string {
|
|
11
12
|
return join(tmpdir(), "pi-better-background-tasks");
|
|
@@ -40,6 +41,14 @@ export function writeMeta(meta: BackgroundTaskMeta): void {
|
|
|
40
41
|
ensureTaskDir(meta.id);
|
|
41
42
|
writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
|
|
42
43
|
metaCache.set(meta.id, meta);
|
|
44
|
+
for (const listener of metaChangedListeners) {
|
|
45
|
+
try { listener(); } catch { /* best effort */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function onMetaChanged(listener: () => void): () => void {
|
|
50
|
+
metaChangedListeners.add(listener);
|
|
51
|
+
return () => metaChangedListeners.delete(listener);
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export function readMeta(id: string): BackgroundTaskMeta | undefined {
|
package/src/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
1
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { appendLine, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
|
|
3
4
|
import { evaluateCondition } from "./conditions.js";
|
|
@@ -51,6 +52,7 @@ export function spawnTask(
|
|
|
51
52
|
kind: "process",
|
|
52
53
|
status: "running",
|
|
53
54
|
startedAt: now,
|
|
55
|
+
lastProgressAt: now,
|
|
54
56
|
deadlineAt: params.timeout_seconds ? now + params.timeout_seconds * 1000 : undefined,
|
|
55
57
|
logPath,
|
|
56
58
|
callback: params.callback,
|
|
@@ -103,6 +105,7 @@ export function startWatchTask(
|
|
|
103
105
|
kind: "command_watch",
|
|
104
106
|
status: "running",
|
|
105
107
|
startedAt: now,
|
|
108
|
+
lastProgressAt: now,
|
|
106
109
|
deadlineAt: timeoutSeconds ? now + timeoutSeconds * 1000 : undefined,
|
|
107
110
|
intervalMs: Math.max(1, params.interval_seconds ?? 30) * 1000,
|
|
108
111
|
logPath: logPathFor(id),
|
|
@@ -212,6 +215,7 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
|
|
|
212
215
|
if (!latest || latest.status !== "running") return;
|
|
213
216
|
enforceLogRetention(latest);
|
|
214
217
|
latest.lastCheckedAt = Date.now();
|
|
218
|
+
latest.lastProgressAt = latest.lastCheckedAt;
|
|
215
219
|
latest.lastExitCode = result.exitCode;
|
|
216
220
|
latest.lastSignal = result.signal;
|
|
217
221
|
latest.lastState = extractLastState(result);
|
|
@@ -371,6 +375,15 @@ function stopLogRetention(id: string): void {
|
|
|
371
375
|
}
|
|
372
376
|
|
|
373
377
|
function enforceLogRetention(meta: BackgroundTaskMeta): void {
|
|
378
|
+
try {
|
|
379
|
+
const mtimeMs = Math.trunc(statSync(meta.logPath).mtimeMs);
|
|
380
|
+
if (mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
|
|
381
|
+
meta.lastProgressAt = mtimeMs;
|
|
382
|
+
writeMeta(meta);
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
// Logs are optional progress evidence; retention still proceeds if absent.
|
|
386
|
+
}
|
|
374
387
|
const compacted = retainLogTail(meta.logPath, resolveMaxLogBytes(meta.maxLogBytes));
|
|
375
388
|
if (!compacted) return;
|
|
376
389
|
meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
|
package/src/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/src/stall.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { observeStall, type StallObservation, type StallThresholds } from "./shared-stall-detector.js";
|
|
4
|
+
import type { BackgroundTaskMeta } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function durationEnv(name: string): number | undefined {
|
|
7
|
+
const raw = process.env[name];
|
|
8
|
+
if (!raw || raw.trim() === "") return undefined;
|
|
9
|
+
const value = Number(raw);
|
|
10
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function backgroundTaskStallThresholds(): Partial<StallThresholds> {
|
|
14
|
+
return {
|
|
15
|
+
quietMs: durationEnv("PI_BETTER_STALL_QUIET_MS"),
|
|
16
|
+
stallMs: durationEnv("PI_BETTER_STALL_MS"),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Process logs are the only external progress evidence available for detached
|
|
22
|
+
* commands. Watchers instead advance after a completed poll command.
|
|
23
|
+
*/
|
|
24
|
+
export function backgroundTaskProgressAt(meta: BackgroundTaskMeta): number | undefined {
|
|
25
|
+
const recorded = Math.max(meta.lastProgressAt ?? 0, meta.lastCheckedAt ?? 0, meta.startedAt);
|
|
26
|
+
if (meta.kind !== "process") return recorded;
|
|
27
|
+
try {
|
|
28
|
+
return Math.max(recorded, Math.trunc(statSync(meta.logPath).mtimeMs));
|
|
29
|
+
} catch {
|
|
30
|
+
return recorded;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function observeBackgroundTaskStall(meta: BackgroundTaskMeta, now = Date.now()): StallObservation {
|
|
35
|
+
return observeStall({
|
|
36
|
+
now,
|
|
37
|
+
lastProgressAt: backgroundTaskProgressAt(meta),
|
|
38
|
+
startedAt: meta.startedAt,
|
|
39
|
+
exempt: meta.status !== "running",
|
|
40
|
+
thresholds: backgroundTaskStallThresholds(),
|
|
41
|
+
});
|
|
42
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -46,6 +46,8 @@ export interface BackgroundTaskMeta {
|
|
|
46
46
|
deadlineAt?: number;
|
|
47
47
|
intervalMs?: number;
|
|
48
48
|
lastCheckedAt?: number;
|
|
49
|
+
/** Latest command completion or observed process log output. */
|
|
50
|
+
lastProgressAt?: number;
|
|
49
51
|
lastExitCode?: number | null;
|
|
50
52
|
lastSignal?: NodeJS.Signals | null;
|
|
51
53
|
lastState?: unknown;
|