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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 attention = terminal && meta.status !== "succeeded";
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;
@@ -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
- mainListTimer?: ReturnType<typeof setInterval>;
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 = 1000;
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 rows = listRows();
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 / MAIN_LIST_TICK_MS) % RUNNING_DOT_FRAMES.length;
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
- if (detailTimer) clearInterval(detailTimer);
708
- detailTimer = undefined;
712
+ detailScheduler.cancel();
709
713
  }
710
714
 
711
715
  function startDetailTimer(): void {
712
- stopDetailTimer();
713
- detailTimer = setInterval(() => {
714
- if (!detailId || mode !== "detail") return;
715
- detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
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
- stopDetailTimer();
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
- stopDetailTimer();
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;