@fitchmultz/pi-subagents 0.52.18 → 0.52.20
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 +50 -0
- package/README.md +4 -0
- package/dist/agent/host.js +12 -0
- package/dist/agent/index.js +70 -56
- package/dist/agent/launch.js +12 -3
- package/dist/agent/notifications.js +5 -5
- package/dist/agent/parking.js +26 -15
- package/dist/agent/runner.js +18 -16
- package/dist/agent/tool-schemas.js +2 -0
- package/dist/agent/usage.js +42 -3
- package/dist/agent/view.js +13 -13
- package/dist/agent/wake.js +17 -15
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/bridge-presence.js +30 -18
- package/dist/pi-intercom/bridge-wake.js +60 -37
- package/dist/pi-intercom/broker/broker.js +2 -2
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client.js +16 -50
- package/dist/pi-intercom/broker/pending-requests.d.ts +12 -0
- package/dist/pi-intercom/broker/pending-requests.js +26 -0
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +3 -2
- package/dist/pi-intercom/broker/snapshot-reader.js +34 -15
- package/dist/pi-intercom/broker/snapshots.d.ts +25 -7
- package/dist/pi-intercom/broker/snapshots.js +56 -25
- package/dist/pi-intercom/broker/spawn-claim.js +89 -0
- package/dist/pi-intercom/broker/spawn.js +31 -78
- package/dist/pi-intercom/lifecycle.js +26 -26
- package/dist/shared/post-exit-stdio-guard.js +44 -37
- package/docs/development.md +25 -0
- package/docs/intercom.md +13 -0
- package/docs/reference.md +16 -5
- package/package.json +1 -1
- package/scripts/build.mjs +41 -14
- package/skills/pi-subagents/SKILL.md +8 -4
- package/src/agent/contracts.ts +17 -0
- package/src/agent/host.ts +19 -0
- package/src/agent/index.ts +91 -62
- package/src/agent/launch.ts +16 -3
- package/src/agent/notifications.ts +5 -5
- package/src/agent/parking.ts +39 -17
- package/src/agent/runner.ts +20 -21
- package/src/agent/tool-schemas.ts +2 -0
- package/src/agent/usage.ts +48 -3
- package/src/agent/view.ts +15 -13
- package/src/agent/wake.ts +19 -15
- package/src/pi-intercom/bridge-presence.ts +37 -18
- package/src/pi-intercom/bridge-wake.ts +70 -38
- package/src/pi-intercom/broker/broker.ts +2 -1
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client.ts +36 -65
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- package/src/pi-intercom/broker/snapshots.ts +82 -37
- package/src/pi-intercom/broker/spawn-claim.ts +98 -0
- package/src/pi-intercom/broker/spawn.ts +42 -82
- package/src/pi-intercom/lifecycle.ts +38 -27
- package/src/shared/post-exit-stdio-guard.ts +48 -40
package/dist/agent/usage.js
CHANGED
|
@@ -7,6 +7,37 @@ 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
|
+
/** Pending repair remains authoritative until its native terminal is published and validated. */
|
|
11
|
+
export function accountingState(result) {
|
|
12
|
+
if (result?.accountingPending !== undefined) {
|
|
13
|
+
return { kind: "pending", pending: result.accountingPending, error: result.accountingError };
|
|
14
|
+
}
|
|
15
|
+
const terminalEntryId = result?.terminalEntryId;
|
|
16
|
+
if (result?.contributions !== undefined) {
|
|
17
|
+
return { kind: "verified", contributions: result.contributions, terminalEntryId };
|
|
18
|
+
}
|
|
19
|
+
return result?.accountingError === undefined
|
|
20
|
+
? { kind: "unrecorded", terminalEntryId }
|
|
21
|
+
: { kind: "unavailable", error: result.accountingError, terminalEntryId };
|
|
22
|
+
}
|
|
23
|
+
export function accountingFields(state) {
|
|
24
|
+
const terminal = state.kind !== "pending" && state.terminalEntryId !== undefined
|
|
25
|
+
? { terminalEntryId: state.terminalEntryId }
|
|
26
|
+
: {};
|
|
27
|
+
switch (state.kind) {
|
|
28
|
+
case "unrecorded":
|
|
29
|
+
return terminal;
|
|
30
|
+
case "verified":
|
|
31
|
+
return { contributions: state.contributions, ...terminal };
|
|
32
|
+
case "pending":
|
|
33
|
+
return {
|
|
34
|
+
accountingPending: state.pending,
|
|
35
|
+
...(state.error === undefined ? {} : { accountingError: state.error }),
|
|
36
|
+
};
|
|
37
|
+
case "unavailable":
|
|
38
|
+
return { accountingError: state.error, ...terminal };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
10
41
|
/** Billing evidence of one native entry. Missing required usage is an error only outside a baseline. */
|
|
11
42
|
function billing(entry) {
|
|
12
43
|
if (entry.checkpoint === true) {
|
|
@@ -143,10 +174,11 @@ export function readResult(base, launch) {
|
|
|
143
174
|
if (!result) {
|
|
144
175
|
return;
|
|
145
176
|
}
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
177
|
+
const accounting = accountingState(result);
|
|
178
|
+
if (accounting.kind !== "pending") {
|
|
148
179
|
return result;
|
|
149
180
|
}
|
|
181
|
+
const { pending } = accounting;
|
|
150
182
|
let contributions;
|
|
151
183
|
try {
|
|
152
184
|
contributions = childUsage(launch, new Set(pending.baseline), pending.terminalCandidate);
|
|
@@ -156,7 +188,14 @@ export function readResult(base, launch) {
|
|
|
156
188
|
return result;
|
|
157
189
|
}
|
|
158
190
|
const { accountingPending: _pending, accountingError: _error, ...execution } = result;
|
|
159
|
-
const repaired = {
|
|
191
|
+
const repaired = {
|
|
192
|
+
...execution,
|
|
193
|
+
...accountingFields({
|
|
194
|
+
kind: "verified",
|
|
195
|
+
terminalEntryId: pending.terminalCandidate,
|
|
196
|
+
contributions,
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
160
199
|
write(path.join(runDirectory(base, launch.id), "result.json"), repaired);
|
|
161
200
|
return repaired;
|
|
162
201
|
}
|
package/dist/agent/view.js
CHANGED
|
@@ -5,25 +5,25 @@ import { actor, alive, observeAlive } from "./process.js";
|
|
|
5
5
|
import { admissionSchema, commandSchema, exitSchema, successorSchema, wakeFailureSchema, } from "./contracts.js";
|
|
6
6
|
import { conversation, read, runDirectory } from "./storage.js";
|
|
7
7
|
import { published } from "./notifications.js";
|
|
8
|
-
import { readCanonicalResult, readResult } from "./usage.js";
|
|
8
|
+
import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.js";
|
|
9
9
|
import { Type } from "./typebox.js";
|
|
10
10
|
const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
|
|
11
11
|
function toolResult(value, text) {
|
|
12
12
|
return { content: [{ type: "text", text }], details: value };
|
|
13
13
|
}
|
|
14
14
|
function receiptResult(result) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
if (result === undefined) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const { contributions: _contributions, ...accounting } = accountingFields(accountingState(result));
|
|
19
|
+
return {
|
|
20
|
+
id: result.id,
|
|
21
|
+
owner: result.owner,
|
|
22
|
+
status: result.status,
|
|
23
|
+
endedAt: result.endedAt,
|
|
24
|
+
error: result.error,
|
|
25
|
+
...accounting,
|
|
26
|
+
};
|
|
27
27
|
}
|
|
28
28
|
/** `observed` holds batched liveness by actor token; any other actor gets a fresh check. */
|
|
29
29
|
function processView(base, launch, observed) {
|
package/dist/agent/wake.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
2
3
|
import { journalStamp } from "../shared/native-journal.js";
|
|
3
4
|
import { missing } from "./storage.js";
|
|
4
5
|
/** Identity and change stamp of a path; "" when absent. */
|
|
@@ -13,10 +14,10 @@ export function pathStamp(file) {
|
|
|
13
14
|
}
|
|
14
15
|
/** Native change hints for one directory identity; owners still reconcile canonical files. */
|
|
15
16
|
export class DirectoryWatch {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
state = {
|
|
18
|
+
kind: "unarmed",
|
|
19
|
+
};
|
|
18
20
|
checked = "";
|
|
19
|
-
failed = false;
|
|
20
21
|
directory;
|
|
21
22
|
relevant;
|
|
22
23
|
wake;
|
|
@@ -30,41 +31,41 @@ export class DirectoryWatch {
|
|
|
30
31
|
const identity = stat?.isDirectory() === true ? `${stat.dev}:${stat.ino}` : "";
|
|
31
32
|
const checked = this.checked;
|
|
32
33
|
this.checked = stat === undefined ? "" : `${identity}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
33
|
-
if (this.
|
|
34
|
+
if (this.state.kind === "armed" && identity === this.state.identity) {
|
|
34
35
|
return checked === this.checked ? "watching" : "changed";
|
|
35
36
|
}
|
|
36
37
|
// A replaced directory leaves an inode watch silent on Linux.
|
|
37
38
|
this.close();
|
|
38
|
-
return identity === "" || this.
|
|
39
|
+
return identity === "" || this.state.kind === "polling" ? "unwatched" : this.arm(identity);
|
|
39
40
|
}
|
|
40
41
|
arm(identity) {
|
|
41
42
|
try {
|
|
42
43
|
const watcher = fs.watch(this.directory, { persistent: false }, (_event, file) => {
|
|
43
|
-
if (watcher === this.watcher && this.relevant(file)) {
|
|
44
|
+
if (this.state.kind === "armed" && watcher === this.state.watcher && this.relevant(file)) {
|
|
44
45
|
this.wake();
|
|
45
46
|
}
|
|
46
47
|
});
|
|
47
48
|
watcher.on("error", () => {
|
|
48
|
-
if (watcher === this.watcher) {
|
|
49
|
-
|
|
50
|
-
this.
|
|
49
|
+
if (this.state.kind === "armed" && watcher === this.state.watcher) {
|
|
50
|
+
watcher.close();
|
|
51
|
+
this.state = { kind: "polling" };
|
|
51
52
|
this.wake();
|
|
52
53
|
}
|
|
53
54
|
});
|
|
54
|
-
this.
|
|
55
|
-
this.armed = identity;
|
|
55
|
+
this.state = { kind: "armed", watcher, identity };
|
|
56
56
|
return "armed";
|
|
57
57
|
}
|
|
58
58
|
catch (error) {
|
|
59
59
|
// Unsupported platforms and watch limits keep their owner's polling fallback.
|
|
60
|
-
this.
|
|
60
|
+
this.state = { kind: missing(error) ? "unarmed" : "polling" };
|
|
61
61
|
return "unwatched";
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
close() {
|
|
65
|
-
this.
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
if (this.state.kind === "armed") {
|
|
66
|
+
this.state.watcher.close();
|
|
67
|
+
this.state = { kind: "unarmed" };
|
|
68
|
+
}
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
/** A coalesced wake for one owner loop; raised until that loop takes it. */
|
|
@@ -82,6 +83,7 @@ export class Wake {
|
|
|
82
83
|
}
|
|
83
84
|
/** Returns at once for a wake raised since the last take. */
|
|
84
85
|
async sleep(ms) {
|
|
86
|
+
assert.equal(this.resolve, undefined, "A Wake belongs to one waiting owner loop.");
|
|
85
87
|
if (this.raised) {
|
|
86
88
|
return;
|
|
87
89
|
}
|
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.20","sha256":"6698668f485ef551faf914031fee7ad7165c9062a38c31b2827077d8e93e94b9"});
|
|
@@ -4,9 +4,7 @@ import { readBridgeFile, writeBridgeJson } from "./bridge-files.js";
|
|
|
4
4
|
import { log } from "./bridge-protocol.js";
|
|
5
5
|
export class PersistentPresence {
|
|
6
6
|
registrations = new Map();
|
|
7
|
-
|
|
8
|
-
timer;
|
|
9
|
-
running;
|
|
7
|
+
phase = { kind: "idle" };
|
|
10
8
|
owner;
|
|
11
9
|
constructor(owner) {
|
|
12
10
|
this.owner = owner;
|
|
@@ -61,21 +59,28 @@ export class PersistentPresence {
|
|
|
61
59
|
this.start();
|
|
62
60
|
}
|
|
63
61
|
start() {
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
73
|
-
}, 0);
|
|
62
|
+
if (this.phase.kind === "running" || this.phase.kind === "stopped") {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.schedule(0);
|
|
66
|
+
}
|
|
67
|
+
schedule(delay) {
|
|
68
|
+
if (this.phase.kind === "scheduled") {
|
|
69
|
+
clearTimeout(this.phase.timer);
|
|
74
70
|
}
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
const task = this.maintain().finally(() => {
|
|
73
|
+
if (this.phase.kind !== "stopped") {
|
|
74
|
+
this.schedule(15000);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
this.phase = { kind: "running", task };
|
|
78
|
+
}, delay);
|
|
79
|
+
this.phase = { kind: "scheduled", timer };
|
|
75
80
|
}
|
|
76
81
|
async maintain() {
|
|
77
82
|
for (const registration of this.registrations.values()) {
|
|
78
|
-
if (this.stopped) {
|
|
83
|
+
if (this.phase.kind === "stopped") {
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
81
86
|
const identity = this.owner.clients().get(registration.fingerprint);
|
|
@@ -95,9 +100,16 @@ export class PersistentPresence {
|
|
|
95
100
|
}
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
stop() {
|
|
104
|
+
const phase = this.phase;
|
|
105
|
+
if (phase.kind === "stopped") {
|
|
106
|
+
return phase.task;
|
|
107
|
+
}
|
|
108
|
+
if (phase.kind === "scheduled") {
|
|
109
|
+
clearTimeout(phase.timer);
|
|
110
|
+
}
|
|
111
|
+
const task = phase.kind === "running" ? phase.task : Promise.resolve();
|
|
112
|
+
this.phase = { kind: "stopped", task };
|
|
113
|
+
return task;
|
|
102
114
|
}
|
|
103
115
|
}
|
|
@@ -22,9 +22,7 @@ export class WakeDeliveries {
|
|
|
22
22
|
allowed;
|
|
23
23
|
deliveries = new Map();
|
|
24
24
|
controller = new AbortController();
|
|
25
|
-
|
|
26
|
-
running;
|
|
27
|
-
dirty = false;
|
|
25
|
+
phase = { kind: "idle" };
|
|
28
26
|
storageBackoff = 15000;
|
|
29
27
|
constructor(stateDir, allowed) {
|
|
30
28
|
this.stateDir = stateDir;
|
|
@@ -62,11 +60,17 @@ export class WakeDeliveries {
|
|
|
62
60
|
catch {
|
|
63
61
|
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
64
62
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const phase = this.phase;
|
|
64
|
+
switch (phase.kind) {
|
|
65
|
+
case "running":
|
|
66
|
+
this.phase = { kind: "running-dirty", task: phase.task };
|
|
67
|
+
return;
|
|
68
|
+
case "running-dirty":
|
|
69
|
+
case "stopped":
|
|
70
|
+
return;
|
|
71
|
+
case "idle":
|
|
72
|
+
case "scheduled":
|
|
73
|
+
this.schedule(Date.now());
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
reconcile() {
|
|
@@ -80,28 +84,36 @@ export class WakeDeliveries {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
schedule(deadline) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
if (this.phase.kind === "scheduled") {
|
|
88
|
+
clearTimeout(this.phase.timer);
|
|
89
|
+
}
|
|
90
|
+
const timer = setTimeout(() => this.startDelivery(), Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
|
|
91
|
+
this.phase = { kind: "scheduled", timer };
|
|
92
|
+
}
|
|
93
|
+
startDelivery() {
|
|
94
|
+
let next = Infinity;
|
|
95
|
+
const task = this.deliver()
|
|
96
|
+
.then((due) => {
|
|
97
|
+
next = due;
|
|
98
|
+
this.storageBackoff = 15000;
|
|
99
|
+
return;
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {
|
|
102
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
103
|
+
next = Date.now() + this.storageBackoff;
|
|
104
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
105
|
+
})
|
|
106
|
+
.finally(() => {
|
|
107
|
+
if (this.phase.kind === "stopped") {
|
|
91
108
|
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
this.
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
101
|
-
this.schedule(this.dirty ? Date.now() : next);
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
}, Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)));
|
|
109
|
+
}
|
|
110
|
+
const dirty = this.phase.kind === "running-dirty";
|
|
111
|
+
this.phase = { kind: "idle" };
|
|
112
|
+
if (dirty || Number.isFinite(next)) {
|
|
113
|
+
this.schedule(dirty ? Date.now() : next);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
this.phase = { kind: "running", task };
|
|
105
117
|
}
|
|
106
118
|
authorized(delivery) {
|
|
107
119
|
return (!this.controller.signal.aborted &&
|
|
@@ -233,14 +245,25 @@ export class WakeDeliveries {
|
|
|
233
245
|
}
|
|
234
246
|
this.changed(false);
|
|
235
247
|
}
|
|
236
|
-
|
|
237
|
-
this.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
stop() {
|
|
249
|
+
const phase = this.phase;
|
|
250
|
+
if (phase.kind === "stopped") {
|
|
251
|
+
return phase.task;
|
|
252
|
+
}
|
|
253
|
+
if (phase.kind === "scheduled") {
|
|
254
|
+
clearTimeout(phase.timer);
|
|
242
255
|
}
|
|
243
|
-
|
|
244
|
-
|
|
256
|
+
const running = phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
|
|
257
|
+
const task = Promise.resolve(running).then(() => {
|
|
258
|
+
for (const delivery of this.deliveries.values()) {
|
|
259
|
+
delivery.mailbox.close();
|
|
260
|
+
}
|
|
261
|
+
this.deliveries.clear();
|
|
262
|
+
this.state?.close();
|
|
263
|
+
return;
|
|
264
|
+
});
|
|
265
|
+
this.phase = { kind: "stopped", task };
|
|
266
|
+
this.controller.abort();
|
|
267
|
+
return task;
|
|
245
268
|
}
|
|
246
269
|
}
|
|
@@ -13,7 +13,7 @@ import { acceptBrokerConnection, registeredSessionInfo } from "./server-connecti
|
|
|
13
13
|
import { guardedDeliveryError } from "./guard.js";
|
|
14
14
|
import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
|
|
15
15
|
import { errorMessage, isRecord } from "../../shared/unknown.js";
|
|
16
|
-
import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
|
|
16
|
+
import { messageSender, snapshotFrames, TopicSnapshot, publicationMessage, preparedPublication, deliveryAcknowledgement, shouldReplaceDelivery, } from "./snapshots.js";
|
|
17
17
|
const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
|
|
18
18
|
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
19
19
|
class IntercomBroker {
|
|
@@ -192,7 +192,7 @@ class IntercomBroker {
|
|
|
192
192
|
? session.info
|
|
193
193
|
: session.topics.snapshot(session.info, prepared);
|
|
194
194
|
const from = messageSender(nextInfo);
|
|
195
|
-
const deliveries = this.planPublications(currentId, from, prepared
|
|
195
|
+
const deliveries = this.planPublications(currentId, from, preparedPublication(prepared));
|
|
196
196
|
// Validate every frame before committing state or delivering publications.
|
|
197
197
|
if (prepared.restore) {
|
|
198
198
|
session.topics.validateRestore(session.info, prepared, requestId);
|
|
@@ -3,6 +3,7 @@ import { errorMessage } from "../../shared/unknown.js";
|
|
|
3
3
|
import { isNativeReadQuery, isNativeReadResult, isLastMessageResult, isReceiptResult, } from "./last.js";
|
|
4
4
|
import { normalizeSessionInfo, } from "../types.js";
|
|
5
5
|
import { writeMessage } from "./framing.js";
|
|
6
|
+
import { PendingRequests } from "./pending-requests.js";
|
|
6
7
|
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
7
8
|
export function lastRequest(msg) {
|
|
8
9
|
const from = normalizeSessionInfo(msg.from);
|
|
@@ -24,26 +25,20 @@ export class LastMessageRequests {
|
|
|
24
25
|
constructor(timeoutMs) {
|
|
25
26
|
this.timeoutMs = timeoutMs;
|
|
26
27
|
}
|
|
27
|
-
pending = new
|
|
28
|
+
pending = new PendingRequests();
|
|
28
29
|
fail(error) {
|
|
29
30
|
this.lastMessages = false;
|
|
30
31
|
this.receiptReads = false;
|
|
31
|
-
|
|
32
|
-
clearTimeout(pending.timer);
|
|
33
|
-
pending.reject(error);
|
|
34
|
-
}
|
|
35
|
-
this.pending.clear();
|
|
32
|
+
this.pending.fail(error);
|
|
36
33
|
}
|
|
37
34
|
receive(message) {
|
|
38
35
|
if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
|
|
39
36
|
throw new Error("Invalid last-message response");
|
|
40
37
|
}
|
|
41
|
-
const pending = this.pending.
|
|
38
|
+
const pending = this.pending.take(message.requestId);
|
|
42
39
|
if (!pending) {
|
|
43
40
|
return;
|
|
44
41
|
}
|
|
45
|
-
clearTimeout(pending.timer);
|
|
46
|
-
this.pending.delete(message.requestId);
|
|
47
42
|
pending.resolve(message.result);
|
|
48
43
|
}
|
|
49
44
|
async last(socket, to, query) {
|
|
@@ -66,19 +61,12 @@ export class LastMessageRequests {
|
|
|
66
61
|
}
|
|
67
62
|
return new Promise((resolve, reject) => {
|
|
68
63
|
const requestId = randomUUID();
|
|
69
|
-
|
|
70
|
-
this.pending.delete(requestId);
|
|
71
|
-
reject(new Error("Last-message read timeout"));
|
|
72
|
-
}, this.timeoutMs);
|
|
73
|
-
timer.unref();
|
|
74
|
-
this.pending.set(requestId, { resolve, reject, timer });
|
|
64
|
+
this.pending.set(requestId, { resolve, reject }, this.timeoutMs, () => new Error("Last-message read timeout"));
|
|
75
65
|
try {
|
|
76
66
|
writeMessage(socket, { type: "last_request", requestId, to, query });
|
|
77
67
|
}
|
|
78
68
|
catch (error) {
|
|
79
|
-
|
|
80
|
-
this.pending.delete(requestId);
|
|
81
|
-
reject(new Error(errorMessage(error)));
|
|
69
|
+
this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
|
|
82
70
|
}
|
|
83
71
|
});
|
|
84
72
|
}
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { writeMessage } from "./framing.js";
|
|
4
4
|
import { BrokerConnection, connectBrokerSocket } from "./connection.js";
|
|
5
5
|
import { SnapshotReader } from "./snapshot-reader.js";
|
|
6
|
+
import { PendingRequests } from "./pending-requests.js";
|
|
6
7
|
import { LastMessageRequests, lastRequest } from "./client-last.js";
|
|
7
8
|
import { ParkRequests } from "./client-park.js";
|
|
8
9
|
import { WakeMessages } from "./client-wake.js";
|
|
@@ -36,8 +37,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
36
37
|
wakes = new WakeMessages(this, () => this.requireActiveSocket());
|
|
37
38
|
guardedDeliverySupported = false;
|
|
38
39
|
lastRequests;
|
|
39
|
-
pendingSends = new
|
|
40
|
-
pendingLists = new
|
|
40
|
+
pendingSends = new PendingRequests();
|
|
41
|
+
pendingLists = new PendingRequests();
|
|
41
42
|
connecting = false;
|
|
42
43
|
disconnecting = false;
|
|
43
44
|
sendTimeoutMs;
|
|
@@ -50,14 +51,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
50
51
|
}
|
|
51
52
|
failPending(error) {
|
|
52
53
|
this.lastRequests.fail(error);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
this.pendingSends.clear();
|
|
57
|
-
for (const pending of this.pendingLists.values()) {
|
|
58
|
-
pending.reject(error);
|
|
59
|
-
}
|
|
60
|
-
this.pendingLists.clear();
|
|
54
|
+
this.pendingSends.fail(error);
|
|
55
|
+
this.pendingLists.fail(error);
|
|
61
56
|
}
|
|
62
57
|
get sessionId() {
|
|
63
58
|
return this._sessionId;
|
|
@@ -240,12 +235,11 @@ export class IntercomClient extends EventEmitter {
|
|
|
240
235
|
if (type !== "delivered" && typeof reason !== "string") {
|
|
241
236
|
throw new Error(`Invalid ${errorMessage(type)} message`);
|
|
242
237
|
}
|
|
243
|
-
const pending = this.pendingSends.
|
|
238
|
+
const pending = this.pendingSends.take(messageId);
|
|
244
239
|
// Late responses are harmless after the caller has timed out.
|
|
245
240
|
if (!pending) {
|
|
246
241
|
return;
|
|
247
242
|
}
|
|
248
|
-
this.pendingSends.delete(messageId);
|
|
249
243
|
pending.resolve({
|
|
250
244
|
id: messageId,
|
|
251
245
|
accepted: type !== "delivery_failed",
|
|
@@ -266,14 +260,12 @@ export class IntercomClient extends EventEmitter {
|
|
|
266
260
|
return;
|
|
267
261
|
}
|
|
268
262
|
if (typeof error === "string") {
|
|
269
|
-
this.pendingLists.
|
|
270
|
-
pending.reject(new Error(error));
|
|
263
|
+
this.pendingLists.take(requestId)?.reject(new Error(error));
|
|
271
264
|
return;
|
|
272
265
|
}
|
|
273
266
|
pending.reader.append(sessions, receipts);
|
|
274
267
|
if (more !== true) {
|
|
275
|
-
this.pendingLists.
|
|
276
|
-
pending.resolve(pending.reader.finish());
|
|
268
|
+
this.pendingLists.take(requestId)?.resolve(pending.reader.finish());
|
|
277
269
|
}
|
|
278
270
|
}
|
|
279
271
|
park(on = true) {
|
|
@@ -319,8 +311,8 @@ export class IntercomClient extends EventEmitter {
|
|
|
319
311
|
}
|
|
320
312
|
return new Promise((resolve, reject) => {
|
|
321
313
|
const requestId = randomUUID();
|
|
314
|
+
const reader = new SnapshotReader();
|
|
322
315
|
const wrappedResolve = (snapshot) => {
|
|
323
|
-
clearTimeout(timeout);
|
|
324
316
|
try {
|
|
325
317
|
// Commit confirmed subscriptions before handling a following message in the same read.
|
|
326
318
|
onAccepted?.(snapshot);
|
|
@@ -331,21 +323,14 @@ export class IntercomClient extends EventEmitter {
|
|
|
331
323
|
}
|
|
332
324
|
};
|
|
333
325
|
const wrappedReject = (error) => {
|
|
334
|
-
|
|
326
|
+
reader.fail();
|
|
335
327
|
reject(error);
|
|
336
328
|
};
|
|
337
|
-
const timeout = setTimeout(() => {
|
|
338
|
-
if (this.pendingLists.has(requestId)) {
|
|
339
|
-
this.pendingLists.delete(requestId);
|
|
340
|
-
wrappedReject(Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
|
|
341
|
-
}
|
|
342
|
-
}, this.listTimeoutMs);
|
|
343
|
-
timeout.unref();
|
|
344
329
|
this.pendingLists.set(requestId, {
|
|
345
330
|
resolve: wrappedResolve,
|
|
346
331
|
reject: wrappedReject,
|
|
347
|
-
reader
|
|
348
|
-
});
|
|
332
|
+
reader,
|
|
333
|
+
}, this.listTimeoutMs, () => Object.assign(new Error("List sessions timeout"), { code: "list_timeout" }));
|
|
349
334
|
try {
|
|
350
335
|
writeMessage(socket, {
|
|
351
336
|
type: "list",
|
|
@@ -355,9 +340,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
355
340
|
});
|
|
356
341
|
}
|
|
357
342
|
catch (error) {
|
|
358
|
-
|
|
359
|
-
this.pendingLists.delete(requestId);
|
|
360
|
-
reject(toError(error));
|
|
343
|
+
this.pendingLists.take(requestId)?.reject(toError(error));
|
|
361
344
|
}
|
|
362
345
|
});
|
|
363
346
|
}
|
|
@@ -377,7 +360,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
377
360
|
return Promise.reject(toError(error));
|
|
378
361
|
}
|
|
379
362
|
const messageId = options.messageId ?? (options.guard ? "" : randomUUID());
|
|
380
|
-
if (this.pendingSends.
|
|
363
|
+
if (this.pendingSends.get(messageId)) {
|
|
381
364
|
return Promise.reject(new Error("Message ID already has a pending send"));
|
|
382
365
|
}
|
|
383
366
|
const message = {
|
|
@@ -402,22 +385,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
402
385
|
return Promise.reject(new Error("Invalid message format"));
|
|
403
386
|
}
|
|
404
387
|
return new Promise((resolve, reject) => {
|
|
405
|
-
|
|
406
|
-
clearTimeout(timeout);
|
|
407
|
-
resolve(result);
|
|
408
|
-
};
|
|
409
|
-
const wrappedReject = (error) => {
|
|
410
|
-
clearTimeout(timeout);
|
|
411
|
-
reject(error);
|
|
412
|
-
};
|
|
413
|
-
const timeout = setTimeout(() => {
|
|
414
|
-
if (this.pendingSends.has(messageId)) {
|
|
415
|
-
this.pendingSends.delete(messageId);
|
|
416
|
-
wrappedReject(new Error("Send timeout"));
|
|
417
|
-
}
|
|
418
|
-
}, this.sendTimeoutMs);
|
|
419
|
-
timeout.unref();
|
|
420
|
-
this.pendingSends.set(messageId, { resolve: wrappedResolve, reject: wrappedReject });
|
|
388
|
+
this.pendingSends.set(messageId, { resolve, reject }, this.sendTimeoutMs, () => new Error("Send timeout"));
|
|
421
389
|
try {
|
|
422
390
|
writeMessage(socket, {
|
|
423
391
|
type: "send",
|
|
@@ -426,9 +394,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
426
394
|
});
|
|
427
395
|
}
|
|
428
396
|
catch (error) {
|
|
429
|
-
|
|
430
|
-
this.pendingSends.delete(messageId);
|
|
431
|
-
reject(toError(error));
|
|
397
|
+
this.pendingSends.take(messageId)?.reject(toError(error));
|
|
432
398
|
}
|
|
433
399
|
});
|
|
434
400
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface Request {
|
|
2
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
3
|
+
}
|
|
4
|
+
/** Map membership owns settlement; taking a request also releases its deadline. */
|
|
5
|
+
export declare class PendingRequests<T extends Request> {
|
|
6
|
+
private readonly pending;
|
|
7
|
+
get(id: string): T | undefined;
|
|
8
|
+
set(id: string, value: T, timeoutMs: number, timeoutError: () => Error): void;
|
|
9
|
+
take(id: string): T | undefined;
|
|
10
|
+
fail(error: Readonly<Error>): void;
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Map membership owns settlement; taking a request also releases its deadline. */
|
|
2
|
+
export class PendingRequests {
|
|
3
|
+
pending = new Map();
|
|
4
|
+
get(id) {
|
|
5
|
+
return this.pending.get(id)?.value;
|
|
6
|
+
}
|
|
7
|
+
set(id, value, timeoutMs, timeoutError) {
|
|
8
|
+
const timer = setTimeout(() => this.take(id)?.reject(timeoutError()), timeoutMs);
|
|
9
|
+
timer.unref();
|
|
10
|
+
this.pending.set(id, { value, timer });
|
|
11
|
+
}
|
|
12
|
+
take(id) {
|
|
13
|
+
const pending = this.pending.get(id);
|
|
14
|
+
if (pending === undefined) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
this.pending.delete(id);
|
|
18
|
+
clearTimeout(pending.timer);
|
|
19
|
+
return pending.value;
|
|
20
|
+
}
|
|
21
|
+
fail(error) {
|
|
22
|
+
for (const id of this.pending.keys()) {
|
|
23
|
+
this.take(id)?.reject(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|