@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.
@@ -1,9 +1,9 @@
1
1
  import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
- import { journalStamp } from "../shared/native-journal.ts";
3
+ import { journalStamp, nativeAppend } from "../shared/native-journal.ts";
4
4
  import { nativeProjection } from "../shared/journal-projections.ts";
5
5
  import { scanJournal } from "../shared/journal-frames.ts";
6
- import { isRecord } from "../shared/unknown.ts";
6
+ import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
7
7
  import { Assert } from "./typebox.ts";
8
8
  import {
9
9
  contributionsSchema,
@@ -14,7 +14,8 @@ import {
14
14
  } from "./contracts.ts";
15
15
  import { read, runDirectory, write } from "./storage.ts";
16
16
 
17
- function billing(entry: Record<string, unknown>) {
17
+ /** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
18
+ function billing(entry: UnknownRecord): UnknownRecord | "missing" | undefined {
18
19
  if (entry.checkpoint === true) {
19
20
  return;
20
21
  }
@@ -25,13 +26,13 @@ function billing(entry: Record<string, unknown>) {
25
26
  ) {
26
27
  return;
27
28
  }
28
- const required = entry.type === "usage" || message.role === "assistant";
29
- if (required && message.usage === undefined) {
30
- throw new Error("Required native usage is unavailable.");
29
+ if (message.usage === undefined) {
30
+ return entry.type === "usage" || message.role === "assistant" ? "missing" : undefined;
31
31
  }
32
- return message.usage === undefined ? undefined : message;
32
+ const { role, provider, model, responseModel, usage } = message;
33
+ return { role, provider, model, responseModel, usage };
33
34
  }
34
- function attribution(message: Record<string, unknown>, launch: Launch) {
35
+ function attribution(message: UnknownRecord, launch: Launch) {
35
36
  if (message.role === "toolResult") {
36
37
  return { provider: launch.provider, model: launch.model };
37
38
  }
@@ -44,63 +45,118 @@ function attribution(message: Record<string, unknown>, launch: Launch) {
44
45
  model: typeof model === "string" ? model : launch.model,
45
46
  };
46
47
  }
47
- export function childUsage(
48
- launch: Launch,
49
- baseline: ReadonlySet<string>,
50
- terminal?: string,
51
- ): Contribution[] {
52
- const values: unknown[] = [];
53
- const observed = { header: false, ended: false };
54
- const reachedTerminal = new Error("Native terminal boundary reached.");
55
- const fd = fs.openSync(launch.sessionFile, "r");
56
- try {
57
- const stamp = journalStamp(fs.fstatSync(fd, { bigint: true }));
48
+ interface Entry {
49
+ readonly id: string;
50
+ readonly billing: UnknownRecord | "missing" | undefined;
51
+ }
52
+ /** Accounting metadata of the validated LF-published journal records before `end`. */
53
+ interface Prefix {
54
+ stat: fs.BigIntStats;
55
+ end: number;
56
+ header?: UnknownRecord;
57
+ readonly entries: Entry[];
58
+ }
59
+ function identify(header: UnknownRecord, launch: Launch): void {
60
+ if (header.type !== "session" || header.id !== launch.sessionId) {
61
+ throw new Error("Foreign native usage journal identity.");
62
+ }
63
+ }
64
+ /** An unchanged or native v3 appended journal keeps its prefix; any other change rebuilds it. */
65
+ function reusable(cached: Prefix | undefined, stat: fs.BigIntStats): Prefix {
66
+ return cached !== undefined &&
67
+ (journalStamp(cached.stat) === journalStamp(stat) ||
68
+ nativeAppend(cached.stat, stat, cached.header))
69
+ ? cached
70
+ : { stat, end: 0, entries: [] };
71
+ }
72
+ /**
73
+ * Child usage of one physical native journal. A retained index parses only records after its last
74
+ * accepted LF boundary. Baselines, required usage and attribution apply to each read.
75
+ */
76
+ export class UsageIndex {
77
+ private prefix?: Prefix;
78
+ read(launch: Launch, baseline: ReadonlySet<string>, terminal?: string): Contribution[] {
79
+ const values: unknown[] = [];
80
+ const reachedTerminal = new Error("Native terminal boundary reached.");
81
+ const charge = (entry: Entry): void => {
82
+ const billed = baseline.has(entry.id) ? undefined : entry.billing;
83
+ if (billed === "missing") {
84
+ throw new Error("Required native usage is unavailable.");
85
+ }
86
+ if (billed !== undefined) {
87
+ values.push({
88
+ id: `${launch.sessionId}:${entry.id}`,
89
+ ...attribution(billed, launch),
90
+ usage: billed.usage,
91
+ });
92
+ }
93
+ if (entry.id === terminal) {
94
+ throw reachedTerminal;
95
+ }
96
+ };
97
+ const cached = this.prefix;
98
+ // Only a completed read is retained.
99
+ this.prefix = undefined;
100
+ const fd = fs.openSync(launch.sessionFile, "r");
58
101
  try {
59
- scanJournal(
60
- fd,
61
- nativeProjection,
62
- ({ value }) => {
63
- if (!observed.header) {
64
- if (value.type !== "session" || value.id !== launch.sessionId) {
65
- throw new Error("Foreign native usage journal identity.");
102
+ const stat = fs.fstatSync(fd, { bigint: true });
103
+ const prefix = reusable(cached, stat);
104
+ let ended = false;
105
+ try {
106
+ if (prefix.header !== undefined) {
107
+ identify(prefix.header, launch);
108
+ }
109
+ for (const entry of prefix.entries) {
110
+ charge(entry);
111
+ }
112
+ scanJournal(
113
+ fd,
114
+ nativeProjection,
115
+ ({ value, end }) => {
116
+ if (prefix.header === undefined) {
117
+ identify(value, launch);
118
+ prefix.header = value;
119
+ prefix.end = end;
120
+ return;
121
+ }
122
+ if (typeof value.id !== "string" || typeof value.type !== "string") {
123
+ throw new Error("Missing native usage entry identity.");
66
124
  }
67
- observed.header = true;
68
- return;
69
- }
70
- if (typeof value.id !== "string" || typeof value.type !== "string") {
71
- throw new Error("Missing native usage entry identity.");
72
- }
73
- const message = baseline.has(value.id) ? undefined : billing(value);
74
- if (message) {
75
- values.push({
76
- id: `${launch.sessionId}:${value.id}`,
77
- ...attribution(message, launch),
78
- usage: message.usage,
79
- });
80
- }
81
- if (value.id === terminal) {
82
- observed.ended = true;
83
- throw reachedTerminal;
84
- }
85
- },
86
- { policy: "live", requireNewline: true },
87
- );
88
- } catch (error) {
89
- if (error !== reachedTerminal) {
90
- throw error;
125
+ const entry = { id: value.id, billing: billing(value) };
126
+ prefix.entries.push(entry);
127
+ prefix.end = end;
128
+ charge(entry);
129
+ },
130
+ { policy: "live", requireNewline: true, start: prefix.end },
131
+ );
132
+ } catch (error) {
133
+ if (error !== reachedTerminal) {
134
+ throw error;
135
+ }
136
+ ended = true;
91
137
  }
138
+ if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== journalStamp(stat)) {
139
+ throw new Error("Native usage journal changed while reading.");
140
+ }
141
+ if (prefix.header === undefined || (terminal !== undefined && !ended)) {
142
+ throw new Error("Native terminal or session identity is not LF-published.");
143
+ }
144
+ Assert(contributionsSchema, values);
145
+ prefix.stat = stat;
146
+ this.prefix = prefix;
147
+ return values;
148
+ } finally {
149
+ fs.closeSync(fd);
92
150
  }
93
- if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== stamp) {
94
- throw new Error("Native usage journal changed while reading.");
95
- }
96
- } finally {
97
- fs.closeSync(fd);
98
- }
99
- if (!observed.header || (terminal !== undefined && !observed.ended)) {
100
- throw new Error("Native terminal or session identity is not LF-published.");
101
151
  }
102
- Assert(contributionsSchema, values);
103
- return values;
152
+ }
153
+ /** A fresh read for callers without an owner lifetime. */
154
+ export function childUsage(
155
+ launch: Launch,
156
+ baseline: ReadonlySet<string>,
157
+ terminal?: string,
158
+ ): Contribution[] {
159
+ return new UsageIndex().read(launch, baseline, terminal);
104
160
  }
105
161
  export function readCanonicalResult(base: string, launch: Launch): Result | undefined {
106
162
  const file = path.join(runDirectory(base, launch.id), "result.json");
@@ -196,14 +196,24 @@ export class Mailbox {
196
196
  }
197
197
  ack(ids: readonly string[]): UnknownRecord {
198
198
  const acked: string[] = [];
199
- for (const id of ids) {
200
- const entry = this.get(id);
201
- if (entry) {
202
- this.notificationAccepted(id);
203
- this.remove(id);
204
- acked.push(id);
199
+ this.stored(() => {
200
+ // One FULL commit per ACK group; a rollback leaves every listed payload and intent pending.
201
+ this.db?.exec("BEGIN IMMEDIATE");
202
+ try {
203
+ for (const id of ids) {
204
+ const entry = this.get(id);
205
+ if (entry) {
206
+ this.notificationAccepted(id);
207
+ this.remove(id);
208
+ acked.push(id);
209
+ }
210
+ }
211
+ this.db?.exec("COMMIT");
212
+ } catch (error) {
213
+ this.db?.exec("ROLLBACK");
214
+ throw error;
205
215
  }
206
- }
216
+ });
207
217
  return { acked };
208
218
  }
209
219
  page(after: number, limit: number): InboxPage {
@@ -138,21 +138,20 @@ export class WakeDeliveries {
138
138
 
139
139
  private pending(delivery: Delivery): PendingNotification | undefined {
140
140
  let after = 0;
141
- let next: PendingNotification | undefined;
142
141
  let page;
143
142
  do {
144
- page = delivery.mailbox.pendingNotifications(after);
143
+ // Decode only the retired prefix and the oldest actionable entry, not the whole backlog.
144
+ page = delivery.mailbox.pendingNotifications(after, 1);
145
145
  for (const entry of page.messages) {
146
- if (!actionable(entry)) {
147
- // Retire only notification intent. Payload/reply obligations require actual ACK/reply.
148
- delivery.mailbox.notificationAccepted(entry.message.id);
149
- } else if (!next) {
150
- next = entry;
146
+ if (actionable(entry)) {
147
+ return entry;
151
148
  }
149
+ // Retire only notification intent. Payload/reply obligations require actual ACK/reply.
150
+ delivery.mailbox.notificationAccepted(entry.message.id);
152
151
  }
153
152
  after = Number(page.nextCursor);
154
153
  } while (page.hasMore);
155
- return next;
154
+ return undefined;
156
155
  }
157
156
 
158
157
  private groups(): Map<string, Candidate | undefined> {
@@ -8,7 +8,12 @@ import {
8
8
  type InboundMessageEntry,
9
9
  type PendingInboundMessage,
10
10
  } from "./runtime-types.ts";
11
- import { isInboundCheckpoint, publicationMetadata, requestedDelivery } from "./inbound-record.ts";
11
+ import {
12
+ checkpointMessageId,
13
+ isInboundCheckpoint,
14
+ publicationMetadata,
15
+ requestedDelivery,
16
+ } from "./inbound-record.ts";
12
17
  import {
13
18
  isReceiptQuery,
14
19
  type NativeReceipt,
@@ -66,6 +71,7 @@ export class InboundJournal {
66
71
  if (
67
72
  item.type === "custom" &&
68
73
  item.customType === INBOUND_CHECKPOINT_TYPE &&
74
+ checkpointMessageId(item.data) === query.messageId &&
69
75
  isInboundCheckpoint(item.data) &&
70
76
  item.data.sessionId === query.rootSessionId
71
77
  ) {
@@ -82,7 +88,7 @@ export class InboundJournal {
82
88
  data: InboundCheckpoint,
83
89
  ): NativeReceipt | undefined {
84
90
  if ("entry" in data) {
85
- if (receipt || data.entry.message.id !== query.messageId) {
91
+ if (receipt) {
86
92
  return receipt;
87
93
  }
88
94
  const { from, message, stage, receivedAt } = data.entry;
@@ -106,7 +112,7 @@ export class InboundJournal {
106
112
  discarded: false,
107
113
  };
108
114
  }
109
- if (!receipt || data.messageId !== query.messageId) {
115
+ if (!receipt) {
110
116
  return receipt;
111
117
  }
112
118
  return {
@@ -97,3 +97,15 @@ export function isInboundCheckpoint(
97
97
  value.stage === "reply-retired")
98
98
  );
99
99
  }
100
+ /** Unvalidated: lets a receipt skip checkpoints for other messages before full validation. */
101
+ export function checkpointMessageId(value: unknown): unknown {
102
+ if (!isRecord(value)) {
103
+ return;
104
+ }
105
+ if ("entry" in value) {
106
+ return isRecord(value.entry) && isRecord(value.entry.message)
107
+ ? value.entry.message.id
108
+ : undefined;
109
+ }
110
+ return value.messageId;
111
+ }
@@ -11,10 +11,19 @@ function localForkStartCommand(): string {
11
11
  return `pi --name worker --extension ${shellQuote(path.join(PACKAGE_ROOT, "src", "index.ts"))}`;
12
12
  }
13
13
  function duplicateNames(sessions: readonly SessionInfo[]): ReadonlySet<string> {
14
- const names = sessions
15
- .map((session) => session.name?.toLowerCase())
16
- .filter((name): name is string => name !== undefined && name !== "");
17
- return new Set(names.filter((name, index) => names.indexOf(name) !== index));
14
+ const seen = new Set<string>();
15
+ const duplicates = new Set<string>();
16
+ for (const session of sessions) {
17
+ const name = session.name?.toLowerCase();
18
+ if (name === undefined || name === "") {
19
+ continue;
20
+ }
21
+ if (seen.has(name)) {
22
+ duplicates.add(name);
23
+ }
24
+ seen.add(name);
25
+ }
26
+ return duplicates;
18
27
  }
19
28
  export function formatDuration(seconds: number): string {
20
29
  if (seconds < 60) {
@@ -79,7 +88,7 @@ function healthTags(session: SessionInfo, now: number): readonly string[] {
79
88
  interface RowOptions {
80
89
  readonly currentCwd: string;
81
90
  readonly self: boolean;
82
- readonly duplicates: (name: string) => boolean;
91
+ readonly duplicates: ReadonlySet<string>;
83
92
  readonly allSessions: readonly SessionInfo[];
84
93
  readonly now: number;
85
94
  }
@@ -88,7 +97,7 @@ function row(session: SessionInfo, options: RowOptions): string {
88
97
  const duplicate =
89
98
  session.name !== undefined &&
90
99
  session.name !== "" &&
91
- options.duplicates(session.name.toLowerCase());
100
+ options.duplicates.has(session.name.toLowerCase());
92
101
  let location: string | undefined;
93
102
  if (options.self) {
94
103
  location = "self";
@@ -132,7 +141,7 @@ export function formatSessionListSections(
132
141
  const others = scoped.filter((session) => session.id !== id);
133
142
  const options = {
134
143
  currentCwd: current.cwd,
135
- duplicates: (name: string) => duplicateNames(allSessions).has(name),
144
+ duplicates: duplicateNames(allSessions),
136
145
  allSessions,
137
146
  now: Date.now(),
138
147
  };