@fitchmultz/pi-subagents 0.52.21 → 0.52.22

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 CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## 0.52.22 - 2026-11-07
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+
9
+ - Bind each durable wake reservation to one POST and its required settlement, preserving retryable settlement failures and crash uncertainty.
10
+
11
+ <!-- npm-release:unreleased:end -->
12
+
13
+ - refactor: bind wake reservations to one settled attempt (#366) ([9bd60f9](https://github.com/fitchmultz/pi-subagents/commit/9bd60f92a4630ca9300ed9c4c06bc48758077a40))
14
+
15
+ ### What's Changed
16
+
17
+ - refactor: bind wake reservations to one settled attempt by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/366
18
+
19
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.21...v0.52.22
20
+
5
21
  ## 0.52.21 - 2026-11-07
6
22
 
7
23
  <!-- npm-release:unreleased:start -->
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.52.21","sha256":"d0c9a96a0eca06a58dc73d77b64300d7e41a9eb5c75b1deee8751ae3af4b87ce"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.52.22","sha256":"fc7e48d1fb96f143f56be9757d30a98bb138b94edd1f5388438ddeb3eed54638"});
@@ -28,7 +28,7 @@ export class WakeState {
28
28
  inode;
29
29
  device;
30
30
  owner;
31
- finishing = false;
31
+ phase = { kind: "idle" };
32
32
  constructor(stateDir) {
33
33
  this.stateDir = stateDir;
34
34
  this.path = join(stateDir, FILE);
@@ -194,13 +194,37 @@ export class WakeState {
194
194
  this.validate();
195
195
  this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
196
196
  }
197
- /** Persist quota and uncertainty BEFORE dispatch. Returning a deadline means no attempt admitted. */
197
+ /** Persist quota and uncertainty before exposing a single-use POST reservation. */
198
198
  reserve(bucket) {
199
199
  this.retrySettlement();
200
- return this.transaction(() => this.reservation(bucket));
200
+ if (this.phase.kind !== "idle") {
201
+ throw new Error("A wake reservation is already active or its owner is closed.");
202
+ }
203
+ const until = this.transaction(() => this.reservation(bucket));
204
+ if (until !== undefined) {
205
+ return { kind: "deferred", until };
206
+ }
207
+ const reservation = {
208
+ kind: "reserved",
209
+ post: async (action) => {
210
+ if (this.phase !== reservation) {
211
+ throw new Error("This wake reservation is no longer available.");
212
+ }
213
+ this.phase = { kind: "posting" };
214
+ try {
215
+ await action();
216
+ }
217
+ finally {
218
+ // Completion, rejection and abort all start the durable expiry window here.
219
+ this.settle();
220
+ }
221
+ },
222
+ };
223
+ this.phase = reservation;
224
+ return reservation;
201
225
  }
202
226
  retrySettlement() {
203
- if (this.finishing) {
227
+ if (this.phase.kind === "settlementPending") {
204
228
  this.settle();
205
229
  }
206
230
  }
@@ -219,9 +243,9 @@ export class WakeState {
219
243
  }
220
244
  /** A failed settlement stays open and must be retried before this owner can dispatch again. */
221
245
  settle() {
222
- this.finishing = true;
246
+ this.phase = { kind: "settlementPending" };
223
247
  this.transaction(() => this.finishOwner(this.owner.name));
224
- this.finishing = false;
248
+ this.phase = { kind: "idle" };
225
249
  }
226
250
  finishOwner(owner) {
227
251
  const now = Math.max(Date.now(), integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock));
@@ -286,6 +310,7 @@ export class WakeState {
286
310
  this.settle();
287
311
  }
288
312
  finally {
313
+ this.phase = { kind: "closed" };
289
314
  this.db.close();
290
315
  this.owner.close();
291
316
  }
@@ -171,20 +171,14 @@ export class WakeDeliveries {
171
171
  next = Math.min(next, due);
172
172
  continue;
173
173
  }
174
- const deferred = state.reserve(key);
175
- if (deferred !== undefined) {
176
- next = Math.min(next, deferred);
174
+ const reservation = state.reserve(key);
175
+ if (reservation.kind === "deferred") {
176
+ next = Math.min(next, reservation.until);
177
177
  continue;
178
178
  }
179
179
  // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
180
180
  // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
181
- try {
182
- await this.post(candidate);
183
- }
184
- finally {
185
- // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
186
- state.settle();
187
- }
181
+ await reservation.post(() => this.post(candidate));
188
182
  // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
189
183
  return Date.now();
190
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.52.21",
3
+ "version": "0.52.22",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,13 @@ function integer(value: unknown): number {
24
24
  return value;
25
25
  }
26
26
 
27
+ type WakeReservation =
28
+ | { readonly kind: "deferred"; readonly until: number }
29
+ | { readonly kind: "reserved"; readonly post: (action: () => Promise<void>) => Promise<void> };
30
+ type AttemptPhase =
31
+ | { readonly kind: "idle" | "posting" | "settlementPending" | "closed" }
32
+ | Extract<WakeReservation, { readonly kind: "reserved" }>;
33
+
27
34
  /** One helper ledger, independent of identity, certificate, URL and credential rotation. */
28
35
  export class WakeState {
29
36
  private readonly db: DatabaseSync;
@@ -32,7 +39,7 @@ export class WakeState {
32
39
  private readonly inode: number;
33
40
  private readonly device: number;
34
41
  private readonly owner: WakeOwner;
35
- private finishing = false;
42
+ private phase: AttemptPhase = { kind: "idle" };
36
43
 
37
44
  constructor(stateDir: string) {
38
45
  this.stateDir = stateDir;
@@ -212,14 +219,37 @@ export class WakeState {
212
219
  this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
213
220
  }
214
221
 
215
- /** Persist quota and uncertainty BEFORE dispatch. Returning a deadline means no attempt admitted. */
216
- reserve(bucket: string): number | undefined {
222
+ /** Persist quota and uncertainty before exposing a single-use POST reservation. */
223
+ reserve(bucket: string): WakeReservation {
217
224
  this.retrySettlement();
218
- return this.transaction(() => this.reservation(bucket));
225
+ if (this.phase.kind !== "idle") {
226
+ throw new Error("A wake reservation is already active or its owner is closed.");
227
+ }
228
+ const until = this.transaction(() => this.reservation(bucket));
229
+ if (until !== undefined) {
230
+ return { kind: "deferred", until };
231
+ }
232
+ const reservation: Extract<WakeReservation, { readonly kind: "reserved" }> = {
233
+ kind: "reserved",
234
+ post: async (action) => {
235
+ if (this.phase !== reservation) {
236
+ throw new Error("This wake reservation is no longer available.");
237
+ }
238
+ this.phase = { kind: "posting" };
239
+ try {
240
+ await action();
241
+ } finally {
242
+ // Completion, rejection and abort all start the durable expiry window here.
243
+ this.settle();
244
+ }
245
+ },
246
+ };
247
+ this.phase = reservation;
248
+ return reservation;
219
249
  }
220
250
 
221
251
  retrySettlement(): void {
222
- if (this.finishing) {
252
+ if (this.phase.kind === "settlementPending") {
223
253
  this.settle();
224
254
  }
225
255
  }
@@ -238,10 +268,10 @@ export class WakeState {
238
268
  }
239
269
 
240
270
  /** A failed settlement stays open and must be retried before this owner can dispatch again. */
241
- settle(): void {
242
- this.finishing = true;
271
+ private settle(): void {
272
+ this.phase = { kind: "settlementPending" };
243
273
  this.transaction(() => this.finishOwner(this.owner.name));
244
- this.finishing = false;
274
+ this.phase = { kind: "idle" };
245
275
  }
246
276
 
247
277
  private finishOwner(owner: string): void {
@@ -314,6 +344,7 @@ export class WakeState {
314
344
  try {
315
345
  this.settle();
316
346
  } finally {
347
+ this.phase = { kind: "closed" };
317
348
  this.db.close();
318
349
  this.owner.close();
319
350
  }
@@ -211,19 +211,14 @@ export class WakeDeliveries {
211
211
  next = Math.min(next, due);
212
212
  continue;
213
213
  }
214
- const deferred = state.reserve(key);
215
- if (deferred !== undefined) {
216
- next = Math.min(next, deferred);
214
+ const reservation = state.reserve(key);
215
+ if (reservation.kind === "deferred") {
216
+ next = Math.min(next, reservation.until);
217
217
  continue;
218
218
  }
219
219
  // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
220
220
  // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
221
- try {
222
- await this.post(candidate);
223
- } finally {
224
- // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
225
- state.settle();
226
- }
221
+ await reservation.post(() => this.post(candidate));
227
222
  // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
228
223
  return Date.now();
229
224
  }