pi-better-subagents 0.2.0 → 0.3.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 +2 -2
- package/docs/failure-observations.md +11 -0
- package/failures.ts +188 -0
- package/finalization.ts +14 -0
- package/index.ts +112 -23
- package/list.mjs +4 -1
- package/package.json +3 -2
- package/permission-policy.ts +106 -0
- package/sandbox.ts +26 -5
- package/shared-callback-batcher.ts +42 -16
- package/shared-failure-observations.ts +205 -0
- package/shared-sandbox-core.ts +283 -11
- package/tools.ts +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"docs/agent-catalog-operations.md",
|
|
52
52
|
"docs/agent-model-resolution.md",
|
|
53
53
|
"docs/agent-catalog-lifecycle.md",
|
|
54
|
-
"docs/agent-catalog-acceptance.md"
|
|
54
|
+
"docs/agent-catalog-acceptance.md",
|
|
55
|
+
"docs/failure-observations.md"
|
|
55
56
|
],
|
|
56
57
|
"peerDependencies": {
|
|
57
58
|
"@earendil-works/pi-ai": "*",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Optional sandbox-extension policy, mirrored per Pi event bus at launch time. */
|
|
2
|
+
export const SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
|
|
3
|
+
export const SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
|
|
4
|
+
|
|
5
|
+
type Access = "off" | "read" | "read-write";
|
|
6
|
+
export type PermissionProfile = Readonly<{
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
projectFiles: Access;
|
|
9
|
+
outsideProject: Access;
|
|
10
|
+
storedCredentials: Access;
|
|
11
|
+
commands: boolean;
|
|
12
|
+
network: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
export type PermissionSnapshot = Readonly<{
|
|
15
|
+
permissions?: PermissionProfile;
|
|
16
|
+
subagentPermissions?: PermissionProfile;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
type EventBus = {
|
|
20
|
+
on(channel: string, handler: (data: unknown) => void): unknown;
|
|
21
|
+
emit(channel: string, data: unknown): void;
|
|
22
|
+
};
|
|
23
|
+
const mirrors = new WeakMap<EventBus, { policy?: PermissionSnapshot; error?: Error }>();
|
|
24
|
+
|
|
25
|
+
function busOf(pi: unknown): EventBus | undefined {
|
|
26
|
+
const bus = (pi as { events?: unknown } | undefined)?.events;
|
|
27
|
+
if (!bus || typeof bus !== "object") return undefined;
|
|
28
|
+
const candidate = bus as Partial<EventBus>;
|
|
29
|
+
return typeof candidate.on === "function" && typeof candidate.emit === "function" ? candidate as EventBus : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function profile(value: unknown): PermissionProfile {
|
|
33
|
+
if (!value || typeof value !== "object") throw new Error("Invalid sandbox permission profile.");
|
|
34
|
+
const p = value as Record<string, unknown>;
|
|
35
|
+
const access = (v: unknown): v is Access => v === "off" || v === "read" || v === "read-write";
|
|
36
|
+
if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" || typeof p.network !== "boolean" ||
|
|
37
|
+
!access(p.projectFiles) || !access(p.outsideProject) || !access(p.storedCredentials)) {
|
|
38
|
+
throw new Error("Invalid sandbox permission profile; update permissions in the sandbox UI.");
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
enabled: p.enabled, commands: p.commands, network: p.network,
|
|
42
|
+
projectFiles: p.projectFiles, outsideProject: p.outsideProject, storedCredentials: p.storedCredentials,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readPolicy(data: unknown): PermissionSnapshot | undefined {
|
|
47
|
+
if (!data || typeof data !== "object") return undefined;
|
|
48
|
+
const p = data as Record<string, unknown>;
|
|
49
|
+
if (!["inactive", "enabled", "disabled", "unavailable", "failed"].includes(String(p.state))) return undefined;
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
...(p.permissions === undefined ? {} : { permissions: profile(p.permissions) }),
|
|
52
|
+
...(p.subagentPermissions === undefined ? {} : { subagentPermissions: profile(p.subagentPermissions) }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Subscribe before launching; request a replay for either extension load order. */
|
|
57
|
+
export function observeSandboxPermissions(pi: unknown): void {
|
|
58
|
+
const bus = busOf(pi);
|
|
59
|
+
if (!bus || mirrors.has(bus)) return;
|
|
60
|
+
const mirror: { policy?: PermissionSnapshot; error?: Error } = {};
|
|
61
|
+
mirrors.set(bus, mirror);
|
|
62
|
+
bus.on(SANDBOX_POLICY_CHANNEL, (data) => {
|
|
63
|
+
try {
|
|
64
|
+
const next = readPolicy(data);
|
|
65
|
+
if (next) { mirror.policy = next; mirror.error = undefined; }
|
|
66
|
+
} catch (error) {
|
|
67
|
+
mirror.error = error as Error;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function currentSandboxPermissions(pi: unknown): PermissionSnapshot | undefined {
|
|
74
|
+
observeSandboxPermissions(pi);
|
|
75
|
+
const bus = busOf(pi);
|
|
76
|
+
if (!bus) return undefined;
|
|
77
|
+
bus.emit(SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
78
|
+
const mirror = mirrors.get(bus);
|
|
79
|
+
if (mirror?.error) throw mirror.error;
|
|
80
|
+
return mirror?.policy;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve before allocating a run. Absent settings retain the legacy default-on policy. */
|
|
84
|
+
export function resolveSubagentPermissions(pi: unknown, requestedSandbox: boolean | undefined): {
|
|
85
|
+
sandboxEnabled: boolean;
|
|
86
|
+
enforced: boolean;
|
|
87
|
+
permissions?: Omit<PermissionProfile, "enabled">;
|
|
88
|
+
} {
|
|
89
|
+
const snapshot = currentSandboxPermissions(pi);
|
|
90
|
+
if (snapshot?.permissions?.enabled && !snapshot.permissions.commands) {
|
|
91
|
+
throw new Error("Main sandbox profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
|
|
92
|
+
}
|
|
93
|
+
const child = snapshot?.subagentPermissions;
|
|
94
|
+
if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false };
|
|
95
|
+
if (child.enabled && requestedSandbox === false) {
|
|
96
|
+
throw new Error("Subagent sandbox is enforced by the human-enabled profile; sandbox:false cannot bypass it. Change Subagents permissions in the sandbox UI.");
|
|
97
|
+
}
|
|
98
|
+
const sandboxEnabled = child.enabled || requestedSandbox === true;
|
|
99
|
+
if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false };
|
|
100
|
+
if (!child.commands) throw new Error("Subagents profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
|
|
101
|
+
if (!child.network) {
|
|
102
|
+
throw new Error("Subagents profile disables network, including model requests. Child provider isolation is not available; enable network in the sandbox UI before launching a subagent.");
|
|
103
|
+
}
|
|
104
|
+
const { enabled: _enabled, ...permissions } = child;
|
|
105
|
+
return { sandboxEnabled: true, enforced: true, permissions };
|
|
106
|
+
}
|
package/sandbox.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* is passed through unchanged.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { PermissionProfile } from "./permission-policy.ts";
|
|
13
14
|
import {
|
|
14
15
|
buildSandboxCommand as buildSharedSandboxCommand,
|
|
16
|
+
compileWritePolicy,
|
|
15
17
|
maybeBuildSandboxCommand as maybeBuildSharedSandboxCommand,
|
|
16
18
|
sandboxSupported as sharedSandboxSupported,
|
|
17
19
|
type SandboxCommand,
|
|
@@ -25,19 +27,34 @@ type SandboxCommandArgs = {
|
|
|
25
27
|
home: string;
|
|
26
28
|
piBin: string;
|
|
27
29
|
piArgs: string[];
|
|
30
|
+
permissions?: Omit<PermissionProfile, "enabled">;
|
|
31
|
+
denyWrite?: readonly string[];
|
|
32
|
+
runtimeDir?: string;
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
/** Map the subagent's single-writable-directory shape onto the shared policy. */
|
|
31
36
|
function sharedArgs(args: SandboxCommandArgs): SharedSandboxCommandArgs {
|
|
32
37
|
return {
|
|
33
38
|
profilePath: args.profilePath,
|
|
34
|
-
//
|
|
35
|
-
policy: { writableRoot: args.writableDir, home: args.home
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
// Parent-owned run artifacts must never be writable by the child.
|
|
40
|
+
policy: { writableRoot: args.writableDir, home: args.home,
|
|
41
|
+
...(args.permissions ? { permissions: args.permissions } : {}),
|
|
42
|
+
...(args.denyWrite ? { denyWrite: args.denyWrite } : {}),
|
|
43
|
+
...(args.runtimeDir ? { runtimeWrite: [args.runtimeDir] } : {}),
|
|
44
|
+
},
|
|
45
|
+
execPath: args.runtimeDir ? "/usr/bin/env" : args.piBin,
|
|
46
|
+
execArgs: args.runtimeDir
|
|
47
|
+
? [`TMPDIR=${args.runtimeDir}`, `TMP=${args.runtimeDir}`, `TEMP=${args.runtimeDir}`, args.piBin, ...args.piArgs]
|
|
48
|
+
: args.piArgs,
|
|
38
49
|
};
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
function assertPermissionCore(args: SandboxCommandArgs): void {
|
|
53
|
+
if (args.permissions && !("permissions" in compileWritePolicy(sharedArgs(args).policy))) {
|
|
54
|
+
throw new Error("Permission-aware sandbox core is unavailable; update the sandbox packages before launching a subagent.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
/** True when an OS write-sandbox backend can be applied on this platform. */
|
|
42
59
|
export function sandboxSupported(): boolean {
|
|
43
60
|
return sharedSandboxSupported();
|
|
@@ -55,9 +72,12 @@ export function maybeBuildSandboxCommand(
|
|
|
55
72
|
// `sandbox:false` is this surface's opt-out, and the only one its operator
|
|
56
73
|
// has: a subagent has no slash commands. A caller that states its own remedy
|
|
57
74
|
// keeps it.
|
|
75
|
+
assertPermissionCore(args);
|
|
58
76
|
return maybeBuildSharedSandboxCommand(sharedArgs(args), {
|
|
59
77
|
...request,
|
|
60
|
-
remedy: request.remedy ??
|
|
78
|
+
remedy: request.remedy ?? (args.permissions
|
|
79
|
+
? "Change the Subagents permissions in /sandbox."
|
|
80
|
+
: "Pass sandbox:false to run this subagent unconfined."),
|
|
61
81
|
});
|
|
62
82
|
}
|
|
63
83
|
|
|
@@ -67,5 +87,6 @@ export function maybeBuildSandboxCommand(
|
|
|
67
87
|
* bypass the request-policy helper above.
|
|
68
88
|
*/
|
|
69
89
|
export function buildSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
|
|
90
|
+
assertPermissionCore(args);
|
|
70
91
|
return buildSharedSandboxCommand(sharedArgs(args));
|
|
71
92
|
}
|
|
@@ -105,6 +105,7 @@ export function createCallbackBatcher(
|
|
|
105
105
|
const pending = new Map<string, PendingEvent>();
|
|
106
106
|
const inFlight = new Set<string>();
|
|
107
107
|
const urgentInFlight = new Set<string>();
|
|
108
|
+
const handedOff = new Map<string, number>();
|
|
108
109
|
let sequence = 0;
|
|
109
110
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
111
|
let flushPromise: Promise<boolean> | undefined;
|
|
@@ -140,9 +141,25 @@ export function createCallbackBatcher(
|
|
|
140
141
|
for (const [key] of snapshot) inFlight.add(key);
|
|
141
142
|
|
|
142
143
|
const deliverable: Array<[string, PendingEvent]> = [];
|
|
144
|
+
let deferred = false;
|
|
143
145
|
for (const item of snapshot) {
|
|
144
146
|
const [key, pendingEvent] = item;
|
|
147
|
+
const priorHandoff = handedOff.get(key);
|
|
148
|
+
if (priorHandoff !== undefined) {
|
|
149
|
+
if (!invokeDelivered(pendingEvent.event, priorHandoff)) {
|
|
150
|
+
deferred = true;
|
|
151
|
+
pending.set(key, pendingEvent);
|
|
152
|
+
}
|
|
153
|
+
inFlight.delete(key);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
145
156
|
const disposition = eventDisposition(pendingEvent.event);
|
|
157
|
+
if (disposition.kind === "deferred") {
|
|
158
|
+
deferred = true;
|
|
159
|
+
pending.set(key, pendingEvent);
|
|
160
|
+
inFlight.delete(key);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
146
163
|
if (disposition.kind === "delivered") {
|
|
147
164
|
inFlight.delete(key);
|
|
148
165
|
continue;
|
|
@@ -156,8 +173,8 @@ export function createCallbackBatcher(
|
|
|
156
173
|
}
|
|
157
174
|
|
|
158
175
|
if (deliverable.length === 0) {
|
|
159
|
-
if (pending.size > 0) schedule(windowMs);
|
|
160
|
-
return
|
|
176
|
+
if (pending.size > 0) schedule(deferred ? retryMs : windowMs);
|
|
177
|
+
return !deferred;
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
try {
|
|
@@ -183,11 +200,15 @@ export function createCallbackBatcher(
|
|
|
183
200
|
|
|
184
201
|
const deliveredAt = Date.now();
|
|
185
202
|
for (const [key, item] of deliverable) {
|
|
186
|
-
|
|
203
|
+
handedOff.set(key, deliveredAt);
|
|
204
|
+
if (!invokeDelivered(item.event, deliveredAt)) {
|
|
205
|
+
deferred = true;
|
|
206
|
+
pending.set(key, item);
|
|
207
|
+
}
|
|
187
208
|
inFlight.delete(key);
|
|
188
209
|
}
|
|
189
|
-
if (pending.size > 0) schedule(windowMs);
|
|
190
|
-
return
|
|
210
|
+
if (pending.size > 0) schedule(deferred ? retryMs : windowMs);
|
|
211
|
+
return !deferred;
|
|
191
212
|
};
|
|
192
213
|
|
|
193
214
|
const flush = (): Promise<boolean> => {
|
|
@@ -201,7 +222,15 @@ export function createCallbackBatcher(
|
|
|
201
222
|
const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
|
|
202
223
|
const key = eventKey(event);
|
|
203
224
|
if (urgentInFlight.has(key)) return false;
|
|
225
|
+
const priorHandoff = handedOff.get(key);
|
|
226
|
+
if (priorHandoff !== undefined) return invokeDelivered(event, priorHandoff);
|
|
227
|
+
const acknowledge = (): boolean => {
|
|
228
|
+
const at = Date.now();
|
|
229
|
+
handedOff.set(key, at);
|
|
230
|
+
return invokeDelivered(event, at);
|
|
231
|
+
};
|
|
204
232
|
const disposition = eventDisposition(event);
|
|
233
|
+
if (disposition.kind === "deferred") return false;
|
|
205
234
|
if (disposition.kind === "delivered") return true;
|
|
206
235
|
if (disposition.kind === "suppressed") {
|
|
207
236
|
invokeSuppressed(event, disposition.reason, Date.now());
|
|
@@ -216,16 +245,13 @@ export function createCallbackBatcher(
|
|
|
216
245
|
);
|
|
217
246
|
if (isPromiseLike(handoff)) {
|
|
218
247
|
return Promise.resolve(handoff).then(
|
|
219
|
-
() =>
|
|
220
|
-
invokeDelivered(event, Date.now());
|
|
221
|
-
return true;
|
|
222
|
-
},
|
|
248
|
+
() => acknowledge(),
|
|
223
249
|
() => false,
|
|
224
250
|
).finally(() => urgentInFlight.delete(key));
|
|
225
251
|
}
|
|
226
|
-
|
|
252
|
+
const acknowledged = acknowledge();
|
|
227
253
|
urgentInFlight.delete(key);
|
|
228
|
-
return
|
|
254
|
+
return acknowledged;
|
|
229
255
|
} catch {
|
|
230
256
|
urgentInFlight.delete(key);
|
|
231
257
|
return false;
|
|
@@ -278,25 +304,25 @@ function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">):
|
|
|
278
304
|
|
|
279
305
|
function eventDisposition(
|
|
280
306
|
event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
|
|
281
|
-
): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
|
|
307
|
+
): | { kind: "deliver" } | { kind: "delivered" } | { kind: "deferred" } | { kind: "suppressed"; reason: string } {
|
|
282
308
|
try {
|
|
283
309
|
if (event.isDelivered?.()) return { kind: "delivered" };
|
|
284
310
|
} catch {
|
|
285
|
-
return { kind: "
|
|
311
|
+
return { kind: "deferred" };
|
|
286
312
|
}
|
|
287
313
|
try {
|
|
288
314
|
const reason = event.getSuppressionReason?.();
|
|
289
315
|
return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
|
|
290
316
|
} catch {
|
|
291
|
-
return { kind: "
|
|
317
|
+
return { kind: "deferred" };
|
|
292
318
|
}
|
|
293
319
|
}
|
|
294
320
|
|
|
295
321
|
function invokeDelivered(
|
|
296
322
|
event: Pick<CallbackBatchEvent, "onDelivered">,
|
|
297
323
|
at: number,
|
|
298
|
-
):
|
|
299
|
-
try { event.onDelivered?.(at); } catch {
|
|
324
|
+
): boolean {
|
|
325
|
+
try { event.onDelivered?.(at); return true; } catch { return false; }
|
|
300
326
|
}
|
|
301
327
|
|
|
302
328
|
function invokeSuppressed(
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Generated from packages/failure-observations/index.ts. Do not edit directly.
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export interface FailureEvent {
|
|
7
|
+
/** Stable source event identity: replay must reuse this id. */
|
|
8
|
+
id: string;
|
|
9
|
+
operation: string;
|
|
10
|
+
kind: "failure" | "recovered" | "incomplete" | "delivered";
|
|
11
|
+
/** Event time when known; never fabricate it from log mtime. */
|
|
12
|
+
at?: number;
|
|
13
|
+
summary?: string;
|
|
14
|
+
category?: string;
|
|
15
|
+
evidence?: string;
|
|
16
|
+
expected?: boolean;
|
|
17
|
+
/** Recovery/delivery must name the incidents it resolves/delivers. */
|
|
18
|
+
incidents?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface FailureObservation {
|
|
21
|
+
id: string;
|
|
22
|
+
operation: string;
|
|
23
|
+
status: "unresolved" | "expected" | "resolved";
|
|
24
|
+
category: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
evidence?: string;
|
|
27
|
+
firstObservedAt: number;
|
|
28
|
+
lastObservedAt: number;
|
|
29
|
+
/** Journal order breaks timestamp ties without inventing an event time. */
|
|
30
|
+
lastSequence?: number;
|
|
31
|
+
at?: number;
|
|
32
|
+
count: number;
|
|
33
|
+
resolvedAt?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface FailureState {
|
|
36
|
+
version: 1;
|
|
37
|
+
seen: string[];
|
|
38
|
+
observations: Record<string, FailureObservation>;
|
|
39
|
+
delivered: Record<string, number>;
|
|
40
|
+
resolved?: Record<string, number>;
|
|
41
|
+
}
|
|
42
|
+
export function emptyFailureState(): FailureState {
|
|
43
|
+
return { version: 1, seen: [], observations: {}, delivered: {} };
|
|
44
|
+
}
|
|
45
|
+
export function failureIdentity(...parts: unknown[]): string {
|
|
46
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
|
|
47
|
+
}
|
|
48
|
+
function text(value: string | undefined, fallback: string): string {
|
|
49
|
+
return (value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400);
|
|
50
|
+
}
|
|
51
|
+
/** Pure transition. Lifecycle is deliberately not an input or an output. */
|
|
52
|
+
export function reduceFailure(state: FailureState, event: FailureEvent, observedAt: number): FailureState {
|
|
53
|
+
if (state.seen.includes(event.id)) return state;
|
|
54
|
+
const next: FailureState = { ...state, seen: [...state.seen, event.id],
|
|
55
|
+
observations: { ...state.observations }, delivered: { ...state.delivered } };
|
|
56
|
+
if (event.kind === "delivered") {
|
|
57
|
+
for (const id of event.incidents ?? []) next.delivered = { ...next.delivered, [id]: observedAt };
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
const key = failureIdentity(event.operation);
|
|
61
|
+
const previous = state.observations[key];
|
|
62
|
+
if (event.kind === "recovered") {
|
|
63
|
+
if (previous && event.incidents?.includes(previous.id)) {
|
|
64
|
+
next.observations[key] = { ...previous, status: "resolved", resolvedAt: event.at ?? observedAt };
|
|
65
|
+
next.resolved = { ...state.resolved, [previous.id]: event.at ?? observedAt };
|
|
66
|
+
}
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
const active = previous && previous.status !== "resolved" &&
|
|
70
|
+
!(previous.status === "expected" && !event.expected);
|
|
71
|
+
next.observations[key] = {
|
|
72
|
+
id: active ? previous.id : event.id, operation: event.operation,
|
|
73
|
+
status: active ? previous.status : event.expected ? "expected" : "unresolved",
|
|
74
|
+
category: event.kind === "incomplete" ? "observation-incomplete" : event.category ?? "operation",
|
|
75
|
+
summary: text(event.summary, "Operation failed"), evidence: event.evidence,
|
|
76
|
+
firstObservedAt: active ? previous.firstObservedAt : observedAt,
|
|
77
|
+
lastObservedAt: observedAt, lastSequence: next.seen.length, at: event.at,
|
|
78
|
+
count: active ? previous.count + 1 : 1,
|
|
79
|
+
};
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
export function activeFailures(state: FailureState): FailureObservation[] {
|
|
83
|
+
const priority = (x: FailureObservation) => x.status === "expected" ? 2 : x.category === "observation-incomplete" ? 0 : 1;
|
|
84
|
+
return Object.values(state.observations).filter((x) => x.status !== "resolved")
|
|
85
|
+
.sort((a, b) => priority(a) - priority(b) || (b.lastSequence ?? 0) - (a.lastSequence ?? 0) || b.lastObservedAt - a.lastObservedAt);
|
|
86
|
+
}
|
|
87
|
+
/** Shared priority text, placed BEFORE assistant progress on every consumer surface. */
|
|
88
|
+
export function formatFailureSummary(state: FailureState): string {
|
|
89
|
+
const failures = activeFailures(state);
|
|
90
|
+
if (!failures.length) return "";
|
|
91
|
+
const rows = failures.slice(0, 5).map((x) => {
|
|
92
|
+
const label = x.status === "expected" ? "Expected failure" :
|
|
93
|
+
x.category === "observation-incomplete" ? "Observation incomplete" : "Unresolved failure";
|
|
94
|
+
const time = x.at === undefined ? `observed ${new Date(x.firstObservedAt).toISOString()}` : new Date(x.at).toISOString();
|
|
95
|
+
return `${label} · ${time} · ${x.summary}${x.count > 1 ? ` (${x.count} occurrences)` : ""}${x.evidence ? ` · evidence: ${text(x.evidence, "")}` : ""}`;
|
|
96
|
+
});
|
|
97
|
+
if (failures.length > 5) rows.push(`${failures.length - 5} additional active failure observations retained in the failure journal.`);
|
|
98
|
+
return rows.join("\n");
|
|
99
|
+
}
|
|
100
|
+
export function pendingFailureAttention(state: FailureState, now: number, options: { terminal?: boolean; graceMs?: number } = {}): { key: string; incidents: string[]; summary: string } | undefined {
|
|
101
|
+
const due = activeFailures(state).filter((x) => x.status === "unresolved" && !Object.hasOwn(state.delivered, x.id) &&
|
|
102
|
+
(options.terminal || x.category === "observation-incomplete" || now - x.firstObservedAt >= (options.graceMs ?? 60_000)));
|
|
103
|
+
if (!due.length) return undefined;
|
|
104
|
+
const incidents = due.map((x) => x.id).sort();
|
|
105
|
+
return { key: failureIdentity(incidents), incidents,
|
|
106
|
+
summary: due.map((x) => x.summary).join("; ").slice(0, 800) };
|
|
107
|
+
}
|
|
108
|
+
function storageProblem(state: FailureState, summary: string): FailureState {
|
|
109
|
+
return reduceFailure(state, { id: failureIdentity("storage", summary), operation: "failure-observation-storage",
|
|
110
|
+
kind: "incomplete", summary }, Date.now());
|
|
111
|
+
}
|
|
112
|
+
/** Append-only journal: individual bounded writes avoid lost read/modify/write snapshots. */
|
|
113
|
+
interface PendingRecord { event: FailureEvent; observedAt: number }
|
|
114
|
+
const pendingWrites = new Map<string, PendingRecord[]>();
|
|
115
|
+
const knownJournals = new Map<string, number>();
|
|
116
|
+
function appendRecord(path: string, record: PendingRecord): void {
|
|
117
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
118
|
+
// A durable existence marker distinguishes a lost journal from a run that has
|
|
119
|
+
// never observed a failure, including after a process restart.
|
|
120
|
+
try { closeSync(openSync(`${path}.observed`, "wx", 0o600)); }
|
|
121
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
122
|
+
appendFileSync(path, "\n" + JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
123
|
+
}
|
|
124
|
+
/** Retry unpersisted evidence and receipts on every observation/read. Pending
|
|
125
|
+
* receipts count as handed off in this process, preventing notification storms. */
|
|
126
|
+
export function readFailureState(path: string): FailureState {
|
|
127
|
+
const pending = pendingWrites.get(path);
|
|
128
|
+
while (pending?.length) {
|
|
129
|
+
try { appendRecord(path, pending[0]!); pending.shift(); }
|
|
130
|
+
catch { break; }
|
|
131
|
+
}
|
|
132
|
+
if (!pending?.length) pendingWrites.delete(path);
|
|
133
|
+
let state = readStoredState(path);
|
|
134
|
+
const remaining = pendingWrites.get(path);
|
|
135
|
+
for (const record of remaining ?? []) state = reduceFailure(state, record.event, record.observedAt);
|
|
136
|
+
return remaining?.length ? storageProblem(state, "Failure evidence could not be persisted") : state;
|
|
137
|
+
}
|
|
138
|
+
function readStoredState(path: string): FailureState {
|
|
139
|
+
let source: string;
|
|
140
|
+
try { source = readFileSync(path, "utf8"); }
|
|
141
|
+
catch (error) {
|
|
142
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && !knownJournals.has(path) && !existsSync(`${path}.observed`)) return emptyFailureState();
|
|
143
|
+
return storageProblem(emptyFailureState(), "Failure journal could not be read");
|
|
144
|
+
}
|
|
145
|
+
const bytes = Buffer.byteLength(source);
|
|
146
|
+
const truncated = bytes < (knownJournals.get(path) ?? 0);
|
|
147
|
+
if (bytes || knownJournals.has(path)) knownJournals.set(path, bytes);
|
|
148
|
+
let state = truncated ? storageProblem(emptyFailureState(), "Failure journal was truncated; observations may be incomplete") : emptyFailureState();
|
|
149
|
+
for (const line of source.split("\n")) {
|
|
150
|
+
if (!line) continue;
|
|
151
|
+
try {
|
|
152
|
+
const row = JSON.parse(line);
|
|
153
|
+
const e = row.event;
|
|
154
|
+
if (!e || typeof e.id !== "string" || !e.id || typeof e.operation !== "string" || !e.operation ||
|
|
155
|
+
(e.expected !== undefined && typeof e.expected !== "boolean") ||
|
|
156
|
+
!["failure", "incomplete", "recovered", "delivered"].includes(e.kind) ||
|
|
157
|
+
!Number.isFinite(row.observedAt) || Math.abs(row.observedAt) > 8.64e15 ||
|
|
158
|
+
(e.at !== undefined && (!Number.isFinite(e.at) || Math.abs(e.at) > 8.64e15)) ||
|
|
159
|
+
(e.incidents !== undefined && (!Array.isArray(e.incidents) || !e.incidents.every((id: unknown) => typeof id === "string"))) ||
|
|
160
|
+
[e.summary, e.category, e.evidence].some((v) => v !== undefined && typeof v !== "string")) throw new Error("invalid record");
|
|
161
|
+
state = reduceFailure(state, e, row.observedAt);
|
|
162
|
+
} catch { state = storageProblem(state, "Failure journal contains unreadable records; observations may be incomplete"); }
|
|
163
|
+
}
|
|
164
|
+
if (truncated) {
|
|
165
|
+
const summary = "Failure journal was truncated; observations may be incomplete";
|
|
166
|
+
const record: PendingRecord = { event: { id: failureIdentity("storage", summary), operation: "failure-observation-storage", kind: "incomplete", summary }, observedAt: Date.now() };
|
|
167
|
+
try { appendRecord(path, record); }
|
|
168
|
+
catch { pendingWrites.set(path, [...(pendingWrites.get(path) ?? []), record]); }
|
|
169
|
+
}
|
|
170
|
+
return state;
|
|
171
|
+
}
|
|
172
|
+
export function observeFailures(path: string, events: readonly FailureEvent[], now = Date.now()): FailureState {
|
|
173
|
+
let state = readFailureState(path);
|
|
174
|
+
for (const raw of events) {
|
|
175
|
+
if (state.seen.includes(raw.id)) continue;
|
|
176
|
+
if (raw.kind === "recovered") {
|
|
177
|
+
const prior = state.observations[failureIdentity(raw.operation)];
|
|
178
|
+
if (!prior || prior.status === "resolved" || !raw.incidents?.includes(prior.id)) continue;
|
|
179
|
+
}
|
|
180
|
+
const event = { ...raw, ...(raw.summary ? { summary: text(raw.summary, "") } : {}),
|
|
181
|
+
...(raw.evidence ? { evidence: text(raw.evidence, "") } : {}) };
|
|
182
|
+
try {
|
|
183
|
+
if (pendingWrites.has(path)) throw new Error("Earlier evidence is awaiting persistence");
|
|
184
|
+
appendRecord(path, { event, observedAt: now });
|
|
185
|
+
state = reduceFailure(state, event, now);
|
|
186
|
+
} catch {
|
|
187
|
+
const pending = pendingWrites.get(path) ?? [];
|
|
188
|
+
pending.push({ event, observedAt: now });
|
|
189
|
+
pendingWrites.set(path, pending);
|
|
190
|
+
state = storageProblem(reduceFailure(state, event, now), "Failure evidence could not be persisted");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return state;
|
|
194
|
+
}
|
|
195
|
+
export function failureAttentionHandled(state: FailureState, incidents: readonly string[]): boolean {
|
|
196
|
+
return incidents.every((id) => {
|
|
197
|
+
if (Object.hasOwn(state.delivered, id) || Object.hasOwn(state.resolved ?? {}, id)) return true;
|
|
198
|
+
const observation = Object.values(state.observations).find((item) => item.id === id);
|
|
199
|
+
if (!observation) throw new Error("Failure incident evidence is unavailable; defer notification delivery");
|
|
200
|
+
return observation.status !== "unresolved";
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
export function markFailureAttentionDelivered(path: string, pending: { key: string; incidents: string[] }, at = Date.now()): FailureState {
|
|
204
|
+
return observeFailures(path, [{ id: `delivered:${pending.key}`, operation: "attention-delivery", kind: "delivered", incidents: pending.incidents }], at);
|
|
205
|
+
}
|