@pontia/pi-client-plugin 0.5.2 → 0.6.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/package.json +1 -1
- package/src/compaction.ts +225 -0
- package/src/control-socket.ts +29 -11
- package/src/events.ts +11 -1
- package/src/index.ts +54 -2
- package/src/reporter.ts +37 -57
package/package.json
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
SessionBeforeCompactEvent,
|
|
4
|
+
SessionCompactEvent,
|
|
5
|
+
SessionCompactFailedEvent,
|
|
6
|
+
CompactionResult,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { InternalEvent } from "./events.js";
|
|
9
|
+
import type { SessionContext } from "./session.js";
|
|
10
|
+
import { RpcError } from "./rpc-error.js";
|
|
11
|
+
|
|
12
|
+
type Trigger = "manual" | "threshold" | "overflow" | "unknown";
|
|
13
|
+
interface Operation {
|
|
14
|
+
id: string;
|
|
15
|
+
trigger: Trigger;
|
|
16
|
+
started?: InternalEvent;
|
|
17
|
+
terminal?: InternalEvent;
|
|
18
|
+
acknowledged: Set<string>;
|
|
19
|
+
reporting: Promise<void>;
|
|
20
|
+
awaitingNativeResult: boolean;
|
|
21
|
+
failureKey?: string;
|
|
22
|
+
}
|
|
23
|
+
interface State {
|
|
24
|
+
report: (event: InternalEvent) => Promise<boolean>;
|
|
25
|
+
active?: Operation;
|
|
26
|
+
operations: Map<string, Operation>;
|
|
27
|
+
signals: WeakMap<AbortSignal, Operation>;
|
|
28
|
+
failures: WeakSet<object>;
|
|
29
|
+
completed: Map<string, Operation>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Pi reloads extensions in the same process. Keep only control metadata across
|
|
33
|
+
// reload, so a replaced hook or reconnect cannot allocate another operation.
|
|
34
|
+
const stateKey = Symbol.for("pontia.pi.compaction");
|
|
35
|
+
const processState = globalThis as typeof globalThis & { [stateKey]?: Map<string, State> };
|
|
36
|
+
const states = (processState[stateKey] ??= new Map());
|
|
37
|
+
|
|
38
|
+
export class PiCompaction {
|
|
39
|
+
private readonly state: State;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
home: string,
|
|
43
|
+
private readonly context: SessionContext,
|
|
44
|
+
report: (event: InternalEvent) => Promise<boolean>,
|
|
45
|
+
) {
|
|
46
|
+
const key = JSON.stringify([
|
|
47
|
+
home,
|
|
48
|
+
context.sessionId,
|
|
49
|
+
context.runtimeId,
|
|
50
|
+
context.clientSessionKey,
|
|
51
|
+
]);
|
|
52
|
+
let state = states.get(key);
|
|
53
|
+
if (!state) {
|
|
54
|
+
state = {
|
|
55
|
+
report,
|
|
56
|
+
operations: new Map(),
|
|
57
|
+
signals: new WeakMap(),
|
|
58
|
+
failures: new WeakSet(),
|
|
59
|
+
completed: new Map(),
|
|
60
|
+
};
|
|
61
|
+
states.set(key, state);
|
|
62
|
+
}
|
|
63
|
+
state.report = report;
|
|
64
|
+
this.state = state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get running(): boolean {
|
|
68
|
+
return !!this.state.active && !this.state.active.terminal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async reconnect(): Promise<void> {
|
|
72
|
+
for (const operation of this.state.operations.values()) {
|
|
73
|
+
if (operation.started) await this.publish(operation, !operation.awaitingNativeResult);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
request(operationId: string, compact: () => void): void {
|
|
78
|
+
if (this.state.operations.has(operationId)) return;
|
|
79
|
+
if (this.running) throw new RpcError(-32006, "Pi compaction is already running");
|
|
80
|
+
const operation = this.create("manual", operationId);
|
|
81
|
+
operation.awaitingNativeResult = true;
|
|
82
|
+
try {
|
|
83
|
+
compact();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
this.state.operations.delete(operationId);
|
|
86
|
+
this.state.active = undefined;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async before(event: SessionBeforeCompactEvent): Promise<void> {
|
|
92
|
+
let operation = this.state.signals.get(event.signal);
|
|
93
|
+
if (!operation) {
|
|
94
|
+
operation = this.running ? this.state.active! : this.create(trigger(event.reason));
|
|
95
|
+
this.state.signals.set(event.signal, operation);
|
|
96
|
+
}
|
|
97
|
+
await this.publish(operation);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async completed(event: SessionCompactEvent): Promise<void> {
|
|
101
|
+
const entry = event.compactionEntry;
|
|
102
|
+
const operation = this.state.completed.get(entry.id) ?? this.current(event.reason);
|
|
103
|
+
this.state.completed.set(entry.id, operation);
|
|
104
|
+
operation.terminal ??= this.fact("session.compaction_completed", operation, {
|
|
105
|
+
native_compaction_id: entry.id ?? null,
|
|
106
|
+
tokens_before: tokens(entry.tokensBefore),
|
|
107
|
+
// Pi's extension event does not expose a post-compaction token count.
|
|
108
|
+
tokens_after: null,
|
|
109
|
+
});
|
|
110
|
+
// The manual context callback supplies Pi's estimatedTokensAfter result.
|
|
111
|
+
// Automatic compaction exposes only the extension event, so it stays null.
|
|
112
|
+
await this.publish(operation, !operation.awaitingNativeResult);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async failed(event: SessionCompactFailedEvent, leaf: string | null): Promise<void> {
|
|
116
|
+
if (this.state.failures.has(event)) return;
|
|
117
|
+
this.state.failures.add(event);
|
|
118
|
+
const failureKey = JSON.stringify([
|
|
119
|
+
event.reason,
|
|
120
|
+
event.aborted,
|
|
121
|
+
event.errorMessage ?? null,
|
|
122
|
+
leaf,
|
|
123
|
+
]);
|
|
124
|
+
const previous = this.state.active;
|
|
125
|
+
const operation =
|
|
126
|
+
this.running || previous?.failureKey === failureKey
|
|
127
|
+
? previous!
|
|
128
|
+
: this.create(trigger(event.reason));
|
|
129
|
+
operation.failureKey = failureKey;
|
|
130
|
+
operation.terminal ??= this.fact("session.compaction_failed", operation, {
|
|
131
|
+
aborted: event.aborted,
|
|
132
|
+
error_message: event.errorMessage ?? null,
|
|
133
|
+
});
|
|
134
|
+
operation.awaitingNativeResult = false;
|
|
135
|
+
await this.publish(operation);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The callback covers native failures occurring before session_before_compact
|
|
139
|
+
// (for example, a session too small to compact). Hooks remain authoritative
|
|
140
|
+
// for cancellation and supply richer diagnostics when present.
|
|
141
|
+
async callbackError(operationId: string, error: Error): Promise<void> {
|
|
142
|
+
const operation = this.state.operations.get(operationId);
|
|
143
|
+
if (!operation) return;
|
|
144
|
+
operation.awaitingNativeResult = false;
|
|
145
|
+
operation.terminal ??= this.fact("session.compaction_failed", operation, {
|
|
146
|
+
aborted: false,
|
|
147
|
+
error_message: error.message,
|
|
148
|
+
});
|
|
149
|
+
await this.publish(operation);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async callbackComplete(operationId: string, result: CompactionResult): Promise<void> {
|
|
153
|
+
const operation = this.state.operations.get(operationId);
|
|
154
|
+
if (!operation) return;
|
|
155
|
+
operation.awaitingNativeResult = false;
|
|
156
|
+
operation.terminal ??= this.fact("session.compaction_completed", operation, {
|
|
157
|
+
native_compaction_id: null,
|
|
158
|
+
tokens_before: tokens(result.tokensBefore),
|
|
159
|
+
tokens_after: null,
|
|
160
|
+
});
|
|
161
|
+
if (
|
|
162
|
+
!operation.acknowledged.has(operation.terminal.type) &&
|
|
163
|
+
operation.terminal.type === "session.compaction_completed"
|
|
164
|
+
) {
|
|
165
|
+
operation.terminal.data.tokens_after = tokens(result.estimatedTokensAfter);
|
|
166
|
+
}
|
|
167
|
+
await this.publish(operation);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private current(reason: unknown): Operation {
|
|
171
|
+
return this.running ? this.state.active! : this.create(trigger(reason));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private create(reason: Trigger, id = `compact_${randomUUID()}`): Operation {
|
|
175
|
+
const operation: Operation = {
|
|
176
|
+
id,
|
|
177
|
+
trigger: reason,
|
|
178
|
+
acknowledged: new Set(),
|
|
179
|
+
reporting: Promise.resolve(),
|
|
180
|
+
awaitingNativeResult: false,
|
|
181
|
+
};
|
|
182
|
+
this.state.operations.set(id, operation);
|
|
183
|
+
this.state.active = operation;
|
|
184
|
+
return operation;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fact(
|
|
188
|
+
type: InternalEvent["type"],
|
|
189
|
+
operation: Operation,
|
|
190
|
+
metadata: Record<string, unknown> = {},
|
|
191
|
+
): InternalEvent {
|
|
192
|
+
return {
|
|
193
|
+
session_id: this.context.sessionId,
|
|
194
|
+
type,
|
|
195
|
+
data: { operation_id: operation.id, trigger: operation.trigger, ...metadata },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private publish(operation: Operation, includeTerminal = true): Promise<void> {
|
|
200
|
+
operation.started ??= this.fact("session.compaction_started", operation);
|
|
201
|
+
operation.reporting = operation.reporting
|
|
202
|
+
.catch(() => {})
|
|
203
|
+
.then(async () => {
|
|
204
|
+
for (const event of [
|
|
205
|
+
operation.started!,
|
|
206
|
+
includeTerminal ? operation.terminal : undefined,
|
|
207
|
+
]) {
|
|
208
|
+
if (!event || operation.acknowledged.has(event.type)) continue;
|
|
209
|
+
if (!(await this.state.report(event))) return;
|
|
210
|
+
operation.acknowledged.add(event.type);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return operation.reporting;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function trigger(reason: unknown): Trigger {
|
|
218
|
+
return reason === "manual" || reason === "threshold" || reason === "overflow"
|
|
219
|
+
? reason
|
|
220
|
+
: "unknown";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function tokens(value: unknown): number | null {
|
|
224
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
225
|
+
}
|
package/src/control-socket.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readHistory, type HistoryContext } from "./history.js";
|
|
|
4
4
|
import { createConnection, type Socket } from "node:net";
|
|
5
5
|
import { isAbsolute, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const CONTROL_VERSION =
|
|
7
|
+
export const CONTROL_VERSION = 8;
|
|
8
8
|
export const MAX_CONTROL_FRAME_BYTES = 64 * 1024;
|
|
9
9
|
// Match the former HTTP event body limit, including the RPC envelope.
|
|
10
10
|
export const MAX_RPC_FRAME_BYTES = 2 * 1024 * 1024 + 1024;
|
|
@@ -33,6 +33,9 @@ export interface LifecycleControl {
|
|
|
33
33
|
interrupt(): void;
|
|
34
34
|
shutdown(): void;
|
|
35
35
|
}
|
|
36
|
+
export interface CompactionControl {
|
|
37
|
+
compact(operationId: string): void;
|
|
38
|
+
}
|
|
36
39
|
export interface PiConnection {
|
|
37
40
|
request(method: string, params: object): Promise<unknown>;
|
|
38
41
|
registered(identity: ControlIdentity): void;
|
|
@@ -65,6 +68,7 @@ class RpcSocket {
|
|
|
65
68
|
private models?: ModelControl;
|
|
66
69
|
private lifecycle?: LifecycleControl;
|
|
67
70
|
private history?: () => HistoryContext;
|
|
71
|
+
private compaction?: CompactionControl;
|
|
68
72
|
private onReplay?: (inboxMessageId: string) => void;
|
|
69
73
|
|
|
70
74
|
constructor(
|
|
@@ -74,6 +78,7 @@ class RpcSocket {
|
|
|
74
78
|
onReplay?: (inboxMessageId: string) => void,
|
|
75
79
|
lifecycle?: LifecycleControl,
|
|
76
80
|
history?: () => HistoryContext,
|
|
81
|
+
compaction?: CompactionControl,
|
|
77
82
|
) {
|
|
78
83
|
this.socket = socket;
|
|
79
84
|
this.onSubmit = onSubmit;
|
|
@@ -81,6 +86,7 @@ class RpcSocket {
|
|
|
81
86
|
this.onReplay = onReplay;
|
|
82
87
|
this.lifecycle = lifecycle;
|
|
83
88
|
this.history = history;
|
|
89
|
+
this.compaction = compaction;
|
|
84
90
|
socket.on("data", (chunk: Buffer) => this.receive(chunk));
|
|
85
91
|
socket.on("error", (error) => this.fail(error));
|
|
86
92
|
socket.on("close", () =>
|
|
@@ -195,6 +201,26 @@ class RpcSocket {
|
|
|
195
201
|
respond({ result: { pong: true } });
|
|
196
202
|
return;
|
|
197
203
|
}
|
|
204
|
+
if (message.method === "compact" && this.compaction) {
|
|
205
|
+
if (
|
|
206
|
+
Object.keys(params).some((key) => key !== "operation_id") ||
|
|
207
|
+
typeof params.operation_id !== "string" ||
|
|
208
|
+
!params.operation_id.trim()
|
|
209
|
+
) {
|
|
210
|
+
error(-32602, "compact requires an operation_id");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
this.compaction.compact(params.operation_id);
|
|
215
|
+
respond({ result: { accepted: true } });
|
|
216
|
+
} catch (failure) {
|
|
217
|
+
error(
|
|
218
|
+
failure instanceof RpcError ? failure.code : -32006,
|
|
219
|
+
failure instanceof Error ? failure.message : String(failure),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
198
224
|
if (this.lifecycle && (message.method === "interrupt" || message.method === "shutdown")) {
|
|
199
225
|
try {
|
|
200
226
|
this.lifecycle[message.method as "interrupt" | "shutdown"]();
|
|
@@ -310,6 +336,7 @@ export async function connectPi(
|
|
|
310
336
|
onReplay?: (inboxMessageId: string) => void,
|
|
311
337
|
lifecycle?: LifecycleControl,
|
|
312
338
|
history?: () => HistoryContext,
|
|
339
|
+
compaction?: CompactionControl,
|
|
313
340
|
): Promise<PiConnection> {
|
|
314
341
|
const path = piSocketPath(pontiaHome);
|
|
315
342
|
let identity: ControlIdentity | undefined;
|
|
@@ -381,6 +408,7 @@ export async function connectPi(
|
|
|
381
408
|
reportingOnly ? undefined : onReplay,
|
|
382
409
|
reportingOnly ? undefined : lifecycle,
|
|
383
410
|
reportingOnly ? undefined : history,
|
|
411
|
+
reportingOnly ? undefined : compaction,
|
|
384
412
|
);
|
|
385
413
|
peers.add(peer);
|
|
386
414
|
socket.once("close", () => {
|
|
@@ -434,16 +462,6 @@ export async function connectPi(
|
|
|
434
462
|
current = await open();
|
|
435
463
|
return {
|
|
436
464
|
async request(method, params) {
|
|
437
|
-
if (method === "turn.startFailure") {
|
|
438
|
-
if (!identity?.clientSessionKey || stopped)
|
|
439
|
-
throw new Error("Pi reporting identity is unavailable");
|
|
440
|
-
await ready();
|
|
441
|
-
if (!current || stopped) throw new Error("Pi connection is unavailable");
|
|
442
|
-
return current.request(method, {
|
|
443
|
-
...params,
|
|
444
|
-
client_session_key: identity.clientSessionKey,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
465
|
await ready();
|
|
448
466
|
if (!current || stopped) return Promise.reject(new Error("Pi connection is unavailable"));
|
|
449
467
|
return current.request(method, params);
|
package/src/events.ts
CHANGED
|
@@ -9,6 +9,9 @@ export type InternalEventType =
|
|
|
9
9
|
| "session.exited"
|
|
10
10
|
| "session.model_updated"
|
|
11
11
|
| "session.context_usage_updated"
|
|
12
|
+
| "session.compaction_started"
|
|
13
|
+
| "session.compaction_completed"
|
|
14
|
+
| "session.compaction_failed"
|
|
12
15
|
| "turn.started"
|
|
13
16
|
| "turn.output"
|
|
14
17
|
| "turn.completed"
|
|
@@ -58,6 +61,7 @@ export interface PiTopologyContext {
|
|
|
58
61
|
export interface InternalEvent {
|
|
59
62
|
session_id: string;
|
|
60
63
|
turn_id?: string;
|
|
64
|
+
report_key?: string;
|
|
61
65
|
type: InternalEventType;
|
|
62
66
|
data: Record<string, unknown>;
|
|
63
67
|
[legacyField: string]: any;
|
|
@@ -311,6 +315,7 @@ export function contextUsageFromPiHook(
|
|
|
311
315
|
|
|
312
316
|
export function buildTurnStartedEvent(
|
|
313
317
|
context: TurnContext,
|
|
318
|
+
reportKey: string,
|
|
314
319
|
previousLeafId: string | null = null,
|
|
315
320
|
topologyContext?: PiTopologyContext,
|
|
316
321
|
): InternalEvent {
|
|
@@ -324,7 +329,12 @@ export function buildTurnStartedEvent(
|
|
|
324
329
|
};
|
|
325
330
|
if (context.inboxMessageId) data.inbox_message_id = context.inboxMessageId;
|
|
326
331
|
if (topologyContext) data.topology_context = topologyContext;
|
|
327
|
-
return {
|
|
332
|
+
return {
|
|
333
|
+
session_id: context.sessionId,
|
|
334
|
+
report_key: reportKey,
|
|
335
|
+
type: "turn.started",
|
|
336
|
+
data,
|
|
337
|
+
};
|
|
328
338
|
}
|
|
329
339
|
|
|
330
340
|
export function buildTurnOutputEvent(context: ActiveTurnContext, output: string): InternalEvent {
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { HistoryContext } from "./history.js";
|
|
4
|
+
import { PiCompaction } from "./compaction.js";
|
|
4
5
|
import { connectPi, RpcError, type PiConnection, type ControlInput } from "./control-socket.js";
|
|
5
6
|
import { defaultHookLogFile, type EnvLike, type TurnContext } from "./context.js";
|
|
6
7
|
import { appendDiagnostic, type DiagnosticEntry } from "./diagnostics.js";
|
|
@@ -187,6 +188,13 @@ export function createPontiaPiExtension(
|
|
|
187
188
|
let controlGeneration = 0;
|
|
188
189
|
let piContext: ExtensionContext | undefined;
|
|
189
190
|
let changingModel = false;
|
|
191
|
+
const compaction = () => {
|
|
192
|
+
const context = boundSessionContext;
|
|
193
|
+
if (!context || reportingDisabled || !readyReported) return undefined;
|
|
194
|
+
return new PiCompaction(currentPontiaHome(), context, async (event) =>
|
|
195
|
+
reportAccepted(await makeReporter(currentHookLogFile()).report(context, event)),
|
|
196
|
+
);
|
|
197
|
+
};
|
|
190
198
|
const directInput = new AsyncLocalStorage<{
|
|
191
199
|
submission: ControlInput;
|
|
192
200
|
sessionContext: SessionContext;
|
|
@@ -317,6 +325,7 @@ export function createPontiaPiExtension(
|
|
|
317
325
|
if (generation !== controlGeneration)
|
|
318
326
|
throw new Error("Pi session changed during reconnect");
|
|
319
327
|
await reportModel();
|
|
328
|
+
await compaction()?.reconnect();
|
|
320
329
|
},
|
|
321
330
|
},
|
|
322
331
|
(inboxMessageId) => {
|
|
@@ -345,6 +354,26 @@ export function createPontiaPiExtension(
|
|
|
345
354
|
throw new Error("Pi history native Session identity changed");
|
|
346
355
|
return { generation, sessionManager: manager } satisfies HistoryContext;
|
|
347
356
|
},
|
|
357
|
+
{
|
|
358
|
+
compact(operationId) {
|
|
359
|
+
const ctx = lifecycleContext();
|
|
360
|
+
if (ctx.sessionManager.getSessionId() !== boundSessionContext?.clientSessionKey)
|
|
361
|
+
throw new RpcError(-32006, "Pi compaction native Session identity changed");
|
|
362
|
+
const observation = compaction()!;
|
|
363
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages() || queuedControlInput || changingModel)
|
|
364
|
+
throw new RpcError(-32006, "Pi is busy; compaction was not requested");
|
|
365
|
+
observation.request(operationId, () =>
|
|
366
|
+
ctx.compact({
|
|
367
|
+
onComplete: (result) => {
|
|
368
|
+
void observation.callbackComplete(operationId, result).catch(controlError);
|
|
369
|
+
},
|
|
370
|
+
onError: (error) => {
|
|
371
|
+
void observation.callbackError(operationId, error).catch(controlError);
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
},
|
|
376
|
+
},
|
|
348
377
|
);
|
|
349
378
|
if (generation !== controlGeneration) {
|
|
350
379
|
await socket.close();
|
|
@@ -519,6 +548,18 @@ export function createPontiaPiExtension(
|
|
|
519
548
|
await reportModel().catch(controlError);
|
|
520
549
|
});
|
|
521
550
|
|
|
551
|
+
pi.on("session_before_compact", async (event) => {
|
|
552
|
+
await compaction()?.before(event).catch(controlError);
|
|
553
|
+
});
|
|
554
|
+
pi.on("session_compact", async (event) => {
|
|
555
|
+
await compaction()?.completed(event).catch(controlError);
|
|
556
|
+
});
|
|
557
|
+
pi.on("session_compact_failed", async (event, ctx) => {
|
|
558
|
+
await compaction()
|
|
559
|
+
?.failed(event, leafIdFromHookContext(ctx ?? piContext))
|
|
560
|
+
.catch(controlError);
|
|
561
|
+
});
|
|
562
|
+
|
|
522
563
|
pi.on("session_start", async (event, ctx) => {
|
|
523
564
|
if (reportingDisabled) return;
|
|
524
565
|
const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
|
|
@@ -658,6 +699,7 @@ export function createPontiaPiExtension(
|
|
|
658
699
|
await makeReporter(logFile).report(context, buildSessionReadyEvent(context)),
|
|
659
700
|
);
|
|
660
701
|
await reportModel().catch(controlError);
|
|
702
|
+
await compaction()?.reconnect().catch(controlError);
|
|
661
703
|
} catch (error) {
|
|
662
704
|
await closeControlSocket();
|
|
663
705
|
const logFile = currentHookLogFile();
|
|
@@ -698,7 +740,7 @@ export function createPontiaPiExtension(
|
|
|
698
740
|
|
|
699
741
|
pi.on("agent_start", async (_event, ctx) => {
|
|
700
742
|
const topologyContext = topologyContextFromHookContext(ctx);
|
|
701
|
-
const
|
|
743
|
+
const reportKey = leafIdFromHookContext(ctx);
|
|
702
744
|
if (reportingDisabled) return;
|
|
703
745
|
try {
|
|
704
746
|
const dispatch = directInput.getStore();
|
|
@@ -708,6 +750,16 @@ export function createPontiaPiExtension(
|
|
|
708
750
|
: undefined;
|
|
709
751
|
if (dispatch) dispatch.consumed = true;
|
|
710
752
|
const logFile = currentHookLogFile();
|
|
753
|
+
if (!reportKey) {
|
|
754
|
+
activeTurn = undefined;
|
|
755
|
+
pendingPrompt = undefined;
|
|
756
|
+
await logDiagnostic(logFile, {
|
|
757
|
+
level: "error",
|
|
758
|
+
code: "turn_start_entry_id_missing",
|
|
759
|
+
message: "pi agent_start has no native session entry id; turn.started was not reported",
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
711
763
|
if (!boundSessionContext) {
|
|
712
764
|
const hookSessionDetails = piSessionDetailsFromHookContext(ctx);
|
|
713
765
|
const sessionDetails = hookSessionDetails.clientSessionKey
|
|
@@ -774,7 +826,7 @@ export function createPontiaPiExtension(
|
|
|
774
826
|
lastContextUsageJson = undefined;
|
|
775
827
|
const started = await reporter.report(
|
|
776
828
|
turnContext,
|
|
777
|
-
buildTurnStartedEvent(turnContext,
|
|
829
|
+
buildTurnStartedEvent(turnContext, reportKey, reportKey, topologyContext),
|
|
778
830
|
);
|
|
779
831
|
const canonicalTurnId = typeof started === "boolean" ? turnContext.turnId : started.turnId;
|
|
780
832
|
if (!reportAccepted(started) || !canonicalTurnId) {
|
package/src/reporter.ts
CHANGED
|
@@ -23,72 +23,52 @@ export class EventReporter {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
async report(context: { runtimeId: string }, event: InternalEvent): Promise<EventReportResult> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
runtime_id: context.runtimeId,
|
|
29
|
-
event,
|
|
30
|
-
});
|
|
31
|
-
const record =
|
|
32
|
-
body && typeof body === "object" ? (body as Record<string, unknown>) : undefined;
|
|
33
|
-
if (
|
|
34
|
-
event.type === "turn.started" &&
|
|
35
|
-
(record?.accepted !== true || typeof record?.turn_id !== "string" || !record.turn_id)
|
|
36
|
-
) {
|
|
37
|
-
await this.reportStartFailure(event, "missing_turn_id");
|
|
38
|
-
return { accepted: false };
|
|
39
|
-
}
|
|
40
|
-
if (record?.accepted !== true)
|
|
41
|
-
throw new Error("Pi event RPC returned an invalid acknowledgement");
|
|
42
|
-
return {
|
|
43
|
-
accepted: true,
|
|
44
|
-
eventId: typeof record.event_id === "string" ? record.event_id : undefined,
|
|
45
|
-
turnId: typeof record.turn_id === "string" ? record.turn_id : undefined,
|
|
46
|
-
};
|
|
47
|
-
} catch (error) {
|
|
48
|
-
await appendDiagnostic(this.logFile, {
|
|
49
|
-
level: "error",
|
|
50
|
-
code: "pi_event_report_failed",
|
|
51
|
-
message: `Pi event RPC failed for ${event.type}`,
|
|
52
|
-
details: {
|
|
53
|
-
event_type: event.type,
|
|
54
|
-
error: error instanceof Error ? error.message : String(error),
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
await this.reportStartFailure(
|
|
58
|
-
event,
|
|
59
|
-
error instanceof RpcError ? "event_rejected" : "transport_failed",
|
|
60
|
-
);
|
|
61
|
-
return { accepted: false };
|
|
62
|
-
}
|
|
63
|
-
}
|
|
26
|
+
const attempts = event.type === "turn.started" ? 3 : 1;
|
|
27
|
+
let lastError: unknown;
|
|
64
28
|
|
|
65
|
-
|
|
66
|
-
event: InternalEvent,
|
|
67
|
-
reason: "event_rejected" | "transport_failed" | "missing_turn_id",
|
|
68
|
-
): Promise<void> {
|
|
69
|
-
if (event.type !== "turn.started" || typeof event.data.runtime_id !== "string") return;
|
|
70
|
-
// A lost turn.started response is ambiguous: retrying it could create a
|
|
71
|
-
// second Turn. Only this fenced, idempotent failure notification is retried.
|
|
72
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
29
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
73
30
|
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
|
|
74
31
|
try {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
32
|
+
const body = await this.connection.request("event.report", {
|
|
33
|
+
runtime_id: context.runtimeId,
|
|
34
|
+
event,
|
|
35
|
+
});
|
|
36
|
+
const record =
|
|
37
|
+
body && typeof body === "object" ? (body as Record<string, unknown>) : undefined;
|
|
38
|
+
if (record?.accepted !== true) {
|
|
39
|
+
if (record?.accepted === false) {
|
|
40
|
+
lastError = new Error("Pi event report was rejected");
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
throw new Error("Pi event RPC returned an invalid acknowledgement");
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
event.type === "turn.started" &&
|
|
47
|
+
(typeof record.turn_id !== "string" || !record.turn_id)
|
|
48
|
+
) {
|
|
49
|
+
throw new Error("Pi turn.started acknowledgement has no canonical turn_id");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
accepted: true,
|
|
53
|
+
eventId: typeof record.event_id === "string" ? record.event_id : undefined,
|
|
54
|
+
turnId: typeof record.turn_id === "string" ? record.turn_id : undefined,
|
|
55
|
+
};
|
|
81
56
|
} catch (error) {
|
|
57
|
+
lastError = error;
|
|
82
58
|
if (error instanceof RpcError && error.code !== -32603) break;
|
|
83
|
-
// Exhaustion is visible locally; an unreachable server cannot persist
|
|
84
|
-
// a state transition. Never claim that this notification succeeded.
|
|
85
59
|
}
|
|
86
60
|
}
|
|
61
|
+
|
|
87
62
|
await appendDiagnostic(this.logFile, {
|
|
88
63
|
level: "error",
|
|
89
|
-
code: "
|
|
90
|
-
message:
|
|
91
|
-
details: {
|
|
64
|
+
code: "pi_event_report_failed",
|
|
65
|
+
message: `Pi event RPC failed for ${event.type}`,
|
|
66
|
+
details: {
|
|
67
|
+
event_type: event.type,
|
|
68
|
+
report_key: event.report_key,
|
|
69
|
+
error: lastError instanceof Error ? lastError.message : String(lastError),
|
|
70
|
+
},
|
|
92
71
|
});
|
|
72
|
+
return { accepted: false };
|
|
93
73
|
}
|
|
94
74
|
}
|