@fitchmultz/pi-subagents 0.52.1 → 0.52.3
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/CHANGELOG.md +20 -0
- package/dist/agent/child-runtime.js +4 -4
- package/dist/agent/host.js +13 -0
- package/dist/agent/index.js +4 -1
- package/dist/agent/observe-card.js +3 -3
- package/dist/agent/observe.js +30 -26
- package/dist/agent/parking.js +25 -5
- package/dist/agent/process.js +10 -3
- package/dist/agent/runner.js +4 -2
- package/dist/agent/usage.js +92 -47
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-mailbox.js +18 -7
- package/dist/pi-intercom/bridge-wake.js +7 -9
- package/dist/pi-intercom/inbound-journal.js +4 -3
- package/dist/pi-intercom/inbound-record.js +12 -0
- package/dist/pi-intercom/ui/session-format.js +15 -6
- package/docs/performance.md +125 -7
- package/package.json +1 -1
- package/scripts/native-package-smoke.mjs +12 -3
- package/src/agent/child-runtime.ts +4 -6
- package/src/agent/host.ts +15 -0
- package/src/agent/index.ts +4 -1
- package/src/agent/observe-card.ts +3 -3
- package/src/agent/observe.ts +39 -37
- package/src/agent/parking.ts +24 -8
- package/src/agent/process.ts +15 -4
- package/src/agent/runner.ts +4 -2
- package/src/agent/usage.ts +116 -60
- package/src/pi-intercom/bridge-mailbox.ts +17 -7
- package/src/pi-intercom/bridge-wake.ts +7 -8
- package/src/pi-intercom/inbound-journal.ts +9 -3
- package/src/pi-intercom/inbound-record.ts +12 -0
- package/src/pi-intercom/ui/session-format.ts +16 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.52.3] - 2026-10-29
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Build Intercom's duplicate-name set once per session list instead of once per row. Session names, duplicate labels and target resolution are unchanged.
|
|
10
|
+
- Hash parked child state in bounded chunks when answering history requests or restoring a child. The digest and native state validation are unchanged.
|
|
11
|
+
- Skip the bash-owner inventory lookup for child tool calls that are not bash commands. Bash ownership checks and child delegation restrictions are unchanged.
|
|
12
|
+
- Avoid revalidating unrelated incoming messages when looking up an Intercom receipt. Receipt contents and branch-wide history remain unchanged.
|
|
13
|
+
- Reuse the child-status widget's display between inventory updates instead of rebuilding it on every parent frame. Theme changes, activity, errors and click targets remain visible.
|
|
14
|
+
- Batch optional bridge acknowledgements in one SQLite transaction and stop reading wake notifications once the next actionable message is known. A failed acknowledgement leaves the group pending. Later non-wake cleanup can wait behind a pending wake.
|
|
15
|
+
- Stop the live child viewer from rebuilding native renderers after each streamed update. Assistant and tool cards now keep the native component's own update result and only refresh the card's outer layout. On the maintained fork, unchanged thinking or prose is no longer re-parsed while a later block streams. On both hosts, tool-argument and progress updates run native tool rendering once per update instead of twice. Visible output, theme changes, resize, selection and expansion are unchanged.
|
|
16
|
+
- Stop each child continuation from rereading its whole native journal to record usage. A resident child keeps the IDs and billing fields of records it already validated and parses only records appended after its previous LF boundary. Unchanged journals are not reread; same-size edits, shrink, inode replacement, non-v3 headers and failed reads rebuild from the start. Inherited baselines, terminal stop, required usage and attribution still apply to every run. A restarted or restored child and the parent's pending-accounting repair still read the journal once from the start.
|
|
17
|
+
- Reuse the running Pi's SDK for the first delegation, `/agents` and parked-history restore in production-only installs, instead of importing a second copy of the same SDK from disk. In five alternating pairs per host on a busy macOS machine, the parent no longer grew by 66–85 MiB during its first `delegate` call. The median time to that call's receipt fell from 1.4 s to 0.1 s on the maintained fork. Official Pi's median fell from 1.9 s to 0.1 s, but the size of that gain is inconclusive under the measured load: one run with the change took 2.3 s. Installs with their own SDK copy, a set `PI_PACKAGE_DIR` and detached children still load the selected Pi package directly.
|
|
18
|
+
|
|
19
|
+
## [0.52.2] - 2026-10-29
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Avoid rereading historical shell records on every idle parking check while a live shell still blocks parking. Retain one publication path and revalidate its current record, owner token and process birth. Read the full inventory again when that witness disappears or dies. Stop and cold continuation keep their complete inventory checks. No records are pruned and live jobs are not interrupted.
|
|
24
|
+
|
|
5
25
|
## [0.52.1] - 2026-10-29
|
|
6
26
|
|
|
7
27
|
### Fixed
|
|
@@ -6,11 +6,11 @@ function guard(pi, directory, token) {
|
|
|
6
6
|
if (event.toolName === "delegate" || event.toolName === "agent_runs") {
|
|
7
7
|
return { block: true, reason: "Only a main session may delegate or control children." };
|
|
8
8
|
}
|
|
9
|
+
if (event.toolName !== "bash" || typeof event.input.command !== "string") {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
9
12
|
const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
|
|
10
|
-
if (
|
|
11
|
-
typeof event.input.command === "string" &&
|
|
12
|
-
owner &&
|
|
13
|
-
(owner.source === "builtin" || localCwdOwner(owner))) {
|
|
13
|
+
if (owner && (owner.source === "builtin" || localCwdOwner(owner))) {
|
|
14
14
|
// Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
|
|
15
15
|
const input = event.input;
|
|
16
16
|
input.command = shellCommand(event.input.command, directory, token);
|
package/dist/agent/host.js
CHANGED
|
@@ -55,7 +55,20 @@ export function packageRoot() {
|
|
|
55
55
|
}
|
|
56
56
|
return path.dirname(manifest);
|
|
57
57
|
}
|
|
58
|
+
// Pi's resolution of this extension's SDK import: the running host in production installs,
|
|
59
|
+
// possibly a package-local copy in development checkouts.
|
|
60
|
+
let resolvedSDK;
|
|
61
|
+
export function rememberSDK(sdk) {
|
|
62
|
+
resolvedSDK = sdk;
|
|
63
|
+
}
|
|
58
64
|
export async function loadSDK(root) {
|
|
65
|
+
// Reuse it only as the selected root. PI_PACKAGE_DIR replaces getPackageDir()'s own
|
|
66
|
+
// location, so only an unset override proves that identity.
|
|
67
|
+
if (resolvedSDK !== undefined &&
|
|
68
|
+
(process.env.PI_PACKAGE_DIR ?? "") === "" &&
|
|
69
|
+
fs.realpathSync(resolvedSDK.getPackageDir()) === fs.realpathSync(root)) {
|
|
70
|
+
return resolvedSDK;
|
|
71
|
+
}
|
|
59
72
|
// Selected host SDK, not a second package-local runtime hidden behind Jiti aliases.
|
|
60
73
|
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
61
74
|
const sdk = await import(__rewriteRelativeImportExtension(pathToFileURL(path.join(root, "dist/index.js")).href));
|
package/dist/agent/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import * as loadedSDK from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { actor, alive, ensureActor, ownIdentity, parkFailure, requestStop, signalProcess, stopChildren, } from "./process.js";
|
|
5
|
-
import { isChild, errorText } from "./host.js";
|
|
6
|
+
import { isChild, errorText, rememberSDK } from "./host.js";
|
|
6
7
|
import { serveParked } from "./parking.js";
|
|
7
8
|
import { resultSchema, attachmentSchema, admissionSchema, commandSchema, } from "./contracts.js";
|
|
8
9
|
import { delegateSchema, runsSchema } from "./tool-schemas.js";
|
|
@@ -438,5 +439,7 @@ class AgentRuntime {
|
|
|
438
439
|
}
|
|
439
440
|
}
|
|
440
441
|
export default function agents(pi) {
|
|
442
|
+
// Delegation, /agents and parked history then avoid importing a second SDK graph.
|
|
443
|
+
rememberSDK(loadedSDK);
|
|
441
444
|
new AgentRuntime(pi).register();
|
|
442
445
|
}
|
|
@@ -137,7 +137,7 @@ export class Card {
|
|
|
137
137
|
if (this.arguments !== value.args) {
|
|
138
138
|
this.tool.updateArgs(projected(value.args) ?? {});
|
|
139
139
|
this.arguments = value.args;
|
|
140
|
-
this.
|
|
140
|
+
this.cache = undefined;
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
updateTool(value, options, preparing) {
|
|
@@ -156,7 +156,7 @@ export class Card {
|
|
|
156
156
|
this.phase = phase;
|
|
157
157
|
this.value = source.result;
|
|
158
158
|
this.ended = ended;
|
|
159
|
-
this.
|
|
159
|
+
this.cache = undefined;
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
preview(value, isError, partial) {
|
|
@@ -188,7 +188,7 @@ export class Card {
|
|
|
188
188
|
this.status?.setText(message.ended ? "Awaiting journal publication" : "In progress");
|
|
189
189
|
this.blocks = message.blocks.slice();
|
|
190
190
|
this.ended = message.ended;
|
|
191
|
-
this.
|
|
191
|
+
this.cache = undefined;
|
|
192
192
|
}
|
|
193
193
|
invalidate() {
|
|
194
194
|
this.cache = undefined;
|
package/dist/agent/observe.js
CHANGED
|
@@ -176,6 +176,7 @@ class Observer {
|
|
|
176
176
|
const scanning = this.update()
|
|
177
177
|
.catch((error) => {
|
|
178
178
|
this.error = plainText(errorText(error));
|
|
179
|
+
this.widget();
|
|
179
180
|
})
|
|
180
181
|
.finally(() => {
|
|
181
182
|
if (this.scanning !== scanning) {
|
|
@@ -267,21 +268,22 @@ class Observer {
|
|
|
267
268
|
this.widgetSignature = "";
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
270
|
-
const
|
|
271
|
+
const visible = rows.slice(0, 4);
|
|
272
|
+
const more = rows.length - visible.length;
|
|
273
|
+
const snapshot = { status: this.status(), rows: visible, more, error: this.error };
|
|
274
|
+
const signature = JSON.stringify(snapshot);
|
|
271
275
|
if (signature === this.widgetSignature) {
|
|
272
276
|
return;
|
|
273
277
|
}
|
|
274
278
|
this.widgetSignature = signature;
|
|
275
279
|
if (this.widgetVisible) {
|
|
276
|
-
this.widgetRender?.();
|
|
280
|
+
this.widgetRender?.(snapshot);
|
|
277
281
|
return;
|
|
278
282
|
}
|
|
279
283
|
this.widgetVisible = true;
|
|
280
284
|
ctx.ui.setWidget(STATUS_KEY, (tui, theme) => {
|
|
281
|
-
this.widgetRender = () => {
|
|
282
|
-
tui.requestRender();
|
|
283
|
-
};
|
|
284
285
|
const content = new Container();
|
|
286
|
+
let shown = snapshot;
|
|
285
287
|
const open = (id) => {
|
|
286
288
|
if (ctx !== this.ctx) {
|
|
287
289
|
return;
|
|
@@ -290,29 +292,31 @@ class Observer {
|
|
|
290
292
|
ctx.ui.notify(errorText(error), "error");
|
|
291
293
|
});
|
|
292
294
|
};
|
|
295
|
+
const build = () => {
|
|
296
|
+
content.clear();
|
|
297
|
+
content.addChild(actionText(theme.fg("accent", shown.status), () => open("")));
|
|
298
|
+
for (const row of shown.rows) {
|
|
299
|
+
const glyph = row.live ? "●" : "○";
|
|
300
|
+
const color = row.live ? "accent" : "warning";
|
|
301
|
+
content.addChild(actionText(`${theme.fg(color, glyph)} ${theme.bold(row.name)} · ${row.activity ?? row.status}`, () => open(row.id)));
|
|
302
|
+
}
|
|
303
|
+
if (shown.more > 0) {
|
|
304
|
+
content.addChild(actionText(theme.fg("dim", `+${shown.more} more · /agents`), () => open("")));
|
|
305
|
+
}
|
|
306
|
+
if (shown.error.length > 0) {
|
|
307
|
+
content.addChild(new TruncatedText(theme.fg("warning", shown.error), 0, 0));
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
build();
|
|
311
|
+
this.widgetRender = (next) => {
|
|
312
|
+
shown = next;
|
|
313
|
+
build();
|
|
314
|
+
tui.requestRender();
|
|
315
|
+
};
|
|
293
316
|
return {
|
|
294
|
-
render: (width) =>
|
|
295
|
-
const strip = this.strip();
|
|
296
|
-
const visible = strip.slice(0, 4);
|
|
297
|
-
content.clear();
|
|
298
|
-
content.addChild(actionText(theme.fg("accent", this.status()), () => open("")));
|
|
299
|
-
for (const row of visible) {
|
|
300
|
-
const glyph = row.live ? "●" : "○";
|
|
301
|
-
const color = row.live ? "accent" : "warning";
|
|
302
|
-
content.addChild(actionText(`${theme.fg(color, glyph)} ${theme.bold(row.name)} · ${row.activity ?? row.status}`, () => open(row.id)));
|
|
303
|
-
}
|
|
304
|
-
if (strip.length > visible.length) {
|
|
305
|
-
content.addChild(actionText(theme.fg("dim", `+${strip.length - visible.length} more · /agents`), () => open("")));
|
|
306
|
-
}
|
|
307
|
-
if (this.error.length > 0) {
|
|
308
|
-
content.addChild(new TruncatedText(theme.fg("warning", this.error), 0, 0));
|
|
309
|
-
}
|
|
310
|
-
return content.render(width);
|
|
311
|
-
},
|
|
317
|
+
render: (width) => content.render(width),
|
|
312
318
|
handleMouse: (event) => content.handleMouse(event),
|
|
313
|
-
invalidate:
|
|
314
|
-
// Render reads current rows, expansion and theme on every frame.
|
|
315
|
-
},
|
|
319
|
+
invalidate: build,
|
|
316
320
|
};
|
|
317
321
|
}, { placement: "aboveEditor" });
|
|
318
322
|
}
|
package/dist/agent/parking.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { parkedSchema } from "./contracts.js";
|
|
5
5
|
import { errorText, loadSDK } from "./host.js";
|
|
6
6
|
import { conversation, latest, launchAt, list, read, runDirectory, write } from "./storage.js";
|
|
7
|
-
import { alive, shells, wakeActor } from "./process.js";
|
|
7
|
+
import { alive, shell, shells, wakeActor } from "./process.js";
|
|
8
8
|
import { isHistoryRequest, PARK, PARKED_HISTORY, WAKE, } from "../shared/parking.js";
|
|
9
9
|
import { nativePeerId } from "../pi-intercom/session-targets.js";
|
|
10
10
|
function parkable(session) {
|
|
@@ -36,7 +36,21 @@ async function cut(pi, session, { quiet, errors, signal }) {
|
|
|
36
36
|
return undefined;
|
|
37
37
|
}
|
|
38
38
|
const PARK_AFTER_MS = 5 * 60_000;
|
|
39
|
-
|
|
39
|
+
/** SHA-256 of every state byte through one reused chunk, like the native state reader. */
|
|
40
|
+
function digest(file) {
|
|
41
|
+
const hash = createHash("sha256");
|
|
42
|
+
const fd = fs.openSync(file, "r");
|
|
43
|
+
try {
|
|
44
|
+
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
45
|
+
for (let count = fs.readSync(fd, chunk); count > 0; count = fs.readSync(fd, chunk)) {
|
|
46
|
+
hash.update(chunk.subarray(0, count));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
return hash.digest("hex");
|
|
53
|
+
}
|
|
40
54
|
/** Parked authority names this session and exactly the saved generation's state bytes. */
|
|
41
55
|
function exact(restore, parked, state, launch) {
|
|
42
56
|
if (parked.sessionId !== launch.sessionId ||
|
|
@@ -74,6 +88,7 @@ export class Parking {
|
|
|
74
88
|
restored;
|
|
75
89
|
since = 0;
|
|
76
90
|
leaf;
|
|
91
|
+
shell;
|
|
77
92
|
base;
|
|
78
93
|
launch;
|
|
79
94
|
token;
|
|
@@ -111,9 +126,7 @@ export class Parking {
|
|
|
111
126
|
}
|
|
112
127
|
/** True once this generation has exited its runtime with or without parked authority. */
|
|
113
128
|
async park(runtime, actor) {
|
|
114
|
-
if (!actor.pi ||
|
|
115
|
-
!this.due(runtime.session, actor.busy) ||
|
|
116
|
-
shells(this.base, this.launch, this.token).some(alive)) {
|
|
129
|
+
if (!actor.pi || !this.due(runtime.session, actor.busy) || this.hasShell()) {
|
|
117
130
|
return false;
|
|
118
131
|
}
|
|
119
132
|
const hold = await this.prepare(runtime, actor.pi, actor);
|
|
@@ -122,6 +135,13 @@ export class Parking {
|
|
|
122
135
|
}
|
|
123
136
|
return hold !== undefined;
|
|
124
137
|
}
|
|
138
|
+
hasShell() {
|
|
139
|
+
const witness = this.shell === undefined ? undefined : shell(this.shell, this.token);
|
|
140
|
+
if (!witness || !alive(witness)) {
|
|
141
|
+
this.shell = shells(this.base, this.launch, this.token).find(alive)?.file;
|
|
142
|
+
}
|
|
143
|
+
return this.shell !== undefined;
|
|
144
|
+
}
|
|
125
145
|
/** Final idle: no run, turn, journal change or viewer since the period began. */
|
|
126
146
|
due(session, busy) {
|
|
127
147
|
const leaf = session.sessionManager.getLeafId();
|
package/dist/agent/process.js
CHANGED
|
@@ -346,6 +346,13 @@ async function waitForExit(targets, timeout) {
|
|
|
346
346
|
await delay(25);
|
|
347
347
|
}
|
|
348
348
|
}
|
|
349
|
+
export function shell(file, token) {
|
|
350
|
+
const identity = published(file, read(file, processSchema));
|
|
351
|
+
if (identity && identity.token !== token) {
|
|
352
|
+
throw new Error("Foreign or missing native shell identity.");
|
|
353
|
+
}
|
|
354
|
+
return identity;
|
|
355
|
+
}
|
|
349
356
|
export function shells(base, launch, token) {
|
|
350
357
|
const dir = path.join(conversation(base, launch), "shells", token);
|
|
351
358
|
if (!fs.existsSync(dir)) {
|
|
@@ -356,11 +363,11 @@ export function shells(base, launch, token) {
|
|
|
356
363
|
.filter((name) => name.endsWith(".json"))
|
|
357
364
|
.map((name) => {
|
|
358
365
|
const file = path.join(dir, name);
|
|
359
|
-
const identity =
|
|
360
|
-
if (!identity
|
|
366
|
+
const identity = shell(file, token);
|
|
367
|
+
if (!identity) {
|
|
361
368
|
throw new Error("Foreign or missing native shell identity.");
|
|
362
369
|
}
|
|
363
|
-
return identity;
|
|
370
|
+
return Object.assign(identity, { file });
|
|
364
371
|
});
|
|
365
372
|
}
|
|
366
373
|
function quote(value) {
|
package/dist/agent/runner.js
CHANGED
|
@@ -5,7 +5,7 @@ import { launchAt, missing, read, runDirectory, write } from "./storage.js";
|
|
|
5
5
|
import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.js";
|
|
6
6
|
import { errorText, loadSDK } from "./host.js";
|
|
7
7
|
import { Parking } from "./parking.js";
|
|
8
|
-
import {
|
|
8
|
+
import { UsageIndex } from "./usage.js";
|
|
9
9
|
import { initializeCwd } from "./cwd.js";
|
|
10
10
|
import { childRuntime } from "./child-runtime.js";
|
|
11
11
|
import { ActorObservation } from "./observation.js";
|
|
@@ -47,6 +47,8 @@ class Actor {
|
|
|
47
47
|
started = new Set();
|
|
48
48
|
// Actor-local validation metadata only; never retain command prompts or receipt authority.
|
|
49
49
|
validated = new Map();
|
|
50
|
+
// Every run of this conversation shares one native journal; later runs read only its suffix.
|
|
51
|
+
accounting = new UsageIndex();
|
|
50
52
|
session;
|
|
51
53
|
observation;
|
|
52
54
|
context;
|
|
@@ -97,7 +99,7 @@ class Actor {
|
|
|
97
99
|
}
|
|
98
100
|
usage(launch, baseline, terminal) {
|
|
99
101
|
try {
|
|
100
|
-
return { contributions:
|
|
102
|
+
return { contributions: this.accounting.read(launch, baseline, terminal) };
|
|
101
103
|
}
|
|
102
104
|
catch (error) {
|
|
103
105
|
return {
|
package/dist/agent/usage.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
-
import { journalStamp } from "../shared/native-journal.js";
|
|
3
|
+
import { journalStamp, nativeAppend } from "../shared/native-journal.js";
|
|
4
4
|
import { nativeProjection } from "../shared/journal-projections.js";
|
|
5
5
|
import { scanJournal } from "../shared/journal-frames.js";
|
|
6
6
|
import { isRecord } from "../shared/unknown.js";
|
|
7
7
|
import { Assert } from "./typebox.js";
|
|
8
8
|
import { contributionsSchema, resultSchema, } from "./contracts.js";
|
|
9
9
|
import { read, runDirectory, write } from "./storage.js";
|
|
10
|
+
/** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
|
|
10
11
|
function billing(entry) {
|
|
11
12
|
if (entry.checkpoint === true) {
|
|
12
13
|
return;
|
|
@@ -16,11 +17,11 @@ function billing(entry) {
|
|
|
16
17
|
(entry.type === "message" && !["assistant", "toolResult"].some((role) => role === message.role))) {
|
|
17
18
|
return;
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
throw new Error("Required native usage is unavailable.");
|
|
20
|
+
if (message.usage === undefined) {
|
|
21
|
+
return entry.type === "usage" || message.role === "assistant" ? "missing" : undefined;
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
const { role, provider, model, responseModel, usage } = message;
|
|
24
|
+
return { role, provider, model, responseModel, usage };
|
|
24
25
|
}
|
|
25
26
|
function attribution(message, launch) {
|
|
26
27
|
if (message.role === "toolResult") {
|
|
@@ -34,56 +35,100 @@ function attribution(message, launch) {
|
|
|
34
35
|
model: typeof model === "string" ? model : launch.model,
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
function identify(header, launch) {
|
|
39
|
+
if (header.type !== "session" || header.id !== launch.sessionId) {
|
|
40
|
+
throw new Error("Foreign native usage journal identity.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** An unchanged or native v3 appended journal keeps its prefix; any other change rebuilds it. */
|
|
44
|
+
function reusable(cached, stat) {
|
|
45
|
+
return cached !== undefined &&
|
|
46
|
+
(journalStamp(cached.stat) === journalStamp(stat) ||
|
|
47
|
+
nativeAppend(cached.stat, stat, cached.header))
|
|
48
|
+
? cached
|
|
49
|
+
: { stat, end: 0, entries: [] };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Child usage of one physical native journal. A retained index parses only records after its last
|
|
53
|
+
* accepted LF boundary. Baselines, required usage and attribution apply to each read.
|
|
54
|
+
*/
|
|
55
|
+
export class UsageIndex {
|
|
56
|
+
prefix;
|
|
57
|
+
read(launch, baseline, terminal) {
|
|
58
|
+
const values = [];
|
|
59
|
+
const reachedTerminal = new Error("Native terminal boundary reached.");
|
|
60
|
+
const charge = (entry) => {
|
|
61
|
+
const billed = baseline.has(entry.id) ? undefined : entry.billing;
|
|
62
|
+
if (billed === "missing") {
|
|
63
|
+
throw new Error("Required native usage is unavailable.");
|
|
64
|
+
}
|
|
65
|
+
if (billed !== undefined) {
|
|
66
|
+
values.push({
|
|
67
|
+
id: `${launch.sessionId}:${entry.id}`,
|
|
68
|
+
...attribution(billed, launch),
|
|
69
|
+
usage: billed.usage,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (entry.id === terminal) {
|
|
73
|
+
throw reachedTerminal;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const cached = this.prefix;
|
|
77
|
+
// Only a completed read is retained.
|
|
78
|
+
this.prefix = undefined;
|
|
79
|
+
const fd = fs.openSync(launch.sessionFile, "r");
|
|
44
80
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (typeof value.id !== "string" || typeof value.type !== "string") {
|
|
54
|
-
throw new Error("Missing native usage entry identity.");
|
|
81
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
82
|
+
const prefix = reusable(cached, stat);
|
|
83
|
+
let ended = false;
|
|
84
|
+
try {
|
|
85
|
+
if (prefix.header !== undefined) {
|
|
86
|
+
identify(prefix.header, launch);
|
|
55
87
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
values.push({
|
|
59
|
-
id: `${launch.sessionId}:${value.id}`,
|
|
60
|
-
...attribution(message, launch),
|
|
61
|
-
usage: message.usage,
|
|
62
|
-
});
|
|
88
|
+
for (const entry of prefix.entries) {
|
|
89
|
+
charge(entry);
|
|
63
90
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
scanJournal(fd, nativeProjection, ({ value, end }) => {
|
|
92
|
+
if (prefix.header === undefined) {
|
|
93
|
+
identify(value, launch);
|
|
94
|
+
prefix.header = value;
|
|
95
|
+
prefix.end = end;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (typeof value.id !== "string" || typeof value.type !== "string") {
|
|
99
|
+
throw new Error("Missing native usage entry identity.");
|
|
100
|
+
}
|
|
101
|
+
const entry = { id: value.id, billing: billing(value) };
|
|
102
|
+
prefix.entries.push(entry);
|
|
103
|
+
prefix.end = end;
|
|
104
|
+
charge(entry);
|
|
105
|
+
}, { policy: "live", requireNewline: true, start: prefix.end });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error !== reachedTerminal) {
|
|
109
|
+
throw error;
|
|
67
110
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
throw error;
|
|
111
|
+
ended = true;
|
|
112
|
+
}
|
|
113
|
+
if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== journalStamp(stat)) {
|
|
114
|
+
throw new Error("Native usage journal changed while reading.");
|
|
73
115
|
}
|
|
116
|
+
if (prefix.header === undefined || (terminal !== undefined && !ended)) {
|
|
117
|
+
throw new Error("Native terminal or session identity is not LF-published.");
|
|
118
|
+
}
|
|
119
|
+
Assert(contributionsSchema, values);
|
|
120
|
+
prefix.stat = stat;
|
|
121
|
+
this.prefix = prefix;
|
|
122
|
+
return values;
|
|
74
123
|
}
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
finally {
|
|
125
|
+
fs.closeSync(fd);
|
|
77
126
|
}
|
|
78
127
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
throw new Error("Native terminal or session identity is not LF-published.");
|
|
84
|
-
}
|
|
85
|
-
Assert(contributionsSchema, values);
|
|
86
|
-
return values;
|
|
128
|
+
}
|
|
129
|
+
/** A fresh read for callers without an owner lifetime. */
|
|
130
|
+
export function childUsage(launch, baseline, terminal) {
|
|
131
|
+
return new UsageIndex().read(launch, baseline, terminal);
|
|
87
132
|
}
|
|
88
133
|
export function readCanonicalResult(base, launch) {
|
|
89
134
|
const file = path.join(runDirectory(base, launch.id), "result.json");
|
package/dist/build-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.3","sha256":"56e15afa9487e79b68bb9a924bf3cbb4a62a8492cf4fe882d0496263f87a1e33"});
|
|
@@ -146,14 +146,25 @@ export class Mailbox {
|
|
|
146
146
|
}
|
|
147
147
|
ack(ids) {
|
|
148
148
|
const acked = [];
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
this.stored(() => {
|
|
150
|
+
// One FULL commit per ACK group; a rollback leaves every listed payload and intent pending.
|
|
151
|
+
this.db?.exec("BEGIN IMMEDIATE");
|
|
152
|
+
try {
|
|
153
|
+
for (const id of ids) {
|
|
154
|
+
const entry = this.get(id);
|
|
155
|
+
if (entry) {
|
|
156
|
+
this.notificationAccepted(id);
|
|
157
|
+
this.remove(id);
|
|
158
|
+
acked.push(id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
this.db?.exec("COMMIT");
|
|
155
162
|
}
|
|
156
|
-
|
|
163
|
+
catch (error) {
|
|
164
|
+
this.db?.exec("ROLLBACK");
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
157
168
|
return { acked };
|
|
158
169
|
}
|
|
159
170
|
page(after, limit) {
|
|
@@ -110,22 +110,20 @@ export class WakeDeliveries {
|
|
|
110
110
|
}
|
|
111
111
|
pending(delivery) {
|
|
112
112
|
let after = 0;
|
|
113
|
-
let next;
|
|
114
113
|
let page;
|
|
115
114
|
do {
|
|
116
|
-
|
|
115
|
+
// Decode only the retired prefix and the oldest actionable entry, not the whole backlog.
|
|
116
|
+
page = delivery.mailbox.pendingNotifications(after, 1);
|
|
117
117
|
for (const entry of page.messages) {
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
delivery.mailbox.notificationAccepted(entry.message.id);
|
|
121
|
-
}
|
|
122
|
-
else if (!next) {
|
|
123
|
-
next = entry;
|
|
118
|
+
if (actionable(entry)) {
|
|
119
|
+
return entry;
|
|
124
120
|
}
|
|
121
|
+
// Retire only notification intent. Payload/reply obligations require actual ACK/reply.
|
|
122
|
+
delivery.mailbox.notificationAccepted(entry.message.id);
|
|
125
123
|
}
|
|
126
124
|
after = Number(page.nextCursor);
|
|
127
125
|
} while (page.hasMore);
|
|
128
|
-
return
|
|
126
|
+
return undefined;
|
|
129
127
|
}
|
|
130
128
|
groups() {
|
|
131
129
|
const groups = new Map();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { isContextRequest } from "./lifecycle.js";
|
|
3
3
|
import { INBOUND_CHECKPOINT_TYPE, } from "./runtime-types.js";
|
|
4
|
-
import { isInboundCheckpoint, publicationMetadata, requestedDelivery } from "./inbound-record.js";
|
|
4
|
+
import { checkpointMessageId, isInboundCheckpoint, publicationMetadata, requestedDelivery, } from "./inbound-record.js";
|
|
5
5
|
import { isReceiptQuery, } from "./types.js";
|
|
6
6
|
import { inboundIdFromCustomMessage } from "./message-format.js";
|
|
7
7
|
import { isRecord } from "./validation.js";
|
|
@@ -40,6 +40,7 @@ export class InboundJournal {
|
|
|
40
40
|
receiptEntry(receipt, query, item) {
|
|
41
41
|
if (item.type === "custom" &&
|
|
42
42
|
item.customType === INBOUND_CHECKPOINT_TYPE &&
|
|
43
|
+
checkpointMessageId(item.data) === query.messageId &&
|
|
43
44
|
isInboundCheckpoint(item.data) &&
|
|
44
45
|
item.data.sessionId === query.rootSessionId) {
|
|
45
46
|
return this.receiptCheckpoint(receipt, query, item.id, item.data);
|
|
@@ -50,7 +51,7 @@ export class InboundJournal {
|
|
|
50
51
|
}
|
|
51
52
|
receiptCheckpoint(receipt, query, entryId, data) {
|
|
52
53
|
if ("entry" in data) {
|
|
53
|
-
if (receipt
|
|
54
|
+
if (receipt) {
|
|
54
55
|
return receipt;
|
|
55
56
|
}
|
|
56
57
|
const { from, message, stage, receivedAt } = data.entry;
|
|
@@ -74,7 +75,7 @@ export class InboundJournal {
|
|
|
74
75
|
discarded: false,
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
|
-
if (!receipt
|
|
78
|
+
if (!receipt) {
|
|
78
79
|
return receipt;
|
|
79
80
|
}
|
|
80
81
|
return {
|
|
@@ -82,3 +82,15 @@ export function isInboundCheckpoint(value) {
|
|
|
82
82
|
value.stage === "discarded" ||
|
|
83
83
|
value.stage === "reply-retired"));
|
|
84
84
|
}
|
|
85
|
+
/** Unvalidated: lets a receipt skip checkpoints for other messages before full validation. */
|
|
86
|
+
export function checkpointMessageId(value) {
|
|
87
|
+
if (!isRecord(value)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if ("entry" in value) {
|
|
91
|
+
return isRecord(value.entry) && isRecord(value.entry.message)
|
|
92
|
+
? value.entry.message.id
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
return value.messageId;
|
|
96
|
+
}
|