@fitchmultz/pi-subagents 0.48.0 → 0.49.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.
@@ -0,0 +1,293 @@
1
+ import { lstatSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { privateDatabase, privateFile, stateDirectory } from "./bridge-storage.js";
4
+ import { readBridgeFile } from "./bridge-files.js";
5
+ import { isRecord, isUnknownArray } from "../shared/unknown.js";
6
+ import { object } from "./bridge-protocol.js";
7
+ import { WakeOwner, WAKE_OWNER_FILE, stoppedWakeOwner } from "./bridge-wake-owner.js";
8
+ const HOUR_MS = 3600000;
9
+ const LIMIT = 20;
10
+ const RETRY_MS = 30000;
11
+ const MAX_BACKOFF_MS = 300000;
12
+ const FILE = "wake-quota.sqlite";
13
+ const ATTEMPTS = `CREATE TABLE wake_attempts(
14
+ sequence INTEGER PRIMARY KEY, attemptedAt INTEGER NOT NULL,
15
+ settledAt INTEGER, owner TEXT,
16
+ CHECK(settledAt IS NOT NULL OR owner IS NOT NULL))`;
17
+ function integer(value) {
18
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
19
+ throw new Error("Invalid durable wake state.");
20
+ }
21
+ return value;
22
+ }
23
+ /** One helper ledger, independent of identity, certificate, URL and credential rotation. */
24
+ export class WakeState {
25
+ db;
26
+ path;
27
+ stateDir;
28
+ inode;
29
+ device;
30
+ owner;
31
+ finishing = false;
32
+ constructor(stateDir) {
33
+ this.stateDir = stateDir;
34
+ this.path = join(stateDir, FILE);
35
+ const { db, created } = privateDatabase(stateDir, FILE);
36
+ this.db = db;
37
+ const stat = lstatSync(this.path);
38
+ this.inode = stat.ino;
39
+ this.device = stat.dev;
40
+ try {
41
+ if (created) {
42
+ this.initialize();
43
+ }
44
+ this.verify();
45
+ this.migrate();
46
+ this.owner = this.createOwner();
47
+ }
48
+ catch (error) {
49
+ db.close();
50
+ throw error;
51
+ }
52
+ }
53
+ verify() {
54
+ const db = this.db;
55
+ if (db.prepare("PRAGMA quick_check").get()?.quick_check !== "ok") {
56
+ throw new Error("Wake ledger is corrupt.");
57
+ }
58
+ const meta = db.prepare("SELECT version, clock FROM wake_meta WHERE singleton=1").get();
59
+ if (!meta || (meta.version !== 1 && meta.version !== 2)) {
60
+ throw new Error("Unsupported wake ledger.");
61
+ }
62
+ integer(meta.clock);
63
+ this.verifyAttempts(meta.version);
64
+ for (const row of db.prepare("SELECT due, backoff FROM wake_batches").all()) {
65
+ integer(row.due);
66
+ const backoff = integer(row.backoff);
67
+ if ((backoff !== 0 && backoff < RETRY_MS) || backoff > MAX_BACKOFF_MS) {
68
+ throw new Error("Invalid wake retry state.");
69
+ }
70
+ }
71
+ }
72
+ createOwner() {
73
+ let owner;
74
+ try {
75
+ // Recovery holds this same ledger lock: it cannot reap a file before its owner locks it.
76
+ return this.transaction(() => {
77
+ owner = new WakeOwner(this.stateDir);
78
+ return owner;
79
+ });
80
+ }
81
+ catch (error) {
82
+ owner?.close();
83
+ throw error;
84
+ }
85
+ }
86
+ verifyAttempts(version) {
87
+ for (const row of this.db.prepare("SELECT attemptedAt FROM wake_attempts").all()) {
88
+ integer(row.attemptedAt);
89
+ }
90
+ if (version !== 2) {
91
+ return;
92
+ }
93
+ for (const row of this.db
94
+ .prepare("SELECT attemptedAt, settledAt, owner FROM wake_attempts")
95
+ .all()) {
96
+ if (row.settledAt !== null) {
97
+ if (integer(row.settledAt) < integer(row.attemptedAt)) {
98
+ throw new Error("Invalid wake completion time.");
99
+ }
100
+ }
101
+ else if (typeof row.owner !== "string" || !WAKE_OWNER_FILE.test(row.owner)) {
102
+ throw new Error("Invalid open wake attempt.");
103
+ }
104
+ }
105
+ }
106
+ initialize() {
107
+ // Timestamped legacy outcomes omit crash-before-log attempts. Verified preflight may provide
108
+ // complete history plus a bounded unknown reservation; absent proof reserves the full hour.
109
+ const legacy = readdirSync(this.stateDir).some((name) => name !== FILE && (name.endsWith(".sqlite") || name.startsWith("presence-")));
110
+ const now = Date.now();
111
+ const attempts = this.history(now) ?? (legacy ? Array(LIMIT).fill(now) : []);
112
+ this.db.exec(`BEGIN IMMEDIATE;
113
+ CREATE TABLE wake_meta(singleton INTEGER PRIMARY KEY CHECK(singleton=1), version INTEGER NOT NULL, clock INTEGER NOT NULL);
114
+ ${ATTEMPTS};
115
+ CREATE TABLE wake_batches(bucket TEXT PRIMARY KEY, due INTEGER NOT NULL, backoff INTEGER NOT NULL);`);
116
+ this.db.prepare("INSERT INTO wake_meta VALUES(1,2,?)").run(now);
117
+ const insert = this.db.prepare("INSERT INTO wake_attempts(attemptedAt,settledAt) VALUES(?,?)");
118
+ for (const timestamp of attempts) {
119
+ insert.run(timestamp, timestamp);
120
+ }
121
+ this.db.exec("COMMIT");
122
+ }
123
+ migrate() {
124
+ this.transaction(() => {
125
+ if (this.db.prepare("SELECT version FROM wake_meta").get()?.version !== 1) {
126
+ return;
127
+ }
128
+ // V1 timestamps precede COMMIT/connect/TLS, so none prove when a POST finished.
129
+ // Stop all old writers before upgrading; the new CHECK also denies their old INSERT shape.
130
+ const now = Math.max(Date.now(), integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock));
131
+ this.db.exec(`ALTER TABLE wake_attempts RENAME TO wake_attempts_v1; ${ATTEMPTS};`);
132
+ this.db
133
+ .prepare(`INSERT INTO wake_attempts(sequence,attemptedAt,settledAt)
134
+ SELECT sequence,attemptedAt,MAX(attemptedAt,?) FROM wake_attempts_v1`)
135
+ .run(now);
136
+ this.db.exec("DROP TABLE wake_attempts_v1");
137
+ this.db.prepare("UPDATE wake_meta SET version=2,clock=?").run(now);
138
+ });
139
+ }
140
+ history(now) {
141
+ const path = join(this.stateDir, "wake-history.json");
142
+ let input;
143
+ try {
144
+ privateFile(path);
145
+ input = JSON.parse(readBridgeFile(path, true, 1024 * 1024).toString("utf8"));
146
+ }
147
+ catch (error) {
148
+ if (isRecord(error) && error.code === "ENOENT") {
149
+ return;
150
+ }
151
+ throw error;
152
+ }
153
+ const raw = object(input, ["coveredSince", "stoppedAt", "attempts", "unknownAttempts"]);
154
+ const coveredSince = integer(raw.coveredSince);
155
+ const stoppedAt = integer(raw.stoppedAt);
156
+ const unknownAttempts = integer(raw.unknownAttempts);
157
+ if (coveredSince > stoppedAt - HOUR_MS ||
158
+ stoppedAt > now ||
159
+ unknownAttempts > LIMIT ||
160
+ !isUnknownArray(raw.attempts) ||
161
+ raw.attempts.length > 10000) {
162
+ throw new Error("Invalid legacy wake history; never reset prior usage.");
163
+ }
164
+ const attempts = raw.attempts.map(integer);
165
+ if (attempts.some((timestamp) => timestamp < coveredSince || timestamp > stoppedAt)) {
166
+ throw new Error("Attempt outside attested history coverage.");
167
+ }
168
+ return attempts
169
+ .filter((timestamp) => timestamp > now - HOUR_MS)
170
+ .concat(Array(unknownAttempts).fill(now));
171
+ }
172
+ validate() {
173
+ stateDirectory(this.stateDir);
174
+ privateFile(this.path);
175
+ const stat = lstatSync(this.path);
176
+ if (stat.ino !== this.inode || stat.dev !== this.device) {
177
+ throw new Error("Wake ledger was replaced.");
178
+ }
179
+ }
180
+ deadline(bucket) {
181
+ this.validate();
182
+ const existing = this.db.prepare("SELECT due FROM wake_batches WHERE bucket=?").get(bucket);
183
+ if (existing) {
184
+ return integer(existing.due);
185
+ }
186
+ // First observation occurs only after mailbox FULL commit, never at the pre-write receivedAt.
187
+ // Keep this grace durable too: new arrivals/reload/restart cannot restart an existing deadline.
188
+ this.db
189
+ .prepare("INSERT OR IGNORE INTO wake_batches VALUES(?,?,0)")
190
+ .run(bucket, Date.now() + 2000);
191
+ return integer(this.db.prepare("SELECT due FROM wake_batches WHERE bucket=?").get(bucket)?.due);
192
+ }
193
+ retire(bucket) {
194
+ this.validate();
195
+ this.db.prepare("DELETE FROM wake_batches WHERE bucket=?").run(bucket);
196
+ }
197
+ /** Persist quota and uncertainty BEFORE dispatch. Returning a deadline means no attempt admitted. */
198
+ reserve(bucket) {
199
+ this.retrySettlement();
200
+ return this.transaction(() => this.reservation(bucket));
201
+ }
202
+ retrySettlement() {
203
+ if (this.finishing) {
204
+ this.settle();
205
+ }
206
+ }
207
+ transaction(action) {
208
+ this.validate();
209
+ this.db.exec("BEGIN IMMEDIATE");
210
+ try {
211
+ const result = action();
212
+ this.db.exec("COMMIT");
213
+ return result;
214
+ }
215
+ catch (error) {
216
+ this.db.exec("ROLLBACK");
217
+ throw error;
218
+ }
219
+ }
220
+ /** A failed settlement stays open and must be retried before this owner can dispatch again. */
221
+ settle() {
222
+ this.finishing = true;
223
+ this.transaction(() => this.finishOwner(this.owner.name));
224
+ this.finishing = false;
225
+ }
226
+ finishOwner(owner) {
227
+ const now = Math.max(Date.now(), integer(this.db.prepare("SELECT clock FROM wake_meta").get()?.clock));
228
+ this.db
229
+ .prepare(`UPDATE wake_attempts SET settledAt=MAX(attemptedAt,?)
230
+ WHERE owner=? AND settledAt IS NULL`)
231
+ .run(now, owner);
232
+ this.db.prepare("UPDATE wake_meta SET clock=?").run(now);
233
+ }
234
+ recover() {
235
+ const names = new Set(readdirSync(this.stateDir).filter((name) => WAKE_OWNER_FILE.test(name)));
236
+ for (const row of this.db
237
+ .prepare("SELECT DISTINCT owner FROM wake_attempts WHERE settledAt IS NULL")
238
+ .all()) {
239
+ if (typeof row.owner !== "string") {
240
+ throw new Error("Invalid open wake owner.");
241
+ }
242
+ names.add(row.owner);
243
+ }
244
+ for (const name of names) {
245
+ if (name !== this.owner.name && stoppedWakeOwner(this.stateDir, name)) {
246
+ // The owner cannot send again. Unknown outcomes retain a full hour from this observation.
247
+ this.finishOwner(name);
248
+ rmSync(join(this.stateDir, name), { force: true });
249
+ }
250
+ }
251
+ }
252
+ reservation(bucket) {
253
+ this.recover();
254
+ const clock = integer(this.db.prepare("SELECT clock FROM wake_meta WHERE singleton=1").get()?.clock);
255
+ const now = Date.now();
256
+ if (now < clock) {
257
+ return clock;
258
+ }
259
+ const row = this.db.prepare("SELECT due, backoff FROM wake_batches WHERE bucket=?").get(bucket);
260
+ if (row && integer(row.due) > now) {
261
+ return integer(row.due);
262
+ }
263
+ this.db.prepare("DELETE FROM wake_attempts WHERE settledAt<=?").run(now - HOUR_MS);
264
+ const attempts = this.db
265
+ .prepare("SELECT settledAt FROM wake_attempts ORDER BY settledAt")
266
+ .all();
267
+ if (attempts.length >= LIMIT) {
268
+ const oldest = attempts.find((attempt) => attempt.settledAt !== null);
269
+ // Only open/unknown attempts have no expiry yet; revisit on the existing recovery backoff.
270
+ return oldest ? integer(oldest.settledAt) + HOUR_MS : now + RETRY_MS;
271
+ }
272
+ const backoff = row
273
+ ? Math.max(RETRY_MS, Math.min(integer(row.backoff) * 2, MAX_BACKOFF_MS))
274
+ : RETRY_MS;
275
+ this.db
276
+ .prepare("INSERT INTO wake_attempts(attemptedAt,owner) VALUES(?,?)")
277
+ .run(now, this.owner.name);
278
+ this.db.prepare("UPDATE wake_meta SET clock=? WHERE singleton=1").run(now);
279
+ this.db
280
+ .prepare("INSERT OR REPLACE INTO wake_batches VALUES(?,?,?)")
281
+ .run(bucket, now + backoff, backoff);
282
+ return;
283
+ }
284
+ close() {
285
+ try {
286
+ this.settle();
287
+ }
288
+ finally {
289
+ this.db.close();
290
+ this.owner.close();
291
+ }
292
+ }
293
+ }
@@ -1,146 +1,232 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { log } from "./bridge-protocol.js";
2
3
  import { Mailbox } from "./bridge-mailbox.js";
3
- const POLL_MS = 15000;
4
- const FALLBACK_AFTER_MS = 120000;
5
- const MAX_BACKOFF_MS = 300000;
6
- export class WakeDelivery {
4
+ import { WakeState } from "./bridge-wake-state.js";
5
+ function actionable(entry) {
6
+ const message = entry.message;
7
+ if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
8
+ return false;
9
+ }
10
+ return (message.expectsReply === true ||
11
+ !/^(?:ACK|DUP-ACK|FYI|NOTE|RECEIPT|NOWAKE)(?=$|[\s:])/.test(message.content.text));
12
+ }
13
+ function bucket(identity) {
14
+ return createHash("sha256")
15
+ .update(identity.wake?.url ?? "")
16
+ .digest("hex");
17
+ }
18
+ /** Mailbox commits and durable retry/quota deadlines drive wakes; there is no intake scan timer. */
19
+ export class WakeDeliveries {
20
+ stateDir;
21
+ state;
22
+ allowed;
23
+ deliveries = new Map();
7
24
  controller = new AbortController();
8
25
  timer;
9
26
  running;
10
- backoff = POLL_MS;
11
- name;
12
- mailbox;
13
- policy;
14
- authorized;
15
- constructor(name, mailbox, policy, authorized) {
16
- this.name = name;
17
- this.mailbox = mailbox;
18
- this.policy = policy;
19
- this.authorized = authorized;
27
+ dirty = false;
28
+ storageBackoff = 15000;
29
+ constructor(stateDir, allowed) {
30
+ this.stateDir = stateDir;
31
+ this.allowed = allowed;
32
+ // Initialize before any presence proof/mailbox is created, so genuinely fresh installs start now.
33
+ this.state = stateDir === undefined ? undefined : new WakeState(stateDir);
34
+ }
35
+ session(identity, expires) {
36
+ if (!identity.wake ||
37
+ this.stateDir === undefined ||
38
+ this.deliveries.has(identity.fingerprint256)) {
39
+ return;
40
+ }
41
+ this.deliveries.set(identity.fingerprint256, {
42
+ identity,
43
+ expires,
44
+ mailbox: new Mailbox(this.stateDir, identity.fingerprint256, true),
45
+ controller: new AbortController(),
46
+ });
47
+ // Startup restores every certificate proof synchronously before the scheduled reconciliation.
48
+ // An empty first fingerprint must not clear another retained fingerprint's durable cold batch.
49
+ this.changed(false);
50
+ }
51
+ changed(reconcile = true) {
52
+ if (this.controller.signal.aborted) {
53
+ return;
54
+ }
55
+ try {
56
+ // ACK may be followed by fresh mail before the event timer runs. Clear completed batches now
57
+ // so a new obligation does not inherit a completed batch's uncertainty/backoff.
58
+ if (reconcile) {
59
+ this.reconcile();
60
+ }
61
+ }
62
+ catch {
63
+ // The scheduled storage recovery fails closed; never let an event bypass durable admission.
64
+ }
65
+ if (this.running) {
66
+ this.dirty = true;
67
+ }
68
+ else {
69
+ this.schedule(Date.now());
70
+ }
20
71
  }
21
- start() {
22
- if (!this.running && !this.controller.signal.aborted) {
23
- this.schedule(0);
72
+ reconcile() {
73
+ for (const [key, candidate] of this.groups()) {
74
+ if (candidate) {
75
+ this.state?.deadline(key);
76
+ }
77
+ else {
78
+ this.state?.retire(key);
79
+ }
24
80
  }
25
81
  }
26
- schedule(delay) {
82
+ schedule(deadline) {
27
83
  clearTimeout(this.timer);
28
84
  this.timer = setTimeout(() => {
85
+ this.dirty = false;
86
+ let next = Infinity;
29
87
  this.running = this.deliver()
88
+ .then((due) => {
89
+ next = due;
90
+ this.storageBackoff = 15000;
91
+ return;
92
+ })
30
93
  .catch(() => {
31
- log("wake_delivery", { name: this.name, result: "storage_unavailable" });
32
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
94
+ log("wake_delivery", { result: "storage_unavailable" });
95
+ next = Date.now() + this.storageBackoff;
96
+ this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
33
97
  })
34
98
  .finally(() => {
35
99
  this.running = undefined;
36
- if (!this.controller.signal.aborted) {
37
- this.schedule(this.backoff);
100
+ if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
101
+ this.schedule(this.dirty ? Date.now() : next);
38
102
  }
39
103
  });
40
- }, delay);
104
+ }, Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
105
+ }
106
+ authorized(delivery) {
107
+ return (!this.controller.signal.aborted &&
108
+ this.allowed(delivery.identity) &&
109
+ delivery.expires > Date.now());
110
+ }
111
+ pending(delivery) {
112
+ let after = 0;
113
+ let next;
114
+ let page;
115
+ do {
116
+ page = delivery.mailbox.pendingNotifications(after);
117
+ for (const entry of page.messages) {
118
+ if (!actionable(entry)) {
119
+ // Retire only notification intent. Payload/reply obligations require actual ACK/reply.
120
+ delivery.mailbox.notificationAccepted(entry.message.id);
121
+ }
122
+ else if (!next) {
123
+ next = entry;
124
+ }
125
+ }
126
+ after = Number(page.nextCursor);
127
+ } while (page.hasMore);
128
+ return next;
129
+ }
130
+ groups() {
131
+ const groups = new Map();
132
+ for (const delivery of this.deliveries.values()) {
133
+ if (!this.authorized(delivery)) {
134
+ continue;
135
+ }
136
+ const key = bucket(delivery.identity);
137
+ const entry = this.pending(delivery);
138
+ const previous = groups.get(key);
139
+ if (!groups.has(key) ||
140
+ (entry && (!previous || entry.receivedAt < previous.entry.receivedAt))) {
141
+ groups.set(key, entry ? { delivery, entry } : undefined);
142
+ }
143
+ }
144
+ return groups;
41
145
  }
42
146
  async deliver() {
43
- if (!this.authorized()) {
44
- return;
147
+ const state = this.state;
148
+ let next = Infinity;
149
+ if (!state) {
150
+ return next;
45
151
  }
46
- const entries = this.mailbox.pendingNotifications().messages;
47
- for (const entry of entries) {
48
- if (entry.acknowledged) {
49
- // The box's durable worker owns primary wake/replay after acknowledgement.
50
- this.mailbox.notificationAccepted(entry.message.id);
152
+ // ACK can remove the final candidate while its failed completion write still needs recovery.
153
+ state.retrySettlement();
154
+ for (const [key, candidate] of this.groups()) {
155
+ if (!candidate) {
156
+ state.retire(key);
157
+ continue;
51
158
  }
159
+ const due = state.deadline(key);
160
+ if (due > Date.now()) {
161
+ next = Math.min(next, due);
162
+ continue;
163
+ }
164
+ const deferred = state.reserve(key);
165
+ if (deferred !== undefined) {
166
+ next = Math.min(next, deferred);
167
+ continue;
168
+ }
169
+ // ponytail: two cold Macs can each admit one wake. Literal cross-Mac single admission
170
+ // requires a neutral quota authority governing every writer, not a URL hash or HTTP 2xx.
171
+ try {
172
+ // oxlint-disable-next-line no-await-in-loop
173
+ await this.post(candidate);
174
+ }
175
+ finally {
176
+ // Expiry starts after response/error/abort, never before COMMIT or connect/TLS.
177
+ state.settle();
178
+ }
179
+ // Re-read ACKs and all groups after transport I/O; a snapshot must not wake already consumed mail.
180
+ return Date.now();
52
181
  }
53
- const next = entries.find((entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS);
54
- if (next) {
55
- // One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
56
- await this.post(next);
57
- return;
58
- }
59
- this.backoff = POLL_MS;
182
+ return next;
60
183
  }
61
- async post(entry) {
62
- if (!this.authorized()) {
184
+ async post({ delivery, entry }) {
185
+ const policy = delivery.identity.wake;
186
+ if (!policy || !this.authorized(delivery)) {
63
187
  return;
64
188
  }
65
- const fields = { name: this.name, messageId: entry.message.id };
189
+ const fields = { name: delivery.identity.name, messageId: entry.message.id };
66
190
  try {
67
- const response = await fetch(this.policy.url, {
191
+ const response = await fetch(policy.url, {
68
192
  method: "POST",
69
193
  redirect: "error",
70
- headers: {
71
- Authorization: `Bearer ${this.policy.key}`,
72
- "Content-Type": "application/json",
73
- },
194
+ headers: { Authorization: `Bearer ${policy.key}`, "Content-Type": "application/json" },
74
195
  body: JSON.stringify({
75
196
  from: entry.from.name ?? entry.from.id,
76
197
  from_id: entry.from.id,
77
- to: this.name,
198
+ to: delivery.identity.name,
78
199
  message_id: entry.message.id,
79
200
  ask_id: entry.message.expectsReply === true ? entry.message.id : null,
80
201
  text: entry.message.content.text,
81
202
  }),
82
- signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
203
+ signal: AbortSignal.any([
204
+ this.controller.signal,
205
+ delivery.controller.signal,
206
+ AbortSignal.timeout(10000),
207
+ ]),
83
208
  });
84
209
  await response.body?.cancel();
85
- if (response.ok && !this.controller.signal.aborted && this.authorized()) {
86
- // HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
87
- this.backoff = MAX_BACKOFF_MS;
88
- log("wake_delivery", { ...fields, result: "accepted" });
89
- }
90
- else {
91
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
92
- log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
93
- }
210
+ // Acceptance does not consume mail or reset uncertainty: only real inbox ACK retires intent.
211
+ log("wake_delivery", {
212
+ ...fields,
213
+ result: response.ok ? "accepted" : "http_rejected",
214
+ status: response.status,
215
+ });
94
216
  }
95
217
  catch {
96
- this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
97
218
  if (!this.controller.signal.aborted) {
98
219
  log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
99
220
  }
100
221
  }
101
222
  }
102
- async stop() {
103
- this.controller.abort();
104
- clearTimeout(this.timer);
105
- await this.running;
106
- }
107
- }
108
- export class WakeDeliveries {
109
- stateDir;
110
- allowed;
111
- deliveries = new Map();
112
- cleanup = new Set();
113
- constructor(stateDir, allowed) {
114
- this.stateDir = stateDir;
115
- this.allowed = allowed;
116
- }
117
- session(identity, expires) {
118
- if (!identity.wake ||
119
- this.stateDir === undefined ||
120
- this.deliveries.has(identity.fingerprint256)) {
121
- return;
122
- }
123
- const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
124
- const worker = new WakeDelivery(identity.name, mailbox, identity.wake, () => this.allowed(identity) && expires > Date.now());
125
- this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
126
- worker.start();
127
- }
128
223
  drop(fp) {
129
224
  const delivery = this.deliveries.get(fp);
130
- if (!delivery) {
131
- return;
132
- }
133
225
  this.deliveries.delete(fp);
134
- const cleanup = delivery.worker
135
- .stop()
136
- .then(() => delivery.mailbox.close())
137
- .catch(() => {
138
- log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
139
- })
140
- .finally(() => {
141
- this.cleanup.delete(cleanup);
142
- });
143
- this.cleanup.add(cleanup);
226
+ delivery?.controller.abort();
227
+ delivery?.mailbox.close();
228
+ // Membership removal alone is not an ACK. Reload restores proof-backed replacements first.
229
+ this.changed(false);
144
230
  }
145
231
  reload() {
146
232
  for (const [fp, delivery] of this.deliveries) {
@@ -148,11 +234,16 @@ export class WakeDeliveries {
148
234
  this.drop(fp);
149
235
  }
150
236
  }
237
+ this.changed(false);
151
238
  }
152
239
  async stop() {
153
- for (const fp of this.deliveries.keys()) {
154
- this.drop(fp);
240
+ this.controller.abort();
241
+ clearTimeout(this.timer);
242
+ await this.running;
243
+ for (const delivery of this.deliveries.values()) {
244
+ delivery.mailbox.close();
155
245
  }
156
- await Promise.all(this.cleanup);
246
+ this.deliveries.clear();
247
+ this.state?.close();
157
248
  }
158
249
  }