@fitchmultz/pi-subagents 0.52.19 → 0.52.21
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 +44 -0
- package/dist/agent/contracts.js +12 -3
- package/dist/agent/index.js +9 -9
- package/dist/agent/legacy.js +5 -5
- package/dist/agent/notifications.js +21 -14
- package/dist/agent/parent-check.js +38 -0
- package/dist/agent/process.js +45 -52
- package/dist/agent/runner.js +59 -64
- package/dist/agent/storage.js +16 -4
- package/dist/agent/view.js +1 -3
- 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 +4 -26
- package/dist/pi-intercom/broker/client-last.js +6 -18
- package/dist/pi-intercom/broker/client-park.d.ts +1 -1
- package/dist/pi-intercom/broker/client-park.js +22 -6
- package/dist/pi-intercom/broker/client.d.ts +3 -2
- package/dist/pi-intercom/broker/client.js +104 -112
- package/dist/pi-intercom/broker/connection.d.ts +3 -5
- package/dist/pi-intercom/broker/connection.js +48 -33
- package/dist/pi-intercom/broker/framing.d.ts +1 -1
- package/dist/pi-intercom/broker/framing.js +19 -12
- 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/replace-deliveries.js +38 -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/connection.js +5 -8
- package/dist/pi-intercom/inbound-journal.js +5 -2
- package/dist/shared/journal-frames.js +19 -24
- package/docs/development.md +6 -0
- package/package.json +1 -1
- package/src/agent/contracts.ts +12 -3
- package/src/agent/index.ts +10 -10
- package/src/agent/legacy.ts +5 -5
- package/src/agent/notifications.ts +35 -15
- package/src/agent/parent-check.ts +40 -0
- package/src/agent/process.ts +53 -55
- package/src/agent/runner.ts +65 -65
- package/src/agent/storage.ts +18 -5
- package/src/agent/view.ts +1 -4
- 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 +5 -37
- package/src/pi-intercom/broker/client-last.ts +14 -25
- package/src/pi-intercom/broker/client-park.ts +27 -6
- package/src/pi-intercom/broker/client.ts +127 -126
- package/src/pi-intercom/broker/connection.ts +58 -39
- package/src/pi-intercom/broker/framing.ts +24 -12
- package/src/pi-intercom/broker/pending-requests.ts +37 -0
- package/src/pi-intercom/broker/replace-deliveries.ts +56 -0
- package/src/pi-intercom/broker/snapshot-reader.ts +44 -16
- package/src/pi-intercom/connection.ts +9 -9
- package/src/pi-intercom/inbound-journal.ts +5 -2
- package/src/shared/journal-frames.ts +22 -23
package/src/agent/runner.ts
CHANGED
|
@@ -10,8 +10,9 @@ import {
|
|
|
10
10
|
type Result,
|
|
11
11
|
type AccountingState,
|
|
12
12
|
} from "./contracts.ts";
|
|
13
|
-
import {
|
|
14
|
-
import { ownIdentity,
|
|
13
|
+
import { commandIdentity, launchAt, read, runDirectory, write } from "./storage.ts";
|
|
14
|
+
import { ownIdentity, shells, signalProcess } from "./process.ts";
|
|
15
|
+
import { ParentCheck } from "./parent-check.ts";
|
|
15
16
|
import { errorText, loadSDK } from "./host.ts";
|
|
16
17
|
import { Parking, type ParkOutcome, type Runtime } from "./parking.ts";
|
|
17
18
|
import { accountingFields, UsageIndex } from "./usage.ts";
|
|
@@ -20,17 +21,6 @@ import { childRuntime } from "./child-runtime.ts";
|
|
|
20
21
|
import { ActorObservation, type ObservationStats } from "./observation.ts";
|
|
21
22
|
import { DirectoryWatch, Wake } from "./wake.ts";
|
|
22
23
|
|
|
23
|
-
function commandIdentity(file: string): string | undefined {
|
|
24
|
-
try {
|
|
25
|
-
const stat = fs.statSync(file, { bigint: true });
|
|
26
|
-
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
27
|
-
} catch (error) {
|
|
28
|
-
if (missing(error)) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
throw error;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
24
|
function completionStatus(
|
|
35
25
|
stopping: boolean,
|
|
36
26
|
message: AssistantMessage | undefined,
|
|
@@ -53,7 +43,11 @@ const failureReason = (
|
|
|
53
43
|
message?.errorMessage ??
|
|
54
44
|
(message ? undefined : "Native run produced no terminal assistant message.");
|
|
55
45
|
class Actor {
|
|
56
|
-
private
|
|
46
|
+
private work:
|
|
47
|
+
| { readonly kind: "idle" }
|
|
48
|
+
| { readonly kind: "running"; readonly launch: Launch; readonly task: Promise<void> } = {
|
|
49
|
+
kind: "idle",
|
|
50
|
+
};
|
|
57
51
|
private stopping = false;
|
|
58
52
|
private stopDeadline?: NodeJS.Timeout;
|
|
59
53
|
private nextParentCheck = 0;
|
|
@@ -63,7 +57,6 @@ class Actor {
|
|
|
63
57
|
private readonly wake = new Wake();
|
|
64
58
|
// A deferred run waits for an idle session, which has no command-file event.
|
|
65
59
|
private retry = false;
|
|
66
|
-
private current?: Launch | undefined;
|
|
67
60
|
private readonly errors: string[] = [];
|
|
68
61
|
private readonly started = new Set<string>();
|
|
69
62
|
// Actor-local validation metadata only; never retain command prompts or receipt authority.
|
|
@@ -151,7 +144,10 @@ class Actor {
|
|
|
151
144
|
this.stopping = true;
|
|
152
145
|
this.wake.raise();
|
|
153
146
|
write(path.join(this.dir, `stopping-${this.token}.json`), { at: Date.now() });
|
|
154
|
-
write(path.join(this.dir, "stop-acknowledged.json"), {
|
|
147
|
+
write(path.join(this.dir, "stop-acknowledged.json"), {
|
|
148
|
+
at: Date.now(),
|
|
149
|
+
id: this.work.kind === "running" ? this.work.launch.id : undefined,
|
|
150
|
+
});
|
|
155
151
|
this.session?.abort().catch((error: unknown) => {
|
|
156
152
|
this.errors.push(errorText(error));
|
|
157
153
|
});
|
|
@@ -181,29 +177,33 @@ class Actor {
|
|
|
181
177
|
if (!session) {
|
|
182
178
|
throw new Error("Native child session is not ready.");
|
|
183
179
|
}
|
|
184
|
-
this.current = launch;
|
|
185
180
|
const baseline = new Set(session.sessionManager.getEntries().map((entry) => entry.id));
|
|
181
|
+
const task = Promise.resolve().then(() => this.executeRun(launch, session, baseline));
|
|
182
|
+
this.work = { kind: "running", launch, task };
|
|
186
183
|
this.observation?.working(true);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
this.saveResult(launch, baseline, errorText(error));
|
|
201
|
-
} finally {
|
|
202
|
-
this.active = undefined;
|
|
203
|
-
this.current = undefined;
|
|
204
|
-
this.observation?.working(false);
|
|
184
|
+
}
|
|
185
|
+
private async executeRun(
|
|
186
|
+
launch: Launch,
|
|
187
|
+
session: AgentSession,
|
|
188
|
+
baseline: ReadonlySet<string>,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
try {
|
|
191
|
+
if (
|
|
192
|
+
launch.provider !== this.initial.provider ||
|
|
193
|
+
launch.model !== this.initial.model ||
|
|
194
|
+
launch.thinking !== this.initial.thinking
|
|
195
|
+
) {
|
|
196
|
+
throw new Error("Continuation model snapshot changed unexpectedly.");
|
|
205
197
|
}
|
|
206
|
-
|
|
198
|
+
await session.prompt(launch.prompt, { expandPromptTemplates: false });
|
|
199
|
+
await session.waitForIdle();
|
|
200
|
+
this.saveResult(launch, baseline);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.saveResult(launch, baseline, errorText(error));
|
|
203
|
+
} finally {
|
|
204
|
+
this.work = { kind: "idle" };
|
|
205
|
+
this.observation?.working(false);
|
|
206
|
+
}
|
|
207
207
|
}
|
|
208
208
|
private commandLaunch(command: Command): Launch | undefined {
|
|
209
209
|
const receipt = path.join(this.dir, "admissions", `${command.id}.json`);
|
|
@@ -235,7 +235,7 @@ class Actor {
|
|
|
235
235
|
}
|
|
236
236
|
if (
|
|
237
237
|
command.action === "run" &&
|
|
238
|
-
(this.stopping || this.
|
|
238
|
+
(this.stopping || this.work.kind === "running" || !session.isIdle)
|
|
239
239
|
) {
|
|
240
240
|
this.retry = true;
|
|
241
241
|
return;
|
|
@@ -260,33 +260,31 @@ class Actor {
|
|
|
260
260
|
this.applyCommand(command, launch, session);
|
|
261
261
|
}
|
|
262
262
|
private staleCommand(command: Command, launch: Launch, session: AgentSession): boolean {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
switch (command.action) {
|
|
264
|
+
case "stop":
|
|
265
|
+
return command.actorToken !== this.token;
|
|
266
|
+
case "steer":
|
|
267
|
+
return this.work.kind !== "running" || session.isIdle || this.work.launch.id !== launch.id;
|
|
268
|
+
case "run":
|
|
269
|
+
return false;
|
|
266
270
|
}
|
|
267
|
-
return (
|
|
268
|
-
command.action === "steer" &&
|
|
269
|
-
(this.active === undefined || session.isIdle || this.current?.id !== launch.id)
|
|
270
|
-
);
|
|
271
271
|
}
|
|
272
272
|
private applyCommand(command: Command, launch: Launch, session: AgentSession): void {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
switch (command.action) {
|
|
274
|
+
case "stop":
|
|
275
|
+
this.stop();
|
|
276
|
+
return;
|
|
277
|
+
case "run":
|
|
278
|
+
this.run(launch);
|
|
279
|
+
return;
|
|
280
|
+
case "steer":
|
|
281
|
+
// Synchronous native queue admission cannot turn into an idle prompt after an await.
|
|
282
|
+
session.agent.steer({
|
|
283
|
+
role: "user",
|
|
284
|
+
content: [{ type: "text", text: command.prompt }],
|
|
285
|
+
timestamp: Date.now(),
|
|
286
|
+
});
|
|
276
287
|
}
|
|
277
|
-
if (command.action === "run") {
|
|
278
|
-
this.run(launch);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
if (command.prompt === undefined) {
|
|
282
|
-
throw new Error("Missing guidance.");
|
|
283
|
-
}
|
|
284
|
-
// Synchronous native queue admission cannot turn into an idle prompt after an await.
|
|
285
|
-
session.agent.steer({
|
|
286
|
-
role: "user",
|
|
287
|
-
content: [{ type: "text", text: command.prompt }],
|
|
288
|
-
timestamp: Date.now(),
|
|
289
|
-
});
|
|
290
288
|
}
|
|
291
289
|
private pendingCommand(file: string): Command | undefined {
|
|
292
290
|
const identity = commandIdentity(file);
|
|
@@ -328,7 +326,7 @@ class Actor {
|
|
|
328
326
|
this.errors.push(errorText(error));
|
|
329
327
|
});
|
|
330
328
|
}
|
|
331
|
-
return this.stopping && this.
|
|
329
|
+
return this.stopping && this.work.kind === "idle" && (this.session?.isIdle ?? true);
|
|
332
330
|
}
|
|
333
331
|
/** Durable command readiness, not elapsed time, defines admission. */
|
|
334
332
|
private async admitCommands(runtime: Runtime, parked: Parking): Promise<void> {
|
|
@@ -368,14 +366,14 @@ class Actor {
|
|
|
368
366
|
this.parking = await parked.park(runtime, {
|
|
369
367
|
signal: owner.signal,
|
|
370
368
|
pi: this.context?.pi,
|
|
371
|
-
busy: this.stopping || this.
|
|
369
|
+
busy: this.stopping || this.work.kind === "running" || this.observation?.watched === true,
|
|
372
370
|
// Raced commands and viewers keep this actor resident; the loop admits after release.
|
|
373
371
|
quiet: () => {
|
|
374
372
|
const queued = this.pending().some((command) => this.commandLaunch(command));
|
|
375
373
|
if (queued) {
|
|
376
374
|
this.wake.raise();
|
|
377
375
|
}
|
|
378
|
-
const idle = this.
|
|
376
|
+
const idle = this.work.kind === "idle" && !this.stopping;
|
|
379
377
|
return !queued && idle && this.observation?.watched !== true;
|
|
380
378
|
},
|
|
381
379
|
errors: this.errors,
|
|
@@ -386,7 +384,7 @@ class Actor {
|
|
|
386
384
|
return this.parking !== "resident";
|
|
387
385
|
}
|
|
388
386
|
private failure(error: unknown, unopened?: string): void {
|
|
389
|
-
const launch = this.
|
|
387
|
+
const launch = this.work.kind === "running" ? this.work.launch : this.initial;
|
|
390
388
|
const result: Result = {
|
|
391
389
|
id: launch.id,
|
|
392
390
|
owner: launch.owner,
|
|
@@ -482,7 +480,9 @@ class Actor {
|
|
|
482
480
|
process.on("SIGTERM", this.stop);
|
|
483
481
|
process.on("SIGINT", this.stop);
|
|
484
482
|
await this.admitCommands(runtime, parked);
|
|
485
|
-
|
|
483
|
+
if (this.work.kind === "running") {
|
|
484
|
+
await this.work.task;
|
|
485
|
+
}
|
|
486
486
|
} catch (error) {
|
|
487
487
|
this.failure(error, parked?.opened === true ? undefined : parkedToken);
|
|
488
488
|
} finally {
|
package/src/agent/storage.ts
CHANGED
|
@@ -119,18 +119,31 @@ export function latest(base: string, initial: Launch): Launch {
|
|
|
119
119
|
export function conversation(base: string, launch: Launch): string {
|
|
120
120
|
return runDirectory(base, launch.rootId);
|
|
121
121
|
}
|
|
122
|
+
/** Metadata only: replacing command bytes invalidates the actor's parsed-command cache. */
|
|
123
|
+
export function commandIdentity(file: string): string | undefined {
|
|
124
|
+
try {
|
|
125
|
+
const stat = fs.statSync(file, { bigint: true });
|
|
126
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (missing(error)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
122
134
|
export function enqueue(
|
|
123
135
|
base: string,
|
|
124
136
|
launch: Launch,
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
input:
|
|
138
|
+
| { readonly action: "run" }
|
|
139
|
+
| { readonly action: "steer"; readonly prompt: string }
|
|
140
|
+
| { readonly action: "stop"; readonly actorToken: string },
|
|
127
141
|
): string {
|
|
128
|
-
const id = action === "run" ? launch.id : randomUUID();
|
|
142
|
+
const id = input.action === "run" ? launch.id : randomUUID();
|
|
129
143
|
const command: Command = {
|
|
130
144
|
id,
|
|
131
145
|
runId: launch.id,
|
|
132
|
-
|
|
133
|
-
...options,
|
|
146
|
+
...input,
|
|
134
147
|
createdAt: Date.now(),
|
|
135
148
|
};
|
|
136
149
|
write(path.join(conversation(base, launch), "commands", `${id}.json`), command, true);
|
package/src/agent/view.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { readOutputPage } from "../shared/output-page.ts";
|
|
|
5
5
|
import { actor, alive, observeAlive } from "./process.ts";
|
|
6
6
|
import {
|
|
7
7
|
admissionSchema,
|
|
8
|
-
|
|
8
|
+
commandMetadataSchema,
|
|
9
9
|
exitSchema,
|
|
10
10
|
successorSchema,
|
|
11
11
|
wakeFailureSchema,
|
|
@@ -16,9 +16,6 @@ import type { RunsInput } from "./tool-schemas.ts";
|
|
|
16
16
|
import { conversation, read, runDirectory } from "./storage.ts";
|
|
17
17
|
import { published } from "./notifications.ts";
|
|
18
18
|
import { accountingFields, accountingState, readCanonicalResult, readResult } from "./usage.ts";
|
|
19
|
-
import { Type } from "./typebox.ts";
|
|
20
|
-
|
|
21
|
-
const commandMetadataSchema = Type.Pick(commandSchema, ["id", "runId", "action", "createdAt"]);
|
|
22
19
|
interface Guidance {
|
|
23
20
|
id: string;
|
|
24
21
|
runId: string;
|
|
@@ -18,11 +18,15 @@ interface PresenceOwner {
|
|
|
18
18
|
readonly restored: (identity: Identity, expires: number) => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type PresencePhase =
|
|
22
|
+
| { readonly kind: "idle" }
|
|
23
|
+
| { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
|
|
24
|
+
| { readonly kind: "running"; readonly task: Promise<void> }
|
|
25
|
+
| { readonly kind: "stopped"; readonly task: Promise<void> };
|
|
26
|
+
|
|
21
27
|
export class PersistentPresence {
|
|
22
28
|
private readonly registrations = new Map<string, Registration>();
|
|
23
|
-
private
|
|
24
|
-
private timer?: NodeJS.Timeout;
|
|
25
|
-
private running?: Promise<void> | undefined;
|
|
29
|
+
private phase: PresencePhase = { kind: "idle" };
|
|
26
30
|
private readonly owner: PresenceOwner;
|
|
27
31
|
|
|
28
32
|
constructor(owner: PresenceOwner) {
|
|
@@ -88,22 +92,30 @@ export class PersistentPresence {
|
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
start(): void {
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}, 0);
|
|
95
|
+
if (this.phase.kind === "running" || this.phase.kind === "stopped") {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.schedule(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private schedule(delay: number): void {
|
|
102
|
+
if (this.phase.kind === "scheduled") {
|
|
103
|
+
clearTimeout(this.phase.timer);
|
|
101
104
|
}
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
const task = this.maintain().finally(() => {
|
|
107
|
+
if (this.phase.kind !== "stopped") {
|
|
108
|
+
this.schedule(15000);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
this.phase = { kind: "running", task };
|
|
112
|
+
}, delay);
|
|
113
|
+
this.phase = { kind: "scheduled", timer };
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
private async maintain(): Promise<void> {
|
|
105
117
|
for (const registration of this.registrations.values()) {
|
|
106
|
-
if (this.stopped) {
|
|
118
|
+
if (this.phase.kind === "stopped") {
|
|
107
119
|
return;
|
|
108
120
|
}
|
|
109
121
|
const identity = this.owner.clients().get(registration.fingerprint);
|
|
@@ -125,9 +137,16 @@ export class PersistentPresence {
|
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
stop(): Promise<void> {
|
|
141
|
+
const phase = this.phase;
|
|
142
|
+
if (phase.kind === "stopped") {
|
|
143
|
+
return phase.task;
|
|
144
|
+
}
|
|
145
|
+
if (phase.kind === "scheduled") {
|
|
146
|
+
clearTimeout(phase.timer);
|
|
147
|
+
}
|
|
148
|
+
const task = phase.kind === "running" ? phase.task : Promise.resolve();
|
|
149
|
+
this.phase = { kind: "stopped", task };
|
|
150
|
+
return task;
|
|
132
151
|
}
|
|
133
152
|
}
|
|
@@ -15,6 +15,12 @@ interface Candidate {
|
|
|
15
15
|
readonly entry: PendingNotification;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
type DeliveryPhase =
|
|
19
|
+
| { readonly kind: "idle" }
|
|
20
|
+
| { readonly kind: "scheduled"; readonly timer: NodeJS.Timeout }
|
|
21
|
+
| { readonly kind: "running" | "running-dirty"; readonly task: Promise<void> }
|
|
22
|
+
| { readonly kind: "stopped"; readonly task: Promise<void> };
|
|
23
|
+
|
|
18
24
|
function actionable(entry: PendingNotification): boolean {
|
|
19
25
|
const message = entry.message;
|
|
20
26
|
if (entry.acknowledged || message.passive === true || message.delivery === "passive") {
|
|
@@ -39,9 +45,7 @@ export class WakeDeliveries {
|
|
|
39
45
|
private readonly allowed: (identity: Identity) => boolean;
|
|
40
46
|
private readonly deliveries = new Map<string, Delivery>();
|
|
41
47
|
private readonly controller = new AbortController();
|
|
42
|
-
private
|
|
43
|
-
private running?: Promise<void> | undefined;
|
|
44
|
-
private dirty = false;
|
|
48
|
+
private phase: DeliveryPhase = { kind: "idle" };
|
|
45
49
|
private storageBackoff = 15000;
|
|
46
50
|
|
|
47
51
|
constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
|
|
@@ -83,10 +87,17 @@ export class WakeDeliveries {
|
|
|
83
87
|
} catch {
|
|
84
88
|
// The scheduled storage recovery fails closed; never let an event bypass durable admission.
|
|
85
89
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
const phase = this.phase;
|
|
91
|
+
switch (phase.kind) {
|
|
92
|
+
case "running":
|
|
93
|
+
this.phase = { kind: "running-dirty", task: phase.task };
|
|
94
|
+
return;
|
|
95
|
+
case "running-dirty":
|
|
96
|
+
case "stopped":
|
|
97
|
+
return;
|
|
98
|
+
case "idle":
|
|
99
|
+
case "scheduled":
|
|
100
|
+
this.schedule(Date.now());
|
|
90
101
|
}
|
|
91
102
|
}
|
|
92
103
|
|
|
@@ -101,31 +112,40 @@ export class WakeDeliveries {
|
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
private schedule(deadline: number): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.running = this.deliver()
|
|
110
|
-
.then((due) => {
|
|
111
|
-
next = due;
|
|
112
|
-
this.storageBackoff = 15000;
|
|
113
|
-
return;
|
|
114
|
-
})
|
|
115
|
-
.catch(() => {
|
|
116
|
-
log("wake_delivery", { result: "storage_unavailable" });
|
|
117
|
-
next = Date.now() + this.storageBackoff;
|
|
118
|
-
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
119
|
-
})
|
|
120
|
-
.finally(() => {
|
|
121
|
-
this.running = undefined;
|
|
122
|
-
if (!this.controller.signal.aborted && (this.dirty || Number.isFinite(next))) {
|
|
123
|
-
this.schedule(this.dirty ? Date.now() : next);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
},
|
|
115
|
+
if (this.phase.kind === "scheduled") {
|
|
116
|
+
clearTimeout(this.phase.timer);
|
|
117
|
+
}
|
|
118
|
+
const timer = setTimeout(
|
|
119
|
+
() => this.startDelivery(),
|
|
127
120
|
Math.max(0, Math.min(deadline - Date.now(), 0x7fffffff)),
|
|
128
121
|
);
|
|
122
|
+
this.phase = { kind: "scheduled", timer };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private startDelivery(): void {
|
|
126
|
+
let next = Infinity;
|
|
127
|
+
const task = this.deliver()
|
|
128
|
+
.then((due) => {
|
|
129
|
+
next = due;
|
|
130
|
+
this.storageBackoff = 15000;
|
|
131
|
+
return;
|
|
132
|
+
})
|
|
133
|
+
.catch(() => {
|
|
134
|
+
log("wake_delivery", { result: "storage_unavailable" });
|
|
135
|
+
next = Date.now() + this.storageBackoff;
|
|
136
|
+
this.storageBackoff = Math.min(this.storageBackoff * 2, 300000);
|
|
137
|
+
})
|
|
138
|
+
.finally(() => {
|
|
139
|
+
if (this.phase.kind === "stopped") {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const dirty = this.phase.kind === "running-dirty";
|
|
143
|
+
this.phase = { kind: "idle" };
|
|
144
|
+
if (dirty || Number.isFinite(next)) {
|
|
145
|
+
this.schedule(dirty ? Date.now() : next);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
this.phase = { kind: "running", task };
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
private authorized(delivery: Delivery): boolean {
|
|
@@ -267,14 +287,26 @@ export class WakeDeliveries {
|
|
|
267
287
|
this.changed(false);
|
|
268
288
|
}
|
|
269
289
|
|
|
270
|
-
|
|
271
|
-
this.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
for (const delivery of this.deliveries.values()) {
|
|
275
|
-
delivery.mailbox.close();
|
|
290
|
+
stop(): Promise<void> {
|
|
291
|
+
const phase = this.phase;
|
|
292
|
+
if (phase.kind === "stopped") {
|
|
293
|
+
return phase.task;
|
|
276
294
|
}
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
if (phase.kind === "scheduled") {
|
|
296
|
+
clearTimeout(phase.timer);
|
|
297
|
+
}
|
|
298
|
+
const running =
|
|
299
|
+
phase.kind === "running" || phase.kind === "running-dirty" ? phase.task : undefined;
|
|
300
|
+
const task = Promise.resolve(running).then(() => {
|
|
301
|
+
for (const delivery of this.deliveries.values()) {
|
|
302
|
+
delivery.mailbox.close();
|
|
303
|
+
}
|
|
304
|
+
this.deliveries.clear();
|
|
305
|
+
this.state?.close();
|
|
306
|
+
return;
|
|
307
|
+
});
|
|
308
|
+
this.phase = { kind: "stopped", task };
|
|
309
|
+
this.controller.abort();
|
|
310
|
+
return task;
|
|
279
311
|
}
|
|
280
312
|
}
|
|
@@ -34,8 +34,9 @@ import {
|
|
|
34
34
|
shouldReplaceDelivery,
|
|
35
35
|
} from "./snapshots.ts";
|
|
36
36
|
|
|
37
|
+
import { ReplaceDeliveries } from "./replace-deliveries.ts";
|
|
38
|
+
|
|
37
39
|
const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
|
|
38
|
-
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
39
40
|
|
|
40
41
|
interface ConnectedSession extends ParkablePeer {
|
|
41
42
|
info: SessionInfo;
|
|
@@ -45,18 +46,11 @@ interface ConnectedSession extends ParkablePeer {
|
|
|
45
46
|
readonly receiptReads: boolean;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
interface PendingReplaceDelivery {
|
|
49
|
-
readonly fromId: string;
|
|
50
|
-
readonly toId: string;
|
|
51
|
-
readonly message: Message;
|
|
52
|
-
readonly timer: NodeJS.Timeout;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
49
|
class IntercomBroker {
|
|
56
50
|
private readonly sessions = new Map<string, ConnectedSession>();
|
|
57
51
|
private readonly lastMessages = new LastMessageRelay(this.sessions);
|
|
58
52
|
private readonly parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
|
|
59
|
-
private readonly
|
|
53
|
+
private readonly replacements = new ReplaceDeliveries(this.deliverMessage.bind(this));
|
|
60
54
|
private readonly server: net.Server;
|
|
61
55
|
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
62
56
|
private readonly socketPath: string;
|
|
@@ -201,7 +195,7 @@ class IntercomBroker {
|
|
|
201
195
|
this.lastMessages.disconnected(sessionId);
|
|
202
196
|
if (!this.parking.closed(session)) {
|
|
203
197
|
this.sessions.delete(sessionId);
|
|
204
|
-
this.
|
|
198
|
+
this.replacements.disconnected(sessionId);
|
|
205
199
|
this.broadcast({ type: "session_left", sessionId }, session.info);
|
|
206
200
|
}
|
|
207
201
|
this.scheduleShutdownCheck();
|
|
@@ -417,10 +411,6 @@ class IntercomBroker {
|
|
|
417
411
|
};
|
|
418
412
|
}
|
|
419
413
|
|
|
420
|
-
private replaceKey(fromId: string, toId: string, threadId: string): string {
|
|
421
|
-
return `${fromId}\0${toId}\0${threadId}`;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
414
|
private queueReplaceDelivery(
|
|
425
415
|
fromId: string,
|
|
426
416
|
toId: string,
|
|
@@ -431,17 +421,7 @@ class IntercomBroker {
|
|
|
431
421
|
if (validationError !== null) {
|
|
432
422
|
return { id: message.id, accepted: false, delivered: false, reason: validationError };
|
|
433
423
|
}
|
|
434
|
-
|
|
435
|
-
const existing = this.pendingReplaceDeliveries.get(key);
|
|
436
|
-
if (existing) {
|
|
437
|
-
clearTimeout(existing.timer);
|
|
438
|
-
}
|
|
439
|
-
const timer = setTimeout(() => {
|
|
440
|
-
this.pendingReplaceDeliveries.delete(key);
|
|
441
|
-
this.deliverMessage(toId, from, message);
|
|
442
|
-
}, REPLACE_DELIVERY_DELAY_MS);
|
|
443
|
-
timer.unref();
|
|
444
|
-
this.pendingReplaceDeliveries.set(key, { fromId, toId, message, timer });
|
|
424
|
+
this.replacements.stage(fromId, toId, from, message);
|
|
445
425
|
return {
|
|
446
426
|
id: message.id,
|
|
447
427
|
accepted: true,
|
|
@@ -451,18 +431,6 @@ class IntercomBroker {
|
|
|
451
431
|
};
|
|
452
432
|
}
|
|
453
433
|
|
|
454
|
-
private clearPendingReplaceDeliveries(sessionId: string): void {
|
|
455
|
-
for (const [key, pending] of this.pendingReplaceDeliveries) {
|
|
456
|
-
if (
|
|
457
|
-
pending.toId === sessionId ||
|
|
458
|
-
(pending.fromId === sessionId && pending.message.expectsReply === true)
|
|
459
|
-
) {
|
|
460
|
-
clearTimeout(pending.timer);
|
|
461
|
-
this.pendingReplaceDeliveries.delete(key);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
434
|
/** One admission rule; retained input is checked as the uncompacted envelope any later
|
|
467
435
|
* transport generation accepts. */
|
|
468
436
|
private validateDeliveryPayload(
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type SessionInfo,
|
|
19
19
|
} from "../types.ts";
|
|
20
20
|
import { writeMessage } from "./framing.ts";
|
|
21
|
+
import { PendingRequests } from "./pending-requests.ts";
|
|
21
22
|
|
|
22
23
|
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
23
24
|
export function lastRequest(msg: UnknownRecord): {
|
|
@@ -46,35 +47,25 @@ export class LastMessageRequests {
|
|
|
46
47
|
constructor(timeoutMs: number) {
|
|
47
48
|
this.timeoutMs = timeoutMs;
|
|
48
49
|
}
|
|
49
|
-
private readonly pending = new
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
readonly reject: (error: Readonly<Error>) => void;
|
|
54
|
-
readonly timer: NodeJS.Timeout;
|
|
55
|
-
}
|
|
56
|
-
>();
|
|
50
|
+
private readonly pending = new PendingRequests<{
|
|
51
|
+
readonly resolve: (result: NativeReadResult) => void;
|
|
52
|
+
readonly reject: (error: Readonly<Error>) => void;
|
|
53
|
+
}>();
|
|
57
54
|
|
|
58
55
|
fail(error: Readonly<Error>): void {
|
|
59
56
|
this.lastMessages = false;
|
|
60
57
|
this.receiptReads = false;
|
|
61
|
-
|
|
62
|
-
clearTimeout(pending.timer);
|
|
63
|
-
pending.reject(error);
|
|
64
|
-
}
|
|
65
|
-
this.pending.clear();
|
|
58
|
+
this.pending.fail(error);
|
|
66
59
|
}
|
|
67
60
|
|
|
68
61
|
receive(message: UnknownRecord): void {
|
|
69
62
|
if (typeof message.requestId !== "string" || !isNativeReadResult(message.result)) {
|
|
70
63
|
throw new Error("Invalid last-message response");
|
|
71
64
|
}
|
|
72
|
-
const pending = this.pending.
|
|
65
|
+
const pending = this.pending.take(message.requestId);
|
|
73
66
|
if (!pending) {
|
|
74
67
|
return;
|
|
75
68
|
}
|
|
76
|
-
clearTimeout(pending.timer);
|
|
77
|
-
this.pending.delete(message.requestId);
|
|
78
69
|
pending.resolve(message.result);
|
|
79
70
|
}
|
|
80
71
|
|
|
@@ -100,18 +91,16 @@ export class LastMessageRequests {
|
|
|
100
91
|
}
|
|
101
92
|
return new Promise((resolve, reject) => {
|
|
102
93
|
const requestId = randomUUID();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
94
|
+
this.pending.set(
|
|
95
|
+
requestId,
|
|
96
|
+
{ resolve, reject },
|
|
97
|
+
this.timeoutMs,
|
|
98
|
+
() => new Error("Last-message read timeout"),
|
|
99
|
+
);
|
|
109
100
|
try {
|
|
110
101
|
writeMessage(socket, { type: "last_request", requestId, to, query });
|
|
111
102
|
} catch (error) {
|
|
112
|
-
|
|
113
|
-
this.pending.delete(requestId);
|
|
114
|
-
reject(new Error(errorMessage(error)));
|
|
103
|
+
this.pending.take(requestId)?.reject(new Error(errorMessage(error)));
|
|
115
104
|
}
|
|
116
105
|
});
|
|
117
106
|
}
|