pi-better-background-tasks 0.1.14 → 0.1.16
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 +44 -17
- package/src/shared-callback-batcher.ts +320 -0
- package/src/shared-navigator.ts +124 -33
- package/src/shared-render-scheduler.ts +47 -0
- package/src/shared-stall-detector.ts +69 -0
- package/src/stall.ts +42 -0
- package/src/tools.ts +3 -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,8 +1,10 @@
|
|
|
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";
|
|
4
5
|
import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
|
|
5
6
|
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, writeMeta } from "./registry.js";
|
|
7
|
+
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
6
8
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, CommandSpec, Condition, TerminalResult } from "./types.js";
|
|
7
9
|
import { isTerminalStatus } from "./types.js";
|
|
8
10
|
|
|
@@ -51,6 +53,7 @@ export function spawnTask(
|
|
|
51
53
|
kind: "process",
|
|
52
54
|
status: "running",
|
|
53
55
|
startedAt: now,
|
|
56
|
+
lastProgressAt: now,
|
|
54
57
|
deadlineAt: params.timeout_seconds ? now + params.timeout_seconds * 1000 : undefined,
|
|
55
58
|
logPath,
|
|
56
59
|
callback: params.callback,
|
|
@@ -103,6 +106,7 @@ export function startWatchTask(
|
|
|
103
106
|
kind: "command_watch",
|
|
104
107
|
status: "running",
|
|
105
108
|
startedAt: now,
|
|
109
|
+
lastProgressAt: now,
|
|
106
110
|
deadlineAt: timeoutSeconds ? now + timeoutSeconds * 1000 : undefined,
|
|
107
111
|
intervalMs: Math.max(1, params.interval_seconds ?? 30) * 1000,
|
|
108
112
|
logPath: logPathFor(id),
|
|
@@ -212,6 +216,7 @@ async function pollWatch(pi: ExtensionAPI, id: string, getActiveSession?: Active
|
|
|
212
216
|
if (!latest || latest.status !== "running") return;
|
|
213
217
|
enforceLogRetention(latest);
|
|
214
218
|
latest.lastCheckedAt = Date.now();
|
|
219
|
+
latest.lastProgressAt = latest.lastCheckedAt;
|
|
215
220
|
latest.lastExitCode = result.exitCode;
|
|
216
221
|
latest.lastSignal = result.signal;
|
|
217
222
|
latest.lastState = extractLastState(result);
|
|
@@ -300,24 +305,37 @@ async function notifyTerminal(
|
|
|
300
305
|
if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
|
|
301
306
|
const latest = readMeta(meta.id) ?? meta;
|
|
302
307
|
if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
|
|
303
|
-
const suppressionReason = getCallbackSuppressionReason(latest, getActiveSession?.());
|
|
304
|
-
if (suppressionReason) {
|
|
305
|
-
latest.callbackSuppressedAt = Date.now();
|
|
306
|
-
latest.callbackSuppressedReason = suppressionReason;
|
|
307
|
-
writeMeta(latest);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
308
|
const label = latest.name ? `${latest.name} (${latest.id})` : latest.id;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
309
|
+
getCallbackBatcher(pi).enqueue({
|
|
310
|
+
source: "background-task",
|
|
311
|
+
id: latest.id,
|
|
312
|
+
label,
|
|
313
|
+
status: latest.status,
|
|
314
|
+
detailTool: "bg_task_status",
|
|
315
|
+
callback: true,
|
|
316
|
+
isDelivered: () => {
|
|
317
|
+
const current = readMeta(latest.id);
|
|
318
|
+
return current?.callbackSentAt !== undefined || current?.callbackSuppressedAt !== undefined;
|
|
319
|
+
},
|
|
320
|
+
getSuppressionReason: () => {
|
|
321
|
+
const current = readMeta(latest.id);
|
|
322
|
+
if (!current) return "background task metadata is unavailable";
|
|
323
|
+
return getCallbackSuppressionReason(current, getActiveSession?.());
|
|
324
|
+
},
|
|
325
|
+
onDelivered: (at) => {
|
|
326
|
+
const current = readMeta(latest.id);
|
|
327
|
+
if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
|
|
328
|
+
current.callbackSentAt = at;
|
|
329
|
+
writeMeta(current);
|
|
330
|
+
},
|
|
331
|
+
onSuppressed: (reason, at) => {
|
|
332
|
+
const current = readMeta(latest.id);
|
|
333
|
+
if (!current || current.callbackSentAt !== undefined || current.callbackSuppressedAt !== undefined) return;
|
|
334
|
+
current.callbackSuppressedAt = at;
|
|
335
|
+
current.callbackSuppressedReason = reason;
|
|
336
|
+
writeMeta(current);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
321
339
|
}
|
|
322
340
|
|
|
323
341
|
function getCallbackSuppressionReason(
|
|
@@ -371,6 +389,15 @@ function stopLogRetention(id: string): void {
|
|
|
371
389
|
}
|
|
372
390
|
|
|
373
391
|
function enforceLogRetention(meta: BackgroundTaskMeta): void {
|
|
392
|
+
try {
|
|
393
|
+
const mtimeMs = Math.trunc(statSync(meta.logPath).mtimeMs);
|
|
394
|
+
if (mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
|
|
395
|
+
meta.lastProgressAt = mtimeMs;
|
|
396
|
+
writeMeta(meta);
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
// Logs are optional progress evidence; retention still proceeds if absent.
|
|
400
|
+
}
|
|
374
401
|
const compacted = retainLogTail(meta.logPath, resolveMaxLogBytes(meta.maxLogBytes));
|
|
375
402
|
if (!compacted) return;
|
|
376
403
|
meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
+
export type CallbackSource = "subagent" | "background-task";
|
|
3
|
+
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
|
+
|
|
5
|
+
export interface CallbackBatchHost {
|
|
6
|
+
sendMessage(
|
|
7
|
+
message: { customType: string; content: string; display: boolean },
|
|
8
|
+
options: Record<string, unknown>,
|
|
9
|
+
): unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CallbackBatchEvent {
|
|
13
|
+
source: CallbackSource;
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
status: string;
|
|
17
|
+
detailTool: CallbackDetailTool;
|
|
18
|
+
callback?: boolean;
|
|
19
|
+
isDelivered?: () => boolean;
|
|
20
|
+
getSuppressionReason?: () => string | undefined;
|
|
21
|
+
onDelivered?: (at: number) => void;
|
|
22
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UrgentCallbackEvent {
|
|
26
|
+
source: CallbackSource;
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
status: "orphaned" | "lost" | string;
|
|
30
|
+
customType: string;
|
|
31
|
+
content: string;
|
|
32
|
+
isDelivered?: () => boolean;
|
|
33
|
+
getSuppressionReason?: () => string | undefined;
|
|
34
|
+
onDelivered?: (at: number) => void;
|
|
35
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CallbackBatcherOptions {
|
|
39
|
+
windowMs?: number;
|
|
40
|
+
retryMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CallbackBatcher {
|
|
44
|
+
enqueue(event: CallbackBatchEvent): boolean;
|
|
45
|
+
flush(): Promise<boolean>;
|
|
46
|
+
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
47
|
+
cancel(): void;
|
|
48
|
+
pendingCount(): number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PendingEvent {
|
|
52
|
+
event: CallbackBatchEvent;
|
|
53
|
+
sequence: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SharedCallbackBatcherState {
|
|
57
|
+
byHost: WeakMap<object, CallbackBatcher>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
61
|
+
const DEFAULT_WINDOW_MS = 100;
|
|
62
|
+
const DEFAULT_RETRY_MS = 1_000;
|
|
63
|
+
const MAX_LABEL_CHARS = 160;
|
|
64
|
+
const MAX_ID_CHARS = 200;
|
|
65
|
+
const MAX_STATUS_CHARS = 80;
|
|
66
|
+
|
|
67
|
+
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
68
|
+
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
69
|
+
|
|
70
|
+
export function resolveCallbackBatchWindowMs(
|
|
71
|
+
value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
|
|
72
|
+
): number {
|
|
73
|
+
if (value === undefined || value === null || value === "") return DEFAULT_WINDOW_MS;
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
if (!Number.isFinite(parsed)) return DEFAULT_WINDOW_MS;
|
|
76
|
+
return Math.max(0, Math.min(5_000, Math.floor(parsed)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
|
|
80
|
+
const count = events.length;
|
|
81
|
+
const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
|
|
82
|
+
const rows = events.map((event) => {
|
|
83
|
+
const source = boundedField(event.source, 40);
|
|
84
|
+
const id = boundedField(event.id, MAX_ID_CHARS);
|
|
85
|
+
const label = boundedField(event.label, MAX_LABEL_CHARS);
|
|
86
|
+
const status = boundedField(event.status, MAX_STATUS_CHARS);
|
|
87
|
+
const detail = event.detailTool === "bg_task_status"
|
|
88
|
+
? `bg_task_status id=${id}`
|
|
89
|
+
: `subagent_result id=${JSON.stringify(id)}`;
|
|
90
|
+
return `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${detail}`;
|
|
91
|
+
});
|
|
92
|
+
return [
|
|
93
|
+
heading,
|
|
94
|
+
...rows,
|
|
95
|
+
"Retrieve durable results/status with the listed tools. Full results and logs are intentionally omitted.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createCallbackBatcher(
|
|
100
|
+
host: CallbackBatchHost,
|
|
101
|
+
options: CallbackBatcherOptions = {},
|
|
102
|
+
): CallbackBatcher {
|
|
103
|
+
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
104
|
+
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
105
|
+
const pending = new Map<string, PendingEvent>();
|
|
106
|
+
const inFlight = new Set<string>();
|
|
107
|
+
const urgentInFlight = new Set<string>();
|
|
108
|
+
let sequence = 0;
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
let flushPromise: Promise<boolean> | undefined;
|
|
111
|
+
|
|
112
|
+
const cancelTimer = (): void => {
|
|
113
|
+
if (timer) clearTimeout(timer);
|
|
114
|
+
timer = undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const schedule = (delayMs: number): void => {
|
|
118
|
+
if (timer || pending.size === 0) return;
|
|
119
|
+
timer = setTimeout(() => {
|
|
120
|
+
timer = undefined;
|
|
121
|
+
void api.flush();
|
|
122
|
+
}, Math.max(0, delayMs));
|
|
123
|
+
timer.unref?.();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const enqueue = (event: CallbackBatchEvent): boolean => {
|
|
127
|
+
if (event.callback === false) return false;
|
|
128
|
+
const key = eventKey(event);
|
|
129
|
+
if (pending.has(key) || inFlight.has(key)) return false;
|
|
130
|
+
pending.set(key, { event, sequence: sequence++ });
|
|
131
|
+
schedule(windowMs);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const performFlush = async (): Promise<boolean> => {
|
|
136
|
+
cancelTimer();
|
|
137
|
+
const snapshot = [...pending.entries()]
|
|
138
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
139
|
+
pending.clear();
|
|
140
|
+
for (const [key] of snapshot) inFlight.add(key);
|
|
141
|
+
|
|
142
|
+
const deliverable: Array<[string, PendingEvent]> = [];
|
|
143
|
+
for (const item of snapshot) {
|
|
144
|
+
const [key, pendingEvent] = item;
|
|
145
|
+
const disposition = eventDisposition(pendingEvent.event);
|
|
146
|
+
if (disposition.kind === "delivered") {
|
|
147
|
+
inFlight.delete(key);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (disposition.kind === "suppressed") {
|
|
151
|
+
invokeSuppressed(pendingEvent.event, disposition.reason, Date.now());
|
|
152
|
+
inFlight.delete(key);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
deliverable.push(item);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (deliverable.length === 0) {
|
|
159
|
+
if (pending.size > 0) schedule(windowMs);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await host.sendMessage(
|
|
165
|
+
{
|
|
166
|
+
customType: "background-completion-batch",
|
|
167
|
+
content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
|
|
168
|
+
display: true,
|
|
169
|
+
},
|
|
170
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
for (const [key] of deliverable) inFlight.delete(key);
|
|
174
|
+
const retryItems = [...deliverable, ...pending.entries()]
|
|
175
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
176
|
+
pending.clear();
|
|
177
|
+
for (const [key, item] of retryItems) {
|
|
178
|
+
if (!pending.has(key)) pending.set(key, item);
|
|
179
|
+
}
|
|
180
|
+
schedule(retryMs);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const deliveredAt = Date.now();
|
|
185
|
+
for (const [key, item] of deliverable) {
|
|
186
|
+
invokeDelivered(item.event, deliveredAt);
|
|
187
|
+
inFlight.delete(key);
|
|
188
|
+
}
|
|
189
|
+
if (pending.size > 0) schedule(windowMs);
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const flush = (): Promise<boolean> => {
|
|
194
|
+
if (flushPromise) return flushPromise;
|
|
195
|
+
flushPromise = performFlush().finally(() => {
|
|
196
|
+
flushPromise = undefined;
|
|
197
|
+
});
|
|
198
|
+
return flushPromise;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
|
|
202
|
+
const key = eventKey(event);
|
|
203
|
+
if (urgentInFlight.has(key)) return false;
|
|
204
|
+
const disposition = eventDisposition(event);
|
|
205
|
+
if (disposition.kind === "delivered") return true;
|
|
206
|
+
if (disposition.kind === "suppressed") {
|
|
207
|
+
invokeSuppressed(event, disposition.reason, Date.now());
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
urgentInFlight.add(key);
|
|
212
|
+
try {
|
|
213
|
+
const handoff = host.sendMessage(
|
|
214
|
+
{ customType: event.customType, content: event.content, display: true },
|
|
215
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
216
|
+
);
|
|
217
|
+
if (isPromiseLike(handoff)) {
|
|
218
|
+
return Promise.resolve(handoff).then(
|
|
219
|
+
() => {
|
|
220
|
+
invokeDelivered(event, Date.now());
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
() => false,
|
|
224
|
+
).finally(() => urgentInFlight.delete(key));
|
|
225
|
+
}
|
|
226
|
+
invokeDelivered(event, Date.now());
|
|
227
|
+
urgentInFlight.delete(key);
|
|
228
|
+
return true;
|
|
229
|
+
} catch {
|
|
230
|
+
urgentInFlight.delete(key);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const api: CallbackBatcher = {
|
|
236
|
+
enqueue,
|
|
237
|
+
flush,
|
|
238
|
+
deliverUrgent,
|
|
239
|
+
cancel() {
|
|
240
|
+
cancelTimer();
|
|
241
|
+
pending.clear();
|
|
242
|
+
},
|
|
243
|
+
pendingCount() {
|
|
244
|
+
return pending.size;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
return api;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function getCallbackBatcher(
|
|
251
|
+
host: CallbackBatchHost,
|
|
252
|
+
options: CallbackBatcherOptions = {},
|
|
253
|
+
): CallbackBatcher {
|
|
254
|
+
const state = globalState();
|
|
255
|
+
const key = host as object;
|
|
256
|
+
const existing = state.byHost.get(key);
|
|
257
|
+
if (existing) return existing;
|
|
258
|
+
const created = createCallbackBatcher(host, options);
|
|
259
|
+
state.byHost.set(key, created);
|
|
260
|
+
return created;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
264
|
+
globalState().byHost.get(host as object)?.cancel();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function globalState(): SharedCallbackBatcherState {
|
|
268
|
+
const root = globalThis as typeof globalThis & {
|
|
269
|
+
[GLOBAL_STATE_KEY]?: SharedCallbackBatcherState;
|
|
270
|
+
};
|
|
271
|
+
root[GLOBAL_STATE_KEY] ??= { byHost: new WeakMap<object, CallbackBatcher>() };
|
|
272
|
+
return root[GLOBAL_STATE_KEY];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">): string {
|
|
276
|
+
return `${event.source}\u0000${event.id}\u0000${event.status}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function eventDisposition(
|
|
280
|
+
event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
|
|
281
|
+
): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
|
|
282
|
+
try {
|
|
283
|
+
if (event.isDelivered?.()) return { kind: "delivered" };
|
|
284
|
+
} catch {
|
|
285
|
+
return { kind: "suppressed", reason: "durable delivery state could not be verified" };
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const reason = event.getSuppressionReason?.();
|
|
289
|
+
return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
|
|
290
|
+
} catch {
|
|
291
|
+
return { kind: "suppressed", reason: "callback ownership could not be verified" };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function invokeDelivered(
|
|
296
|
+
event: Pick<CallbackBatchEvent, "onDelivered">,
|
|
297
|
+
at: number,
|
|
298
|
+
): void {
|
|
299
|
+
try { event.onDelivered?.(at); } catch { /* handoff already succeeded */ }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function invokeSuppressed(
|
|
303
|
+
event: Pick<CallbackBatchEvent, "onSuppressed">,
|
|
304
|
+
reason: string,
|
|
305
|
+
at: number,
|
|
306
|
+
): void {
|
|
307
|
+
try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function boundedField(value: unknown, maxChars: number): string {
|
|
311
|
+
const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
312
|
+
if (oneLine.length <= maxChars) return oneLine;
|
|
313
|
+
return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
317
|
+
return (typeof value === "object" || typeof value === "function")
|
|
318
|
+
&& value !== null
|
|
319
|
+
&& typeof (value as PromiseLike<unknown>).then === "function";
|
|
320
|
+
}
|
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 = {
|
|
@@ -52,6 +54,7 @@ export type BackgroundWorkProvider = {
|
|
|
52
54
|
label: string;
|
|
53
55
|
priority: number;
|
|
54
56
|
visibleCount(): number;
|
|
57
|
+
parentRow?(now: number): BackgroundWorkRow | null;
|
|
55
58
|
listRows(now: number): BackgroundWorkRow[];
|
|
56
59
|
detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
|
|
57
60
|
armCloseLabel(row: BackgroundWorkRow): string;
|
|
@@ -79,7 +82,7 @@ type NavigatorState = {
|
|
|
79
82
|
mainListFocused?: boolean;
|
|
80
83
|
mainListCloseArm?: { id: string; armedAt: number };
|
|
81
84
|
mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
|
|
82
|
-
|
|
85
|
+
mainListDeadlineScheduler?: RenderScheduler;
|
|
83
86
|
detailOverlayRows?: number;
|
|
84
87
|
dispose?: () => void;
|
|
85
88
|
};
|
|
@@ -91,15 +94,10 @@ const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
|
|
|
91
94
|
export const NAVIGATOR_STATUS_KEY = "background-work-nav";
|
|
92
95
|
export const CLOSE_CONFIRM_STATUS_KEY = "background-work-close";
|
|
93
96
|
export const MAIN_LIST_WIDGET_KEY = "background-work-list";
|
|
94
|
-
export const DETAIL_TICK_MS =
|
|
97
|
+
export const DETAIL_TICK_MS = 10_000;
|
|
95
98
|
export const CLOSE_ARM_MS = 3000;
|
|
96
99
|
export const DEFAULT_LOG_TAIL_ROWS = 10;
|
|
97
100
|
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
101
|
const MAIN_LIST_FALLBACK_WIDTH = 100;
|
|
104
102
|
const DETAIL_OVERLAY_HEADER_MARGIN_ROWS = 5;
|
|
105
103
|
const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
|
|
@@ -173,9 +171,10 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
173
171
|
try { (ctx.ui as any).setWidget?.(MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
|
|
174
172
|
s.mainListWidgetInstalled = false;
|
|
175
173
|
s.mainListRequestRender = undefined;
|
|
174
|
+
s.mainListDeadlineScheduler?.dispose();
|
|
175
|
+
s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
|
|
176
176
|
installNavigatorEditor(ctx.ui as any, deps);
|
|
177
177
|
s.lastHint = undefined;
|
|
178
|
-
startMainListWidget(ctx);
|
|
179
178
|
refreshBackgroundWorkNavigator(ctx);
|
|
180
179
|
refreshMainListWidget();
|
|
181
180
|
}
|
|
@@ -193,6 +192,8 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
193
192
|
}
|
|
194
193
|
s.lastHint = undefined;
|
|
195
194
|
s.lastMainListLines = undefined;
|
|
195
|
+
s.mainListDeadlineScheduler?.dispose();
|
|
196
|
+
s.mainListDeadlineScheduler = undefined;
|
|
196
197
|
s.mainListWidgetInstalled = false;
|
|
197
198
|
s.mainListRequestRender = undefined;
|
|
198
199
|
s.detailOverlayRows = undefined;
|
|
@@ -226,17 +227,7 @@ function visibleCount(): number {
|
|
|
226
227
|
}, 0);
|
|
227
228
|
}
|
|
228
229
|
|
|
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
230
|
function stopMainListWidget(): void {
|
|
237
|
-
const s = state();
|
|
238
|
-
if (s.mainListTimer) clearInterval(s.mainListTimer);
|
|
239
|
-
s.mainListTimer = undefined;
|
|
240
231
|
clearMainListCloseArm();
|
|
241
232
|
}
|
|
242
233
|
|
|
@@ -254,7 +245,16 @@ function refreshMainListWidget(): void {
|
|
|
254
245
|
const ctx = s.uiCtx;
|
|
255
246
|
const deps = s.deps;
|
|
256
247
|
if (!ctx || !deps || !isNavigatorUiAvailable(ctx)) return;
|
|
257
|
-
const
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const rows = listRows(now);
|
|
250
|
+
s.mainListDeadlineScheduler?.cancel();
|
|
251
|
+
const nextExpiry = rows.reduce<number | undefined>((next, row) => {
|
|
252
|
+
if (row.expiresAt === undefined || row.expiresAt <= now) return next;
|
|
253
|
+
return next === undefined ? row.expiresAt : Math.min(next, row.expiresAt);
|
|
254
|
+
}, undefined);
|
|
255
|
+
if (nextExpiry !== undefined) {
|
|
256
|
+
s.mainListDeadlineScheduler?.schedule(nextExpiry - now);
|
|
257
|
+
}
|
|
258
258
|
syncMainListSelection(rows);
|
|
259
259
|
s.lastMainListLines = rows.length ? buildMainListLines(rows, MAIN_LIST_FALLBACK_WIDTH, deps.truncate, themeFg(ctx), {
|
|
260
260
|
selectedId: s.mainListFocused ? s.mainListSelectedId : undefined,
|
|
@@ -412,6 +412,16 @@ function buildMainListLines(
|
|
|
412
412
|
const group = grouped.get(label)!;
|
|
413
413
|
if (i > 0) lines.push("");
|
|
414
414
|
lines.push(providerGroupLabel(label, fg));
|
|
415
|
+
const provider = state().providers.get(group[0]!.providerId);
|
|
416
|
+
let parentRow: BackgroundWorkRow | null = null;
|
|
417
|
+
try { parentRow = provider?.parentRow?.(Date.now()) ?? null; } catch { parentRow = null; }
|
|
418
|
+
if (parentRow) {
|
|
419
|
+
lines.push(formatMainListRow({
|
|
420
|
+
...parentRow,
|
|
421
|
+
navigatorId: rowKey(parentRow.providerId, parentRow.id),
|
|
422
|
+
providerLabel: label,
|
|
423
|
+
}, false, fg, width));
|
|
424
|
+
}
|
|
415
425
|
for (const row of group) {
|
|
416
426
|
const selected = options.focused && row.navigatorId === options.selectedId;
|
|
417
427
|
lines.push(formatMainListRow(row, selected === true, fg, width));
|
|
@@ -462,7 +472,7 @@ function statusGlyph(row: InternalRow): string {
|
|
|
462
472
|
|
|
463
473
|
function statusIndicator(row: InternalRow, now = Date.now()): { glyph: string; color: string } {
|
|
464
474
|
if (row.statusTone !== "running") return { glyph: statusGlyph(row), color: toneColor(row.statusTone, row.status) };
|
|
465
|
-
const frame = Math.floor(now /
|
|
475
|
+
const frame = Math.floor(now / DETAIL_TICK_MS) % RUNNING_DOT_FRAMES.length;
|
|
466
476
|
return { glyph: RUNNING_DOT_GLYPH, color: RUNNING_DOT_FRAMES[frame]! };
|
|
467
477
|
}
|
|
468
478
|
|
|
@@ -676,7 +686,6 @@ function createOverlayComponent(
|
|
|
676
686
|
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
677
687
|
if (idx >= 0) overlayState.selected = idx;
|
|
678
688
|
}
|
|
679
|
-
let detailTimer: ReturnType<typeof setInterval> | undefined;
|
|
680
689
|
let closeArm: { id: string; armedAt: number } | undefined;
|
|
681
690
|
let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
|
|
682
691
|
let closed = false;
|
|
@@ -687,6 +696,13 @@ function createOverlayComponent(
|
|
|
687
696
|
try { tui.requestRender(); } catch { /* ignore */ }
|
|
688
697
|
}
|
|
689
698
|
|
|
699
|
+
const detailScheduler = createRenderScheduler(() => {
|
|
700
|
+
if (!detailId || mode !== "detail" || closed) return;
|
|
701
|
+
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
702
|
+
requestRender();
|
|
703
|
+
startDetailTimer();
|
|
704
|
+
});
|
|
705
|
+
|
|
690
706
|
function refreshRows(): void {
|
|
691
707
|
const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
|
|
692
708
|
overlayState.rows = listRows();
|
|
@@ -704,18 +720,14 @@ function createOverlayComponent(
|
|
|
704
720
|
}
|
|
705
721
|
|
|
706
722
|
function stopDetailTimer(): void {
|
|
707
|
-
|
|
708
|
-
detailTimer = undefined;
|
|
723
|
+
detailScheduler.cancel();
|
|
709
724
|
}
|
|
710
725
|
|
|
711
726
|
function startDetailTimer(): void {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
requestRender();
|
|
717
|
-
}, DETAIL_TICK_MS);
|
|
718
|
-
detailTimer.unref?.();
|
|
727
|
+
detailScheduler.cancel();
|
|
728
|
+
if (detail?.status === "running" || detail?.status === "orphaned") {
|
|
729
|
+
detailScheduler.schedule(DETAIL_TICK_MS);
|
|
730
|
+
}
|
|
719
731
|
}
|
|
720
732
|
|
|
721
733
|
function selectedRow(): InternalRow | undefined {
|
|
@@ -759,7 +771,7 @@ function createOverlayComponent(
|
|
|
759
771
|
if (closed) return;
|
|
760
772
|
closed = true;
|
|
761
773
|
clearCloseArm();
|
|
762
|
-
|
|
774
|
+
detailScheduler.dispose();
|
|
763
775
|
try { done(null); } catch { /* ignore */ }
|
|
764
776
|
}
|
|
765
777
|
|
|
@@ -842,7 +854,7 @@ function createOverlayComponent(
|
|
|
842
854
|
invalidate() {},
|
|
843
855
|
dispose() {
|
|
844
856
|
clearCloseArm();
|
|
845
|
-
|
|
857
|
+
detailScheduler.dispose();
|
|
846
858
|
},
|
|
847
859
|
};
|
|
848
860
|
}
|
|
@@ -975,7 +987,10 @@ function buildDetailLines(
|
|
|
975
987
|
} else {
|
|
976
988
|
const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
|
|
977
989
|
lines.push(dim(section(evidenceLabel, width), fg));
|
|
978
|
-
|
|
990
|
+
const wrapped = /log/i.test(detail.evidence.label)
|
|
991
|
+
? wrapLogText(body, width - 6)
|
|
992
|
+
: body.split(/\r?\n/);
|
|
993
|
+
for (const raw of wrapped) lines.push(raw ? ` ${raw}` : " ");
|
|
979
994
|
}
|
|
980
995
|
lines.push("");
|
|
981
996
|
const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
|
|
@@ -1041,6 +1056,82 @@ function wrapEvidenceText(text: string, width: number): string[] {
|
|
|
1041
1056
|
return rows.length ? rows : ["(no output yet)"];
|
|
1042
1057
|
}
|
|
1043
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* Wrap terminal log rows without flattening entries together. Continuations
|
|
1061
|
+
* retain the source indentation, and long paths/JSON tokens are hard-wrapped
|
|
1062
|
+
* so the final width guard never has to discard their suffix.
|
|
1063
|
+
*/
|
|
1064
|
+
export function wrapLogText(text: string, width: number): string[] {
|
|
1065
|
+
const max = Math.max(8, Math.floor(width));
|
|
1066
|
+
const rows: string[] = [];
|
|
1067
|
+
for (const source of String(text ?? "").split(/\r?\n/)) {
|
|
1068
|
+
if (!source) {
|
|
1069
|
+
rows.push("");
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const sourceIndent = source.match(/^[ \t]*/)?.[0] ?? "";
|
|
1073
|
+
const indent = sourceIndent.replace(/\t/g, " ");
|
|
1074
|
+
let remaining = source.slice(sourceIndent.length);
|
|
1075
|
+
const contentWidth = Math.max(1, max - visibleWidth(indent));
|
|
1076
|
+
if (!remaining) {
|
|
1077
|
+
rows.push(indent);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
while (visibleWidth(remaining) > contentWidth) {
|
|
1081
|
+
let [head, tail] = splitVisiblePrefix(remaining, contentWidth);
|
|
1082
|
+
const minSoftBreak = Math.floor(contentWidth * 0.45);
|
|
1083
|
+
const delimiterBreak = lastDelimiterBreak(head);
|
|
1084
|
+
const whitespace = head.match(/\s+\S*$/)?.index;
|
|
1085
|
+
if (delimiterBreak > minSoftBreak) {
|
|
1086
|
+
tail = head.slice(delimiterBreak) + tail;
|
|
1087
|
+
head = head.slice(0, delimiterBreak);
|
|
1088
|
+
} else if (whitespace !== undefined && whitespace > minSoftBreak) {
|
|
1089
|
+
tail = head.slice(whitespace).trimStart() + tail;
|
|
1090
|
+
head = head.slice(0, whitespace).trimEnd();
|
|
1091
|
+
}
|
|
1092
|
+
rows.push(indent + head);
|
|
1093
|
+
remaining = tail.trimStart();
|
|
1094
|
+
}
|
|
1095
|
+
rows.push(indent + remaining);
|
|
1096
|
+
}
|
|
1097
|
+
return rows.length ? rows : [""];
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function lastDelimiterBreak(value: string): number {
|
|
1101
|
+
let last = -1;
|
|
1102
|
+
for (const match of value.matchAll(/[\/,}:]/g)) last = match.index + 1;
|
|
1103
|
+
return last;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function splitVisiblePrefix(value: string, width: number): [string, string] {
|
|
1107
|
+
const max = Math.max(0, Math.floor(width));
|
|
1108
|
+
let visible = 0;
|
|
1109
|
+
let index = 0;
|
|
1110
|
+
while (index < value.length && visible < max) {
|
|
1111
|
+
if (value[index] === "\u001b" || value[index] === "\u009b") {
|
|
1112
|
+
const match = value.slice(index).match(ANSI_RE);
|
|
1113
|
+
if (match?.index === 0) {
|
|
1114
|
+
index += match[0].length;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (value[index] === "<") {
|
|
1119
|
+
const close = value.indexOf(">", index);
|
|
1120
|
+
if (close !== -1) {
|
|
1121
|
+
const tag = value.slice(index, close + 1);
|
|
1122
|
+
if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
|
|
1123
|
+
index = close + 1;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const codePoint = value.codePointAt(index)!;
|
|
1129
|
+
index += codePoint > 0xFFFF ? 2 : 1;
|
|
1130
|
+
visible += 1;
|
|
1131
|
+
}
|
|
1132
|
+
return [value.slice(0, index), value.slice(index)];
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1044
1135
|
function cycleLogTailRows(current: number): number {
|
|
1045
1136
|
const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
|
|
1046
1137
|
return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
|
|
@@ -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/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Type } from "typebox";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readLog } from "./logs.js";
|
|
4
4
|
import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
|
|
5
|
+
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
5
6
|
import { listMetas, readMeta, writeMeta } from "./registry.js";
|
|
6
7
|
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
7
8
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
|
|
@@ -89,9 +90,11 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
89
90
|
});
|
|
90
91
|
pi.on("session_before_switch", () => {
|
|
91
92
|
activeSession = undefined;
|
|
93
|
+
cancelCallbackBatch(pi);
|
|
92
94
|
});
|
|
93
95
|
pi.on("session_shutdown", () => {
|
|
94
96
|
activeSession = undefined;
|
|
97
|
+
cancelCallbackBatch(pi);
|
|
95
98
|
});
|
|
96
99
|
|
|
97
100
|
pi.registerTool({
|
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;
|