pi-better-subagents 0.1.0
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 +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-dimensional subagent health observations (issue #66).
|
|
3
|
+
*
|
|
4
|
+
* Pure seam: accepts durable status, process-liveness facts, parsed child-event
|
|
5
|
+
* facts, optional raw-log diagnostics, thresholds, and `now`, then produces an
|
|
6
|
+
* observation. Never writes metadata and never kills processes.
|
|
7
|
+
*
|
|
8
|
+
* Event vocabulary follows docs/evidence/issue-64/NOTES.md. Stale is residual:
|
|
9
|
+
* open tools, compaction, and active model-error/retry phases explain silence
|
|
10
|
+
* and must not collapse into stale. Raw log mtime/size is diagnostic only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { logPathFor } from "./registry.ts";
|
|
15
|
+
import type { RunStatus } from "./registry.ts";
|
|
16
|
+
import { loadConfig, type SubagentConfig } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
// ---- thresholds -----------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface HealthThresholds {
|
|
21
|
+
/** Age of last meaningful activity before activity becomes `quiet`. */
|
|
22
|
+
quietMs: number;
|
|
23
|
+
/** Age of last meaningful activity before residual `stale` (if unexplained). */
|
|
24
|
+
staleMs: number;
|
|
25
|
+
/** Open-tool age before `long_running`. */
|
|
26
|
+
longToolMs: number;
|
|
27
|
+
/** Open-compaction age before `long_compacting`. */
|
|
28
|
+
longCompactionMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Defaults tuned for coordinator workloads; override per call or via config. */
|
|
32
|
+
export const DEFAULT_HEALTH_THRESHOLDS: Readonly<HealthThresholds> = Object.freeze({
|
|
33
|
+
quietMs: 60_000,
|
|
34
|
+
staleMs: 5 * 60_000,
|
|
35
|
+
longToolMs: 2 * 60_000,
|
|
36
|
+
longCompactionMs: 2 * 60_000,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function positiveMs(n: unknown, fallback: number): number {
|
|
40
|
+
const v = Number(n);
|
|
41
|
+
return Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Merge partial thresholds / config keys onto defaults. */
|
|
45
|
+
export function resolveHealthThresholds(
|
|
46
|
+
partial?: Partial<HealthThresholds> | Pick<
|
|
47
|
+
SubagentConfig,
|
|
48
|
+
"healthQuietMs" | "healthStaleMs" | "healthLongToolMs" | "healthLongCompactionMs"
|
|
49
|
+
> | null,
|
|
50
|
+
): HealthThresholds {
|
|
51
|
+
const p = (partial ?? {}) as Partial<HealthThresholds> & SubagentConfig;
|
|
52
|
+
return {
|
|
53
|
+
quietMs: positiveMs(p.quietMs ?? p.healthQuietMs, DEFAULT_HEALTH_THRESHOLDS.quietMs),
|
|
54
|
+
staleMs: positiveMs(p.staleMs ?? p.healthStaleMs, DEFAULT_HEALTH_THRESHOLDS.staleMs),
|
|
55
|
+
longToolMs: positiveMs(p.longToolMs ?? p.healthLongToolMs, DEFAULT_HEALTH_THRESHOLDS.longToolMs),
|
|
56
|
+
longCompactionMs: positiveMs(
|
|
57
|
+
p.longCompactionMs ?? p.healthLongCompactionMs,
|
|
58
|
+
DEFAULT_HEALTH_THRESHOLDS.longCompactionMs,
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Load thresholds from extension config.json (best-effort). */
|
|
64
|
+
export function loadHealthThresholdsFromConfig(config: SubagentConfig = loadConfig()): HealthThresholds {
|
|
65
|
+
return resolveHealthThresholds(config);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- event facts ----------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export interface ActiveToolFact {
|
|
71
|
+
toolCallId?: string;
|
|
72
|
+
toolName: string;
|
|
73
|
+
startedAt?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ModelErrorEntry {
|
|
77
|
+
message: string;
|
|
78
|
+
at?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ModelFacts {
|
|
82
|
+
/** Current model dimension before observation thresholds. */
|
|
83
|
+
state: "ok" | "error" | "retrying";
|
|
84
|
+
lastError?: ModelErrorEntry;
|
|
85
|
+
errorHistory: ModelErrorEntry[];
|
|
86
|
+
retry?: { attempt?: number; maxAttempts?: number; startedAt?: number };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CompactionEndFact {
|
|
90
|
+
reason?: string;
|
|
91
|
+
aborted?: boolean;
|
|
92
|
+
willRetry?: boolean;
|
|
93
|
+
errorMessage?: string;
|
|
94
|
+
at?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parsed facts from a child JSON event stream. Pure; no filesystem.
|
|
99
|
+
* Timestamps prefer event `at` / message.timestamp when present.
|
|
100
|
+
*/
|
|
101
|
+
export interface ChildEventFacts {
|
|
102
|
+
lastMeaningfulAt?: number;
|
|
103
|
+
activeTools: ActiveToolFact[];
|
|
104
|
+
lastToolAt?: number;
|
|
105
|
+
compacting: boolean;
|
|
106
|
+
compactionStartedAt?: number;
|
|
107
|
+
lastCompaction?: CompactionEndFact;
|
|
108
|
+
model: ModelFacts;
|
|
109
|
+
sawAgentSettled: boolean;
|
|
110
|
+
willRetry?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Explicit model-call lifecycle support. #64 found no model_call_start/end;
|
|
113
|
+
* always false until evidence lands.
|
|
114
|
+
*/
|
|
115
|
+
longModelCallSupported: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface RawLogDiagnostic {
|
|
119
|
+
mtimeMs?: number;
|
|
120
|
+
sizeBytes?: number;
|
|
121
|
+
error?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type LooseEvent = Record<string, unknown>;
|
|
125
|
+
|
|
126
|
+
function emptyFacts(): ChildEventFacts {
|
|
127
|
+
return {
|
|
128
|
+
activeTools: [],
|
|
129
|
+
compacting: false,
|
|
130
|
+
model: { state: "ok", errorHistory: [] },
|
|
131
|
+
sawAgentSettled: false,
|
|
132
|
+
longModelCallSupported: false,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wall-clock time from parsed event provenance only.
|
|
138
|
+
* Never invents timestamps from raw log mtime, `Date.now()`, or stream ordinals —
|
|
139
|
+
* those would promote diagnostic writes into healthy activity.
|
|
140
|
+
*/
|
|
141
|
+
function eventTime(e: LooseEvent): number | undefined {
|
|
142
|
+
if (typeof e.at === "number" && Number.isFinite(e.at)) return e.at;
|
|
143
|
+
if (typeof e.timestamp === "number" && Number.isFinite(e.timestamp)) return e.timestamp;
|
|
144
|
+
const msg = e.message as LooseEvent | undefined;
|
|
145
|
+
if (msg && typeof msg.timestamp === "number" && Number.isFinite(msg.timestamp)) {
|
|
146
|
+
return msg.timestamp;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface RetryRecoveryState {
|
|
152
|
+
recoveredAfterError: boolean;
|
|
153
|
+
retrying: boolean;
|
|
154
|
+
willRetry: boolean | undefined;
|
|
155
|
+
lastError?: ModelErrorEntry;
|
|
156
|
+
errorHistory: ModelErrorEntry[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Clear latched retry intent after later successful child progress. */
|
|
160
|
+
function markRecovered(state: RetryRecoveryState): void {
|
|
161
|
+
if (state.lastError || state.errorHistory.length > 0 || state.retrying || state.willRetry === true) {
|
|
162
|
+
state.recoveredAfterError = true;
|
|
163
|
+
}
|
|
164
|
+
state.retrying = false;
|
|
165
|
+
if (state.willRetry === true) state.willRetry = false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function bumpMeaningful(state: { last?: number }, at: number | undefined): void {
|
|
169
|
+
if (typeof at !== "number" || !Number.isFinite(at)) return;
|
|
170
|
+
if (state.last === undefined || at >= state.last) state.last = at;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isAssistantMessage(msg: unknown): msg is LooseEvent {
|
|
174
|
+
return !!msg && typeof msg === "object" && (msg as LooseEvent).role === "assistant";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function assistantIsError(msg: LooseEvent): boolean {
|
|
178
|
+
return msg.stopReason === "error" || typeof msg.errorMessage === "string";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pushError(history: ModelErrorEntry[], entry: ModelErrorEntry, max = 8): void {
|
|
182
|
+
if (!entry.message) return;
|
|
183
|
+
history.push(entry);
|
|
184
|
+
while (history.length > max) history.shift();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Extract health-relevant facts from an in-memory event list (tests / callers
|
|
189
|
+
* that already parsed NDJSON). Skips non-objects; ignores raw non-JSON noise.
|
|
190
|
+
*/
|
|
191
|
+
export function extractChildEventFacts(events: ReadonlyArray<unknown>): ChildEventFacts {
|
|
192
|
+
const openTools = new Map<string, ActiveToolFact>();
|
|
193
|
+
let anonymous = 0;
|
|
194
|
+
const meaningful: { last?: number } = {};
|
|
195
|
+
let lastToolAt: number | undefined;
|
|
196
|
+
let compacting = false;
|
|
197
|
+
let compactionStartedAt: number | undefined;
|
|
198
|
+
let lastCompaction: CompactionEndFact | undefined;
|
|
199
|
+
let sawAgentSettled = false;
|
|
200
|
+
let retry: ModelFacts["retry"];
|
|
201
|
+
const recovery: RetryRecoveryState = {
|
|
202
|
+
recoveredAfterError: false,
|
|
203
|
+
retrying: false,
|
|
204
|
+
willRetry: undefined,
|
|
205
|
+
lastError: undefined,
|
|
206
|
+
errorHistory: [],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const raw of events) {
|
|
210
|
+
if (!raw || typeof raw !== "object") continue;
|
|
211
|
+
const e = raw as LooseEvent;
|
|
212
|
+
const type = typeof e.type === "string" ? e.type : undefined;
|
|
213
|
+
if (!type) continue;
|
|
214
|
+
// Only parsed-event write provenance counts as wall-clock activity.
|
|
215
|
+
const at = eventTime(e);
|
|
216
|
+
|
|
217
|
+
if (type === "tool_execution_start") {
|
|
218
|
+
const toolName = typeof e.toolName === "string" ? e.toolName : "unknown";
|
|
219
|
+
const toolCallId = typeof e.toolCallId === "string" ? e.toolCallId : undefined;
|
|
220
|
+
const key = toolCallId ?? `anonymous:${anonymous++}`;
|
|
221
|
+
openTools.set(key, { toolCallId, toolName, startedAt: at });
|
|
222
|
+
lastToolAt = at ?? lastToolAt;
|
|
223
|
+
bumpMeaningful(meaningful, at);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (type === "tool_execution_update") {
|
|
227
|
+
lastToolAt = at ?? lastToolAt;
|
|
228
|
+
bumpMeaningful(meaningful, at);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (type === "tool_execution_end") {
|
|
232
|
+
const toolCallId = typeof e.toolCallId === "string" ? e.toolCallId : undefined;
|
|
233
|
+
if (toolCallId) {
|
|
234
|
+
openTools.delete(toolCallId);
|
|
235
|
+
} else if (typeof e.toolName === "string") {
|
|
236
|
+
const match = [...openTools].find(([, t]) => t.toolName === e.toolName);
|
|
237
|
+
if (match) openTools.delete(match[0]);
|
|
238
|
+
}
|
|
239
|
+
lastToolAt = at ?? lastToolAt;
|
|
240
|
+
bumpMeaningful(meaningful, at);
|
|
241
|
+
// Successful tool completion supersedes prior retry intent (AC9).
|
|
242
|
+
if (e.isError !== true) markRecovered(recovery);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (type === "compaction_start") {
|
|
247
|
+
compacting = true;
|
|
248
|
+
compactionStartedAt = at;
|
|
249
|
+
bumpMeaningful(meaningful, at);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (type === "compaction_end") {
|
|
253
|
+
compacting = false;
|
|
254
|
+
lastCompaction = {
|
|
255
|
+
reason: typeof e.reason === "string" ? e.reason : undefined,
|
|
256
|
+
aborted: typeof e.aborted === "boolean" ? e.aborted : undefined,
|
|
257
|
+
willRetry: typeof e.willRetry === "boolean" ? e.willRetry : undefined,
|
|
258
|
+
errorMessage: typeof e.errorMessage === "string" ? e.errorMessage : undefined,
|
|
259
|
+
at,
|
|
260
|
+
};
|
|
261
|
+
compactionStartedAt = undefined;
|
|
262
|
+
bumpMeaningful(meaningful, at);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (type === "auto_retry_start") {
|
|
267
|
+
recovery.retrying = true;
|
|
268
|
+
recovery.recoveredAfterError = false;
|
|
269
|
+
recovery.willRetry = true;
|
|
270
|
+
retry = {
|
|
271
|
+
attempt: typeof e.attempt === "number" ? e.attempt : undefined,
|
|
272
|
+
maxAttempts: typeof e.maxAttempts === "number" ? e.maxAttempts : undefined,
|
|
273
|
+
startedAt: at,
|
|
274
|
+
};
|
|
275
|
+
const msg = typeof e.errorMessage === "string" ? e.errorMessage : "model retry";
|
|
276
|
+
recovery.lastError = { message: msg, at };
|
|
277
|
+
pushError(recovery.errorHistory, recovery.lastError);
|
|
278
|
+
bumpMeaningful(meaningful, at);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (type === "auto_retry_end") {
|
|
282
|
+
recovery.retrying = false;
|
|
283
|
+
const success = e.success === true;
|
|
284
|
+
if (success) {
|
|
285
|
+
markRecovered(recovery);
|
|
286
|
+
} else {
|
|
287
|
+
const msg = typeof e.finalError === "string"
|
|
288
|
+
? e.finalError
|
|
289
|
+
: (recovery.lastError?.message ?? "model retry exhausted");
|
|
290
|
+
recovery.lastError = { message: msg, at };
|
|
291
|
+
pushError(recovery.errorHistory, recovery.lastError);
|
|
292
|
+
recovery.recoveredAfterError = false;
|
|
293
|
+
}
|
|
294
|
+
bumpMeaningful(meaningful, at);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (type === "message_end") {
|
|
299
|
+
const msg = e.message;
|
|
300
|
+
if (isAssistantMessage(msg)) {
|
|
301
|
+
if (assistantIsError(msg)) {
|
|
302
|
+
const message = typeof msg.errorMessage === "string"
|
|
303
|
+
? msg.errorMessage
|
|
304
|
+
: "assistant stopReason=error";
|
|
305
|
+
recovery.lastError = { message, at };
|
|
306
|
+
pushError(recovery.errorHistory, recovery.lastError);
|
|
307
|
+
recovery.recoveredAfterError = false;
|
|
308
|
+
// Model-error evidence is meaningful for timing, not healthy progress.
|
|
309
|
+
bumpMeaningful(meaningful, at);
|
|
310
|
+
} else {
|
|
311
|
+
bumpMeaningful(meaningful, at);
|
|
312
|
+
// Later successful assistant evidence clears retry/warning (AC9).
|
|
313
|
+
markRecovered(recovery);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// user message_end is not child progress
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (type === "turn_end") {
|
|
321
|
+
const msg = e.message;
|
|
322
|
+
if (isAssistantMessage(msg) && !assistantIsError(msg)) {
|
|
323
|
+
bumpMeaningful(meaningful, at);
|
|
324
|
+
markRecovered(recovery);
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (type === "agent_end") {
|
|
330
|
+
if (typeof e.willRetry === "boolean") recovery.willRetry = e.willRetry;
|
|
331
|
+
if (recovery.willRetry === true) {
|
|
332
|
+
recovery.retrying = true;
|
|
333
|
+
recovery.recoveredAfterError = false;
|
|
334
|
+
bumpMeaningful(meaningful, at);
|
|
335
|
+
}
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (type === "agent_settled") {
|
|
339
|
+
sawAgentSettled = true;
|
|
340
|
+
// Settled ends the active retry phase; retain error history for detail.
|
|
341
|
+
recovery.retrying = false;
|
|
342
|
+
if (recovery.willRetry === true) recovery.willRetry = false;
|
|
343
|
+
bumpMeaningful(meaningful, at);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// message_update is noisy; intentionally NOT meaningful for stale detection.
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let modelState: ModelFacts["state"] = "ok";
|
|
351
|
+
if (recovery.retrying || recovery.willRetry === true) modelState = "retrying";
|
|
352
|
+
else if (recovery.lastError && !recovery.recoveredAfterError) modelState = "error";
|
|
353
|
+
else modelState = "ok";
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
lastMeaningfulAt: meaningful.last,
|
|
357
|
+
activeTools: [...openTools.values()],
|
|
358
|
+
lastToolAt,
|
|
359
|
+
compacting,
|
|
360
|
+
compactionStartedAt,
|
|
361
|
+
lastCompaction,
|
|
362
|
+
model: {
|
|
363
|
+
state: modelState,
|
|
364
|
+
lastError: recovery.lastError,
|
|
365
|
+
errorHistory: [...recovery.errorHistory],
|
|
366
|
+
retry,
|
|
367
|
+
},
|
|
368
|
+
sawAgentSettled,
|
|
369
|
+
willRetry: recovery.willRetry,
|
|
370
|
+
longModelCallSupported: false,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Read a run log for event facts + raw mtime/size diagnostics.
|
|
376
|
+
* Raw log write time never promotes activity health by itself.
|
|
377
|
+
*/
|
|
378
|
+
export function extractChildEventFactsFromLog(
|
|
379
|
+
id: string,
|
|
380
|
+
opts: { now?: number; logText?: string } = {},
|
|
381
|
+
): { facts: ChildEventFacts; rawLog: RawLogDiagnostic } {
|
|
382
|
+
const path = logPathFor(id);
|
|
383
|
+
const rawLog: RawLogDiagnostic = {};
|
|
384
|
+
try {
|
|
385
|
+
const st = statSync(path);
|
|
386
|
+
rawLog.mtimeMs = Math.trunc(st.mtimeMs);
|
|
387
|
+
rawLog.sizeBytes = st.size;
|
|
388
|
+
} catch (err) {
|
|
389
|
+
rawLog.error = err instanceof Error ? err.message : String(err);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let text = opts.logText;
|
|
393
|
+
if (text === undefined) {
|
|
394
|
+
try {
|
|
395
|
+
text = readFileSync(path, "utf-8");
|
|
396
|
+
} catch (err) {
|
|
397
|
+
rawLog.error = rawLog.error ?? (err instanceof Error ? err.message : String(err));
|
|
398
|
+
return { facts: emptyFacts(), rawLog };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const events: unknown[] = [];
|
|
403
|
+
for (const line of text.split("\n")) {
|
|
404
|
+
const s = line.trim();
|
|
405
|
+
if (!s || s[0] !== "{") continue;
|
|
406
|
+
try {
|
|
407
|
+
events.push(JSON.parse(s));
|
|
408
|
+
} catch {
|
|
409
|
+
// bad JSON — ignore (noise / partial line)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Do not synthesise wall-clock timestamps from raw mtime / now. Untimestamped
|
|
414
|
+
// events still contribute structural facts (open tools, compacting, model
|
|
415
|
+
// phase); activity age only moves on parsed-event write provenance.
|
|
416
|
+
// `opts.now` is accepted for API symmetry with callers but must not mint times.
|
|
417
|
+
void opts.now;
|
|
418
|
+
return { facts: extractChildEventFacts(events), rawLog };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- observation ----------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
export type ActivityHealth = "healthy" | "quiet" | "stale";
|
|
424
|
+
export type CompactionHealthState = "idle" | "compacting" | "long_compacting";
|
|
425
|
+
export type ToolHealthState = "idle" | "running" | "long_running";
|
|
426
|
+
export type ProcessLiveness = "supervised" | "orphaned" | "lost" | "terminal" | "unknown";
|
|
427
|
+
|
|
428
|
+
export interface ProcessObservation {
|
|
429
|
+
liveness: ProcessLiveness;
|
|
430
|
+
supervised?: boolean;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export interface CompactionObservation {
|
|
434
|
+
state: CompactionHealthState;
|
|
435
|
+
startedAt?: number;
|
|
436
|
+
ageMs?: number;
|
|
437
|
+
last?: CompactionEndFact;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export interface ToolObservation {
|
|
441
|
+
state: ToolHealthState;
|
|
442
|
+
active?: ActiveToolFact;
|
|
443
|
+
ageMs?: number;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export interface ModelObservation {
|
|
447
|
+
state: "ok" | "error" | "retrying";
|
|
448
|
+
/** Present on compact/list surfaces while unrecovered. */
|
|
449
|
+
listWarning?: string;
|
|
450
|
+
lastError?: ModelErrorEntry;
|
|
451
|
+
/** Detail history — retained after recovery. */
|
|
452
|
+
errorHistory: ModelErrorEntry[];
|
|
453
|
+
retry?: ModelFacts["retry"];
|
|
454
|
+
/**
|
|
455
|
+
* Only set when explicit lifecycle evidence supports it. #64: unsupported,
|
|
456
|
+
* so this property is never populated by observeRunHealth today.
|
|
457
|
+
*/
|
|
458
|
+
longModelCall?: { startedAt?: number; ageMs?: number };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Durable run status plus transient display `exited` (dead pid, still `running` on disk). */
|
|
462
|
+
export type ObservationStatus = RunStatus | "exited";
|
|
463
|
+
|
|
464
|
+
export interface HealthObservation {
|
|
465
|
+
status: ObservationStatus;
|
|
466
|
+
process: ProcessObservation;
|
|
467
|
+
activity: ActivityHealth;
|
|
468
|
+
lastMeaningfulAt?: number;
|
|
469
|
+
meaningfulAgeMs?: number;
|
|
470
|
+
compaction: CompactionObservation;
|
|
471
|
+
tool: ToolObservation;
|
|
472
|
+
model: ModelObservation;
|
|
473
|
+
rawLog: RawLogDiagnostic;
|
|
474
|
+
/** At most a couple of compact facts for list/navigator consumers. */
|
|
475
|
+
compactFacts: string[];
|
|
476
|
+
thresholds: HealthThresholds;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export interface ObserveRunHealthInput {
|
|
480
|
+
/** Prefer effective/display status so liveness matches what the UI shows. */
|
|
481
|
+
status: ObservationStatus;
|
|
482
|
+
now: number;
|
|
483
|
+
facts: ChildEventFacts;
|
|
484
|
+
rawLog?: RawLogDiagnostic;
|
|
485
|
+
thresholds?: Partial<HealthThresholds>;
|
|
486
|
+
process?: { supervised?: boolean };
|
|
487
|
+
/** Fallback anchor when no meaningful events exist yet. */
|
|
488
|
+
startedAt?: number;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function processLiveness(status: ObservationStatus, supervised?: boolean): ProcessLiveness {
|
|
492
|
+
if (status === "orphaned") return "orphaned";
|
|
493
|
+
if (status === "lost") return "lost";
|
|
494
|
+
if (status === "completed" || status === "failed" || status === "killed" || status === "exited") {
|
|
495
|
+
return "terminal";
|
|
496
|
+
}
|
|
497
|
+
if (status === "running") return supervised === false ? "unknown" : "supervised";
|
|
498
|
+
return "unknown";
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function fmtAge(ms: number): string {
|
|
502
|
+
let value = ms;
|
|
503
|
+
if (!Number.isFinite(value) || value < 0) value = 0;
|
|
504
|
+
const s = Math.floor(value / 1000);
|
|
505
|
+
if (s < 60) return `${s}s`;
|
|
506
|
+
const m = Math.floor(s / 60);
|
|
507
|
+
if (m < 60) return `${m}m`;
|
|
508
|
+
const h = Math.floor(m / 60);
|
|
509
|
+
return `${h}h${m % 60}m`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Compute a multi-dimensional health observation. Pure.
|
|
514
|
+
*/
|
|
515
|
+
export function observeRunHealth(input: ObserveRunHealthInput): HealthObservation {
|
|
516
|
+
const thresholds = resolveHealthThresholds(input.thresholds);
|
|
517
|
+
const now = input.now;
|
|
518
|
+
const facts = input.facts;
|
|
519
|
+
const rawLog = input.rawLog ?? {};
|
|
520
|
+
|
|
521
|
+
// ---- tool dimension
|
|
522
|
+
const active = facts.activeTools[facts.activeTools.length - 1];
|
|
523
|
+
let toolState: ToolHealthState = "idle";
|
|
524
|
+
let toolAge: number | undefined;
|
|
525
|
+
if (active) {
|
|
526
|
+
const start = active.startedAt ?? facts.lastToolAt ?? facts.lastMeaningfulAt;
|
|
527
|
+
toolAge = typeof start === "number" ? Math.max(0, now - start) : undefined;
|
|
528
|
+
toolState = toolAge !== undefined && toolAge >= thresholds.longToolMs
|
|
529
|
+
? "long_running"
|
|
530
|
+
: "running";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---- compaction dimension
|
|
534
|
+
let compactionState: CompactionHealthState = "idle";
|
|
535
|
+
let compactionAge: number | undefined;
|
|
536
|
+
if (facts.compacting) {
|
|
537
|
+
const start = facts.compactionStartedAt ?? facts.lastMeaningfulAt;
|
|
538
|
+
compactionAge = typeof start === "number" ? Math.max(0, now - start) : undefined;
|
|
539
|
+
compactionState = compactionAge !== undefined && compactionAge >= thresholds.longCompactionMs
|
|
540
|
+
? "long_compacting"
|
|
541
|
+
: "compacting";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---- model dimension
|
|
545
|
+
const modelState = facts.model.state;
|
|
546
|
+
let listWarning: string | undefined;
|
|
547
|
+
if (modelState === "retrying") listWarning = "model retrying";
|
|
548
|
+
else if (modelState === "error") listWarning = "model error";
|
|
549
|
+
// recovered → listWarning stays undefined (cleared from compact/list)
|
|
550
|
+
|
|
551
|
+
// ---- activity (residual stale)
|
|
552
|
+
const anchor = facts.lastMeaningfulAt ?? input.startedAt;
|
|
553
|
+
const meaningfulAgeMs = typeof anchor === "number" ? Math.max(0, now - anchor) : undefined;
|
|
554
|
+
|
|
555
|
+
const explainedByPhase =
|
|
556
|
+
toolState !== "idle"
|
|
557
|
+
|| compactionState !== "idle"
|
|
558
|
+
|| modelState === "retrying"
|
|
559
|
+
|| modelState === "error";
|
|
560
|
+
|
|
561
|
+
let activity: ActivityHealth = "healthy";
|
|
562
|
+
if (
|
|
563
|
+
input.status === "failed"
|
|
564
|
+
|| input.status === "completed"
|
|
565
|
+
|| input.status === "killed"
|
|
566
|
+
|| input.status === "lost"
|
|
567
|
+
|| input.status === "exited"
|
|
568
|
+
) {
|
|
569
|
+
// Terminal runs are not residual-stale workers.
|
|
570
|
+
if (meaningfulAgeMs !== undefined && meaningfulAgeMs >= thresholds.quietMs) activity = "quiet";
|
|
571
|
+
else activity = "healthy";
|
|
572
|
+
} else if (explainedByPhase) {
|
|
573
|
+
// Active known phase: never residual stale.
|
|
574
|
+
if (meaningfulAgeMs !== undefined && meaningfulAgeMs >= thresholds.quietMs) activity = "quiet";
|
|
575
|
+
else activity = "healthy";
|
|
576
|
+
} else if (meaningfulAgeMs === undefined) {
|
|
577
|
+
if (input.startedAt !== undefined) {
|
|
578
|
+
const age = Math.max(0, now - input.startedAt);
|
|
579
|
+
if (age >= thresholds.staleMs) activity = "stale";
|
|
580
|
+
else if (age >= thresholds.quietMs) activity = "quiet";
|
|
581
|
+
else activity = "healthy";
|
|
582
|
+
} else {
|
|
583
|
+
activity = "stale";
|
|
584
|
+
}
|
|
585
|
+
} else if (meaningfulAgeMs >= thresholds.staleMs) {
|
|
586
|
+
activity = "stale";
|
|
587
|
+
} else if (meaningfulAgeMs >= thresholds.quietMs) {
|
|
588
|
+
activity = "quiet";
|
|
589
|
+
} else {
|
|
590
|
+
activity = "healthy";
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const process: ProcessObservation = {
|
|
594
|
+
liveness: processLiveness(input.status, input.process?.supervised),
|
|
595
|
+
supervised: input.process?.supervised,
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const compaction: CompactionObservation = {
|
|
599
|
+
state: compactionState,
|
|
600
|
+
startedAt: facts.compactionStartedAt,
|
|
601
|
+
ageMs: compactionAge,
|
|
602
|
+
last: facts.lastCompaction,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const tool: ToolObservation = {
|
|
606
|
+
state: toolState,
|
|
607
|
+
active,
|
|
608
|
+
ageMs: toolAge,
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const model: ModelObservation = {
|
|
612
|
+
state: modelState,
|
|
613
|
+
listWarning,
|
|
614
|
+
lastError: facts.model.lastError,
|
|
615
|
+
errorHistory: [...facts.model.errorHistory],
|
|
616
|
+
retry: facts.model.retry,
|
|
617
|
+
// longModelCall omitted — unsupported without explicit lifecycle events (#64)
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const compactFacts = buildCompactFacts({
|
|
621
|
+
activity,
|
|
622
|
+
compaction,
|
|
623
|
+
tool,
|
|
624
|
+
model,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
status: input.status,
|
|
629
|
+
process,
|
|
630
|
+
activity,
|
|
631
|
+
lastMeaningfulAt: facts.lastMeaningfulAt,
|
|
632
|
+
meaningfulAgeMs,
|
|
633
|
+
compaction,
|
|
634
|
+
tool,
|
|
635
|
+
model,
|
|
636
|
+
rawLog,
|
|
637
|
+
compactFacts,
|
|
638
|
+
thresholds,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function buildCompactFacts(p: {
|
|
643
|
+
activity: ActivityHealth;
|
|
644
|
+
compaction: CompactionObservation;
|
|
645
|
+
tool: ToolObservation;
|
|
646
|
+
model: ModelObservation;
|
|
647
|
+
}): string[] {
|
|
648
|
+
const facts: string[] = [];
|
|
649
|
+
|
|
650
|
+
if (p.compaction.state === "long_compacting") {
|
|
651
|
+
const age = p.compaction.ageMs !== undefined ? ` ${fmtAge(p.compaction.ageMs)}` : "";
|
|
652
|
+
facts.push(`long compacting${age}`.trim());
|
|
653
|
+
} else if (p.compaction.state === "compacting") {
|
|
654
|
+
const age = p.compaction.ageMs !== undefined ? ` ${fmtAge(p.compaction.ageMs)}` : "";
|
|
655
|
+
facts.push(`compacting${age}`.trim());
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (p.tool.state !== "idle" && p.tool.active) {
|
|
659
|
+
const age = p.tool.ageMs !== undefined ? ` ${fmtAge(p.tool.ageMs)}` : "";
|
|
660
|
+
const name = p.tool.active.toolName;
|
|
661
|
+
facts.push(p.tool.state === "long_running" ? `long ${name}${age}`.trim() : `${name}${age}`.trim());
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (p.model.listWarning) facts.push(p.model.listWarning);
|
|
665
|
+
|
|
666
|
+
// Residual stale only when nothing more specific is already listed.
|
|
667
|
+
if (p.activity === "stale" && facts.length === 0) facts.push("stale");
|
|
668
|
+
|
|
669
|
+
return facts.slice(0, 2);
|
|
670
|
+
}
|