@fitchmultz/pi-subagents 0.51.0 → 0.52.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.
- package/CHANGELOG.md +12 -0
- package/dist/agent/child-runtime.js +80 -0
- package/dist/agent/contracts.js +9 -0
- package/dist/agent/index.js +6 -20
- package/dist/agent/observation.js +4 -0
- package/dist/agent/parking.js +253 -0
- package/dist/agent/process.js +31 -4
- package/dist/agent/runner.js +124 -126
- package/dist/agent/storage.js +18 -0
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +42 -29
- package/dist/pi-intercom/broker/client-last.d.ts +12 -1
- package/dist/pi-intercom/broker/client-last.js +25 -0
- package/dist/pi-intercom/broker/client-park.d.ts +9 -0
- package/dist/pi-intercom/broker/client-park.js +21 -0
- package/dist/pi-intercom/broker/client.d.ts +2 -3
- package/dist/pi-intercom/broker/client.js +23 -28
- package/dist/pi-intercom/broker/last.d.ts +5 -1
- package/dist/pi-intercom/broker/last.js +34 -12
- package/dist/pi-intercom/broker/parked.js +107 -0
- package/dist/pi-intercom/broker/server-connection.js +1 -1
- package/dist/pi-intercom/connection.js +65 -22
- package/dist/pi-intercom/inbound.js +11 -0
- package/dist/pi-intercom/index.js +38 -0
- package/dist/shared/parking.js +12 -0
- package/docs/code-quality.md +5 -5
- package/docs/development.md +3 -3
- package/docs/intercom.md +2 -1
- package/docs/performance.md +31 -3
- package/docs/reference.md +12 -1
- package/package.json +1 -1
- package/scripts/measure-size.mjs +1 -1
- package/src/agent/child-runtime.ts +105 -0
- package/src/agent/contracts.ts +10 -0
- package/src/agent/host.ts +22 -2
- package/src/agent/index.ts +10 -17
- package/src/agent/observation.ts +4 -0
- package/src/agent/parking.ts +341 -0
- package/src/agent/process.ts +41 -4
- package/src/agent/runner.ts +124 -144
- package/src/agent/storage.ts +17 -0
- package/src/pi-intercom/broker/broker.ts +44 -37
- package/src/pi-intercom/broker/client-last.ts +41 -1
- package/src/pi-intercom/broker/client-park.ts +23 -0
- package/src/pi-intercom/broker/client.ts +24 -28
- package/src/pi-intercom/broker/last.ts +49 -14
- package/src/pi-intercom/broker/parked.ts +130 -0
- package/src/pi-intercom/broker/server-connection.ts +2 -2
- package/src/pi-intercom/connection.ts +87 -34
- package/src/pi-intercom/inbound.ts +14 -0
- package/src/pi-intercom/index.ts +41 -0
- package/src/pi-intercom/last-message.ts +1 -1
- package/src/shared/parking.ts +22 -0
package/dist/agent/runner.js
CHANGED
|
@@ -2,10 +2,12 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { commandSchema } from "./contracts.js";
|
|
4
4
|
import { launchAt, missing, read, runDirectory, write } from "./storage.js";
|
|
5
|
-
import { ownIdentity, ParentCheck,
|
|
5
|
+
import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.js";
|
|
6
6
|
import { errorText, loadSDK } from "./host.js";
|
|
7
|
+
import { Parking } from "./parking.js";
|
|
7
8
|
import { childUsage } from "./usage.js";
|
|
8
|
-
import { initializeCwd
|
|
9
|
+
import { initializeCwd } from "./cwd.js";
|
|
10
|
+
import { childRuntime } from "./child-runtime.js";
|
|
9
11
|
import { ActorObservation } from "./observation.js";
|
|
10
12
|
import { DirectoryWatch, Wake } from "./wake.js";
|
|
11
13
|
function commandIdentity(file) {
|
|
@@ -29,28 +31,12 @@ function completionStatus(stopping, message, error) {
|
|
|
29
31
|
}
|
|
30
32
|
return "completed";
|
|
31
33
|
}
|
|
32
|
-
function guard(pi, directory, token) {
|
|
33
|
-
pi.on("tool_call", (event) => {
|
|
34
|
-
if (event.toolName === "delegate" || event.toolName === "agent_runs") {
|
|
35
|
-
return { block: true, reason: "Only a main session may delegate or control children." };
|
|
36
|
-
}
|
|
37
|
-
const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
|
|
38
|
-
if (event.toolName === "bash" &&
|
|
39
|
-
typeof event.input.command === "string" &&
|
|
40
|
-
owner &&
|
|
41
|
-
(owner.source === "builtin" || localCwdOwner(owner))) {
|
|
42
|
-
// Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
|
|
43
|
-
const input = event.input;
|
|
44
|
-
input.command = shellCommand(event.input.command, directory, token);
|
|
45
|
-
}
|
|
46
|
-
return;
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
34
|
class Actor {
|
|
50
35
|
active;
|
|
51
36
|
stopping = false;
|
|
52
37
|
stopDeadline;
|
|
53
38
|
nextParentCheck = 0;
|
|
39
|
+
parking = false;
|
|
54
40
|
parent = new ParentCheck();
|
|
55
41
|
// Raised when durable commands may need admission: change hints and stop.
|
|
56
42
|
wake = new Wake();
|
|
@@ -128,63 +114,6 @@ class Actor {
|
|
|
128
114
|
.map((block) => block.text)
|
|
129
115
|
.join("\n") ?? "");
|
|
130
116
|
}
|
|
131
|
-
createRuntime(sdk) {
|
|
132
|
-
return async ({ sessionManager }) => {
|
|
133
|
-
const launch = this.initial;
|
|
134
|
-
if (!fs.existsSync(launch.sessionFile)) {
|
|
135
|
-
throw new Error("Missing saved native session; refusing to create another.");
|
|
136
|
-
}
|
|
137
|
-
const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
|
|
138
|
-
projectTrusted: launch.trusted,
|
|
139
|
-
});
|
|
140
|
-
const services = await sdk.createAgentSessionServices({
|
|
141
|
-
cwd: launch.cwd,
|
|
142
|
-
agentDir: launch.agentDir,
|
|
143
|
-
settingsManager,
|
|
144
|
-
resourceLoaderOptions: {
|
|
145
|
-
additionalExtensionPaths: [launch.entry],
|
|
146
|
-
extensionFactories: [
|
|
147
|
-
{
|
|
148
|
-
name: "codemode",
|
|
149
|
-
builtin: true,
|
|
150
|
-
factory: sdk.createCodemodeExtension({ mode: "on" }),
|
|
151
|
-
},
|
|
152
|
-
{ name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
|
|
153
|
-
{ name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
|
|
154
|
-
(pi) => {
|
|
155
|
-
guard(pi, this.dir, this.token);
|
|
156
|
-
pi.on("session_start", (_event, ctx) => {
|
|
157
|
-
this.context = { pi, ctx };
|
|
158
|
-
});
|
|
159
|
-
// Sparse tool lifecycle keeps the live view's tools even while nobody watches.
|
|
160
|
-
const tool = (event) => this.observation?.tool(event);
|
|
161
|
-
pi.on("tool_execution_start", tool);
|
|
162
|
-
pi.on("tool_execution_update", tool);
|
|
163
|
-
pi.on("tool_execution_end", tool);
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
|
-
const diagnostics = services.resourceLoader.getExtensions().errors;
|
|
169
|
-
if (diagnostics.length > 0) {
|
|
170
|
-
throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
|
|
171
|
-
}
|
|
172
|
-
const model = services.modelRuntime.getModel(launch.provider, launch.model);
|
|
173
|
-
if (!model) {
|
|
174
|
-
throw new Error(`Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`);
|
|
175
|
-
}
|
|
176
|
-
return {
|
|
177
|
-
...(await sdk.createAgentSessionFromServices({
|
|
178
|
-
services,
|
|
179
|
-
sessionManager,
|
|
180
|
-
model,
|
|
181
|
-
thinkingLevel: launch.thinking,
|
|
182
|
-
})),
|
|
183
|
-
services,
|
|
184
|
-
diagnostics: services.diagnostics,
|
|
185
|
-
};
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
117
|
stop = () => {
|
|
189
118
|
if (this.stopping) {
|
|
190
119
|
return;
|
|
@@ -362,7 +291,7 @@ class Actor {
|
|
|
362
291
|
return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
|
|
363
292
|
}
|
|
364
293
|
/** Durable command readiness, not elapsed time, defines admission. */
|
|
365
|
-
async admitCommands() {
|
|
294
|
+
async admitCommands(runtime, parked) {
|
|
366
295
|
// Queued commands create this directory; otherwise pending() does and the next pass arms.
|
|
367
296
|
const directory = path.join(this.dir, "commands");
|
|
368
297
|
const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
|
|
@@ -374,6 +303,10 @@ class Actor {
|
|
|
374
303
|
this.retry = false;
|
|
375
304
|
this.pending().forEach((command) => this.accept(command));
|
|
376
305
|
}
|
|
306
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
307
|
+
if (await this.park(runtime, parked)) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
377
310
|
const polling = this.retry || this.stopping || watch === "unwatched";
|
|
378
311
|
const tick = Math.max(1, this.nextParentCheck - performance.now());
|
|
379
312
|
// oxlint-disable-next-line no-await-in-loop
|
|
@@ -386,6 +319,36 @@ class Actor {
|
|
|
386
319
|
commands.close();
|
|
387
320
|
}
|
|
388
321
|
}
|
|
322
|
+
/** Parks a final-idle actor; the parent/stop owner stays live while preparation awaits. */
|
|
323
|
+
async park(runtime, parked) {
|
|
324
|
+
const owner = new AbortController();
|
|
325
|
+
const watch = setInterval(() => {
|
|
326
|
+
if (this.shouldExit() || this.stopping) {
|
|
327
|
+
owner.abort();
|
|
328
|
+
}
|
|
329
|
+
}, 100);
|
|
330
|
+
try {
|
|
331
|
+
this.parking = await parked.park(runtime, {
|
|
332
|
+
signal: owner.signal,
|
|
333
|
+
pi: this.context?.pi,
|
|
334
|
+
busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
|
|
335
|
+
// Raced commands and viewers keep this actor resident; the loop admits after release.
|
|
336
|
+
quiet: () => {
|
|
337
|
+
const queued = this.pending().some((command) => this.commandLaunch(command));
|
|
338
|
+
if (queued) {
|
|
339
|
+
this.wake.raise();
|
|
340
|
+
}
|
|
341
|
+
const idle = this.active === undefined && !this.stopping;
|
|
342
|
+
return !queued && idle && this.observation?.watched !== true;
|
|
343
|
+
},
|
|
344
|
+
errors: this.errors,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
clearInterval(watch);
|
|
349
|
+
}
|
|
350
|
+
return this.parking;
|
|
351
|
+
}
|
|
389
352
|
failure(error) {
|
|
390
353
|
const launch = this.current ?? this.initial;
|
|
391
354
|
const result = {
|
|
@@ -402,69 +365,104 @@ class Actor {
|
|
|
402
365
|
}
|
|
403
366
|
process.exitCode = 1;
|
|
404
367
|
}
|
|
368
|
+
async start(sdk, parked) {
|
|
369
|
+
const sessionManager = parked.open();
|
|
370
|
+
const factory = childRuntime(sdk, this.initial, {
|
|
371
|
+
directory: this.dir,
|
|
372
|
+
token: this.token,
|
|
373
|
+
saved: parked.saved,
|
|
374
|
+
started: (pi, ctx) => {
|
|
375
|
+
this.context = { pi, ctx };
|
|
376
|
+
},
|
|
377
|
+
tool: (event) => this.observation?.tool(event),
|
|
378
|
+
});
|
|
379
|
+
const runtime = await sdk.createAgentSessionRuntime(factory, {
|
|
380
|
+
cwd: this.initial.cwd,
|
|
381
|
+
agentDir: this.initial.agentDir,
|
|
382
|
+
sessionManager,
|
|
383
|
+
});
|
|
384
|
+
try {
|
|
385
|
+
await this.ready(runtime, parked);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
// A runtime that never became ready is still disposed, so the failed actor exits.
|
|
389
|
+
await runtime.dispose();
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
return runtime;
|
|
393
|
+
}
|
|
394
|
+
async ready(runtime, parked) {
|
|
395
|
+
this.session = runtime.session;
|
|
396
|
+
await this.session.bindExtensions({
|
|
397
|
+
mode: "print",
|
|
398
|
+
onError: (error) => {
|
|
399
|
+
this.errors.push(error.error);
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
if (!this.context) {
|
|
403
|
+
throw new Error("Child extension context is unavailable.");
|
|
404
|
+
}
|
|
405
|
+
initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
|
|
406
|
+
if (this.initial.name !== undefined && !parked.saved) {
|
|
407
|
+
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
408
|
+
}
|
|
409
|
+
// Optional live view, bound before readiness; its faults never change native execution.
|
|
410
|
+
this.observation = await ActorObservation.open(this.session, {
|
|
411
|
+
token: this.token,
|
|
412
|
+
rootId: this.initial.rootId,
|
|
413
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
414
|
+
});
|
|
415
|
+
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
416
|
+
...ownIdentity(),
|
|
417
|
+
token: this.token,
|
|
418
|
+
});
|
|
419
|
+
write(path.join(this.dir, "ready.json"), {
|
|
420
|
+
pid: process.pid,
|
|
421
|
+
token: this.token,
|
|
422
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
423
|
+
at: Date.now(),
|
|
424
|
+
});
|
|
425
|
+
parked.adopt();
|
|
426
|
+
}
|
|
405
427
|
async serve() {
|
|
406
428
|
const sdk = await loadSDK(this.initial.sdk);
|
|
429
|
+
const parked = new Parking(this.base, this.initial, this.token, sdk);
|
|
407
430
|
let runtime;
|
|
408
431
|
try {
|
|
409
|
-
runtime = await
|
|
410
|
-
cwd: this.initial.cwd,
|
|
411
|
-
agentDir: this.initial.agentDir,
|
|
412
|
-
sessionManager: sdk.SessionManager.open(this.initial.sessionFile, undefined, this.initial.cwd),
|
|
413
|
-
});
|
|
414
|
-
this.session = runtime.session;
|
|
415
|
-
await this.session.bindExtensions({
|
|
416
|
-
mode: "print",
|
|
417
|
-
onError: (error) => {
|
|
418
|
-
this.errors.push(error.error);
|
|
419
|
-
},
|
|
420
|
-
});
|
|
421
|
-
if (!this.context) {
|
|
422
|
-
throw new Error("Child extension context is unavailable.");
|
|
423
|
-
}
|
|
424
|
-
initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
|
|
425
|
-
if (this.initial.name !== undefined) {
|
|
426
|
-
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
427
|
-
}
|
|
428
|
-
// Optional live view, bound before readiness; its faults never change native execution.
|
|
429
|
-
this.observation = await ActorObservation.open(this.session, {
|
|
430
|
-
token: this.token,
|
|
431
|
-
rootId: this.initial.rootId,
|
|
432
|
-
sessionId: this.session.sessionManager.getSessionId(),
|
|
433
|
-
});
|
|
434
|
-
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
435
|
-
...ownIdentity(),
|
|
436
|
-
token: this.token,
|
|
437
|
-
});
|
|
438
|
-
write(path.join(this.dir, "ready.json"), {
|
|
439
|
-
pid: process.pid,
|
|
440
|
-
token: this.token,
|
|
441
|
-
sessionId: this.session.sessionManager.getSessionId(),
|
|
442
|
-
at: Date.now(),
|
|
443
|
-
});
|
|
432
|
+
runtime = await this.start(sdk, parked);
|
|
444
433
|
process.on("SIGTERM", this.stop);
|
|
445
434
|
process.on("SIGINT", this.stop);
|
|
446
|
-
await this.admitCommands();
|
|
435
|
+
await this.admitCommands(runtime, parked);
|
|
447
436
|
await this.active;
|
|
448
437
|
}
|
|
449
438
|
catch (error) {
|
|
450
439
|
this.failure(error);
|
|
451
440
|
}
|
|
452
441
|
finally {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
442
|
+
await this.finish(runtime);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async finish(runtime) {
|
|
446
|
+
process.off("SIGTERM", this.stop);
|
|
447
|
+
process.off("SIGINT", this.stop);
|
|
448
|
+
const observation = this.observation?.close();
|
|
449
|
+
if (!this.parking) {
|
|
456
450
|
await runtime?.dispose();
|
|
457
|
-
if (this.stopping) {
|
|
458
|
-
this.stopShells("SIGKILL");
|
|
459
|
-
}
|
|
460
|
-
clearTimeout(this.stopDeadline);
|
|
461
|
-
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
462
|
-
at: Date.now(),
|
|
463
|
-
errors: this.errors,
|
|
464
|
-
...(observation === undefined ? {} : { observation }),
|
|
465
|
-
});
|
|
466
|
-
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
467
451
|
}
|
|
452
|
+
if (this.stopping) {
|
|
453
|
+
this.stopShells("SIGKILL");
|
|
454
|
+
}
|
|
455
|
+
clearTimeout(this.stopDeadline);
|
|
456
|
+
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
457
|
+
at: Date.now(),
|
|
458
|
+
errors: this.errors,
|
|
459
|
+
...(observation === undefined ? {} : { observation }),
|
|
460
|
+
});
|
|
461
|
+
if (this.parking) {
|
|
462
|
+
// Parked authority requires actual exit; no leftover extension handle may keep it alive.
|
|
463
|
+
process.exit();
|
|
464
|
+
}
|
|
465
|
+
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
468
466
|
}
|
|
469
467
|
}
|
|
470
468
|
const base = process.argv.at(2);
|
package/dist/agent/storage.js
CHANGED
|
@@ -82,6 +82,24 @@ export function launchAt(base, id, owner) {
|
|
|
82
82
|
}
|
|
83
83
|
return launch;
|
|
84
84
|
}
|
|
85
|
+
/** Claims the single successor of a saved run; undefined when another caller already has. */
|
|
86
|
+
export function successor(base, launch, prompt) {
|
|
87
|
+
const id = randomUUID();
|
|
88
|
+
const next = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
|
|
89
|
+
fs.mkdirSync(runDirectory(base, id), { mode: 0o700 });
|
|
90
|
+
write(path.join(runDirectory(base, id), "launch.json"), next, true);
|
|
91
|
+
try {
|
|
92
|
+
write(path.join(runDirectory(base, launch.id), "successor.json"), { id }, true);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
fs.rmSync(runDirectory(base, id), { recursive: true });
|
|
96
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return next;
|
|
102
|
+
}
|
|
85
103
|
export function latest(base, initial) {
|
|
86
104
|
const seen = new Set();
|
|
87
105
|
let launch = initial;
|
package/dist/build-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.52.0","sha256":"a9faf29451051826907d9343b432c9232529ccaa68c75d681dbcb464702fc25f"});
|
|
@@ -8,6 +8,7 @@ import { brokerPidRecord } from "./pid.js";
|
|
|
8
8
|
import { assertBrokerCanStart } from "./spawn.js";
|
|
9
9
|
import { LastMessageRelay } from "./last.js";
|
|
10
10
|
import { canSeeSession, findSessions } from "./targets.js";
|
|
11
|
+
import { ParkedSessions, reachable } from "./parked.js";
|
|
11
12
|
import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.js";
|
|
12
13
|
import { guardedDeliveryError } from "./guard.js";
|
|
13
14
|
import { compactTopicMessage, normalizeMessage, normalizeSessionInfo, } from "../types.js";
|
|
@@ -18,6 +19,7 @@ const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
|
18
19
|
class IntercomBroker {
|
|
19
20
|
sessions = new Map();
|
|
20
21
|
lastMessages = new LastMessageRelay(this.sessions);
|
|
22
|
+
parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
|
|
21
23
|
pendingReplaceDeliveries = new Map();
|
|
22
24
|
server;
|
|
23
25
|
shutdownTimer = null;
|
|
@@ -56,7 +58,7 @@ class IntercomBroker {
|
|
|
56
58
|
}
|
|
57
59
|
this.shutdownTimer = setTimeout(() => {
|
|
58
60
|
this.shutdownTimer = null;
|
|
59
|
-
if (this.
|
|
61
|
+
if (this.parking.exited) {
|
|
60
62
|
console.log("No sessions connected, shutting down");
|
|
61
63
|
this.shutdown();
|
|
62
64
|
}
|
|
@@ -84,7 +86,7 @@ class IntercomBroker {
|
|
|
84
86
|
}
|
|
85
87
|
switch (msg.type) {
|
|
86
88
|
case "unregister":
|
|
87
|
-
this.removeSession(currentId);
|
|
89
|
+
this.removeSession(currentId, socket);
|
|
88
90
|
setId(null);
|
|
89
91
|
break;
|
|
90
92
|
case "list":
|
|
@@ -94,7 +96,8 @@ class IntercomBroker {
|
|
|
94
96
|
this.send(socket, msg, currentId);
|
|
95
97
|
break;
|
|
96
98
|
case "last_request":
|
|
97
|
-
|
|
99
|
+
// Reads never spawn a parked child.
|
|
100
|
+
this.lastMessages.request(currentId, msg, (id) => this.parking.reader(id));
|
|
98
101
|
break;
|
|
99
102
|
case "last_response":
|
|
100
103
|
this.lastMessages.respond(currentId, msg);
|
|
@@ -102,21 +105,28 @@ class IntercomBroker {
|
|
|
102
105
|
case "presence":
|
|
103
106
|
this.presence(msg, currentId);
|
|
104
107
|
break;
|
|
108
|
+
case "park":
|
|
109
|
+
case "unpark":
|
|
110
|
+
this.parking.park(socket, currentId, msg.type === "park");
|
|
111
|
+
break;
|
|
105
112
|
default:
|
|
106
113
|
throw new Error(`Unknown client message type: ${msg.type}`);
|
|
107
114
|
}
|
|
108
115
|
}
|
|
109
116
|
register(socket, msg, setId) {
|
|
110
|
-
const
|
|
111
|
-
const id =
|
|
117
|
+
const registered = registeredSessionInfo(msg, this.sessions);
|
|
118
|
+
const { id, resumed } = this.parking.claim(msg.requestedId, registered);
|
|
119
|
+
const info = { ...registered, id };
|
|
112
120
|
snapshotFrames(randomUUID(), [info], true);
|
|
113
121
|
const topicFrames = msg.topicFrames === true;
|
|
114
122
|
this.sessions.set(id, {
|
|
115
123
|
socket,
|
|
116
124
|
info: messageSender(info),
|
|
117
|
-
topics
|
|
125
|
+
// A woken generation registers no topics; the parked identity's owner keeps them.
|
|
126
|
+
topics: resumed?.topics ?? new TopicSnapshot(info),
|
|
118
127
|
topicFrames,
|
|
119
128
|
lastMessages: msg.lastMessages === true,
|
|
129
|
+
parking: msg.parking === true,
|
|
120
130
|
receiptReads: msg.receiptReads === 1,
|
|
121
131
|
});
|
|
122
132
|
setId(id);
|
|
@@ -128,20 +138,24 @@ class IntercomBroker {
|
|
|
128
138
|
type: "registered",
|
|
129
139
|
sessionId: id,
|
|
130
140
|
lastMessages: true,
|
|
141
|
+
parking: msg.parking === true,
|
|
131
142
|
receiptReads: 1,
|
|
132
143
|
...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
|
|
133
144
|
...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
|
|
134
145
|
});
|
|
146
|
+
this.parking.registered(id, resumed);
|
|
135
147
|
}
|
|
136
|
-
removeSession(sessionId) {
|
|
148
|
+
removeSession(sessionId, socket) {
|
|
137
149
|
const session = this.sessions.get(sessionId);
|
|
138
|
-
if (
|
|
150
|
+
if (session?.socket !== socket) {
|
|
139
151
|
return;
|
|
140
152
|
}
|
|
141
|
-
this.sessions.delete(sessionId);
|
|
142
153
|
this.lastMessages.disconnected(sessionId);
|
|
143
|
-
this.
|
|
144
|
-
|
|
154
|
+
if (!this.parking.closed(session)) {
|
|
155
|
+
this.sessions.delete(sessionId);
|
|
156
|
+
this.clearPendingReplaceDeliveries(sessionId);
|
|
157
|
+
this.broadcast({ type: "session_left", sessionId }, session.info);
|
|
158
|
+
}
|
|
145
159
|
this.scheduleShutdownCheck();
|
|
146
160
|
}
|
|
147
161
|
planPublications(fromId, from, publication) {
|
|
@@ -155,7 +169,7 @@ class IntercomBroker {
|
|
|
155
169
|
continue;
|
|
156
170
|
}
|
|
157
171
|
const message = publicationMessage(publication, subscription.awaitRelease === true);
|
|
158
|
-
const error = this.validateDeliveryPayload(from, message, target
|
|
172
|
+
const error = this.validateDeliveryPayload(from, message, target);
|
|
159
173
|
if (error !== null) {
|
|
160
174
|
throw new Error(error);
|
|
161
175
|
}
|
|
@@ -307,14 +321,16 @@ class IntercomBroker {
|
|
|
307
321
|
}
|
|
308
322
|
sendToSession(fromId, toId, from, message) {
|
|
309
323
|
const target = this.sessions.get(toId);
|
|
310
|
-
if (target && shouldReplaceDelivery(message, target.info)) {
|
|
324
|
+
if (target && target.parked === undefined && shouldReplaceDelivery(message, target.info)) {
|
|
311
325
|
return this.queueReplaceDelivery(fromId, toId, from, message);
|
|
312
326
|
}
|
|
313
327
|
const reason = this.deliverMessage(toId, from, message);
|
|
328
|
+
const queued = reason === null && target?.parked !== undefined;
|
|
314
329
|
return {
|
|
315
330
|
id: message.id,
|
|
316
331
|
accepted: reason === null,
|
|
317
|
-
delivered: reason === null,
|
|
332
|
+
delivered: reason === null && !queued,
|
|
333
|
+
...(queued ? { queued } : {}),
|
|
318
334
|
...(reason !== null ? { reason } : {}),
|
|
319
335
|
};
|
|
320
336
|
}
|
|
@@ -335,7 +351,7 @@ class IntercomBroker {
|
|
|
335
351
|
return `${fromId}\0${toId}\0${threadId}`;
|
|
336
352
|
}
|
|
337
353
|
queueReplaceDelivery(fromId, toId, from, message) {
|
|
338
|
-
const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId)
|
|
354
|
+
const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
|
|
339
355
|
if (validationError !== null) {
|
|
340
356
|
return { id: message.id, accepted: false, delivered: false, reason: validationError };
|
|
341
357
|
}
|
|
@@ -367,28 +383,25 @@ class IntercomBroker {
|
|
|
367
383
|
}
|
|
368
384
|
}
|
|
369
385
|
}
|
|
370
|
-
|
|
386
|
+
/** One admission rule; retained input is checked as the uncompacted envelope any later
|
|
387
|
+
* transport generation accepts. */
|
|
388
|
+
validateDeliveryPayload(from, message, target) {
|
|
389
|
+
const compact = target?.parked === undefined && target?.topicFrames === true;
|
|
371
390
|
return (validateIntercomMessageSize({
|
|
372
391
|
type: "message",
|
|
373
392
|
from: messageSender(from),
|
|
374
|
-
message:
|
|
393
|
+
message: compact ? compactTopicMessage(message) : message,
|
|
375
394
|
})?.message ?? null);
|
|
376
395
|
}
|
|
377
396
|
deliverMessage(toId, from, message) {
|
|
378
|
-
const guardError = guardedDeliveryError(this.sessions, toId, message);
|
|
379
|
-
if (guardError !== null) {
|
|
380
|
-
return guardError;
|
|
381
|
-
}
|
|
382
397
|
const target = this.sessions.get(toId);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
return "Recipient disconnected before delivery";
|
|
398
|
+
const refused = guardedDeliveryError(this.sessions, toId, message) ??
|
|
399
|
+
this.validateDeliveryPayload(from, message, target);
|
|
400
|
+
if (refused !== null || this.parking.retain(target, from, message)) {
|
|
401
|
+
return refused;
|
|
388
402
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return validationError;
|
|
403
|
+
if (!reachable(target)) {
|
|
404
|
+
return "Recipient disconnected before delivery";
|
|
392
405
|
}
|
|
393
406
|
this.touchActivity(toId, true);
|
|
394
407
|
try {
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import type net from "node:net";
|
|
2
2
|
import { type UnknownRecord } from "../../shared/unknown.ts";
|
|
3
|
-
import
|
|
3
|
+
import { type NativeReadQuery } from "./last.ts";
|
|
4
|
+
import { type LastMessageQuery, type LastMessageResult, type ReceiptQuery, type ReceiptResult, type SessionInfo } from "../types.ts";
|
|
5
|
+
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
6
|
+
export declare function lastRequest(msg: UnknownRecord): {
|
|
7
|
+
readonly requestId: string;
|
|
8
|
+
readonly from: SessionInfo;
|
|
9
|
+
readonly query: NativeReadQuery;
|
|
10
|
+
readonly parked?: SessionInfo;
|
|
11
|
+
};
|
|
4
12
|
export declare class LastMessageRequests {
|
|
13
|
+
/** Negotiated at registration; a failed connection supports neither. */
|
|
14
|
+
lastMessages: boolean;
|
|
15
|
+
receiptReads: boolean;
|
|
5
16
|
private readonly timeoutMs;
|
|
6
17
|
constructor(timeoutMs: number);
|
|
7
18
|
private readonly pending;
|
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { errorMessage } from "../../shared/unknown.js";
|
|
3
3
|
import { isNativeReadQuery, isNativeReadResult, isLastMessageResult, isReceiptResult, } from "./last.js";
|
|
4
|
+
import { normalizeSessionInfo, } from "../types.js";
|
|
4
5
|
import { writeMessage } from "./framing.js";
|
|
6
|
+
/** A peer's read of this session or, for a main, of its exited `parked` child. */
|
|
7
|
+
export function lastRequest(msg) {
|
|
8
|
+
const from = normalizeSessionInfo(msg.from);
|
|
9
|
+
if (typeof msg.requestId !== "string" || !from || !isNativeReadQuery(msg.query)) {
|
|
10
|
+
throw new Error("Invalid last-message request");
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
requestId: msg.requestId,
|
|
14
|
+
from,
|
|
15
|
+
query: msg.query,
|
|
16
|
+
parked: normalizeSessionInfo(msg.parked) ?? undefined,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
5
19
|
export class LastMessageRequests {
|
|
20
|
+
/** Negotiated at registration; a failed connection supports neither. */
|
|
21
|
+
lastMessages = false;
|
|
22
|
+
receiptReads = false;
|
|
6
23
|
timeoutMs;
|
|
7
24
|
constructor(timeoutMs) {
|
|
8
25
|
this.timeoutMs = timeoutMs;
|
|
9
26
|
}
|
|
10
27
|
pending = new Map();
|
|
11
28
|
fail(error) {
|
|
29
|
+
this.lastMessages = false;
|
|
30
|
+
this.receiptReads = false;
|
|
12
31
|
for (const pending of this.pending.values()) {
|
|
13
32
|
clearTimeout(pending.timer);
|
|
14
33
|
pending.reject(error);
|
|
@@ -36,9 +55,15 @@ export class LastMessageRequests {
|
|
|
36
55
|
return isReceiptResult(result) ? result : { error: "Invalid receipt response" };
|
|
37
56
|
}
|
|
38
57
|
request(socket, to, query) {
|
|
58
|
+
if (!this.lastMessages) {
|
|
59
|
+
return Promise.reject(new Error("This broker does not support last-message reads; reload after it exits."));
|
|
60
|
+
}
|
|
39
61
|
if (!isNativeReadQuery(query)) {
|
|
40
62
|
return Promise.reject(new Error("Invalid last-message query"));
|
|
41
63
|
}
|
|
64
|
+
if ("messageId" in query && !this.receiptReads) {
|
|
65
|
+
return Promise.reject(new Error("publication_unavailable"));
|
|
66
|
+
}
|
|
42
67
|
return new Promise((resolve, reject) => {
|
|
43
68
|
const requestId = randomUUID();
|
|
44
69
|
const timer = setTimeout(() => {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type net from "node:net";
|
|
2
|
+
/** Parking negotiation and its single outstanding broker acknowledgement. */
|
|
3
|
+
export declare class ParkRequests {
|
|
4
|
+
supported: boolean;
|
|
5
|
+
private acknowledged?;
|
|
6
|
+
settle(parked: boolean): void;
|
|
7
|
+
/** True once the broker retains later input; false when it cannot or the socket closed. */
|
|
8
|
+
request(socket: () => net.Socket, on: boolean): Promise<boolean>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { writeMessage } from "./framing.js";
|
|
2
|
+
/** Parking negotiation and its single outstanding broker acknowledgement. */
|
|
3
|
+
export class ParkRequests {
|
|
4
|
+
supported = false;
|
|
5
|
+
acknowledged;
|
|
6
|
+
settle(parked) {
|
|
7
|
+
this.acknowledged?.(parked);
|
|
8
|
+
}
|
|
9
|
+
/** True once the broker retains later input; false when it cannot or the socket closed. */
|
|
10
|
+
request(socket, on) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
this.acknowledged = on && this.supported ? resolve : undefined;
|
|
13
|
+
if (this.supported) {
|
|
14
|
+
writeMessage(socket(), { type: on ? "park" : "unpark" });
|
|
15
|
+
}
|
|
16
|
+
if (this.acknowledged === undefined) {
|
|
17
|
+
resolve(false);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -11,8 +11,7 @@ export declare class IntercomClient extends EventEmitter {
|
|
|
11
11
|
private connection;
|
|
12
12
|
private _sessionId;
|
|
13
13
|
private _topicsSupported;
|
|
14
|
-
private
|
|
15
|
-
private receiptReadsSupported;
|
|
14
|
+
private readonly parking;
|
|
16
15
|
private guardedDeliverySupported;
|
|
17
16
|
private readonly lastRequests;
|
|
18
17
|
private readonly pendingSends;
|
|
@@ -36,11 +35,11 @@ export declare class IntercomClient extends EventEmitter {
|
|
|
36
35
|
private receiveMessage;
|
|
37
36
|
private receiveDelivery;
|
|
38
37
|
private receiveSessions;
|
|
38
|
+
park(on?: boolean): Promise<boolean>;
|
|
39
39
|
disconnect(): Promise<void>;
|
|
40
40
|
listSessions(): Promise<SessionInfo[]>;
|
|
41
41
|
last(to: string, query?: LastMessageQuery): Promise<LastMessageResult>;
|
|
42
42
|
receipt(to: string, query: ReceiptQuery): Promise<ReceiptResult>;
|
|
43
|
-
private readSocket;
|
|
44
43
|
respondLast(requestId: string, result: unknown): void;
|
|
45
44
|
updateTopics(change: TopicChange, onAccepted?: (snapshot: SessionSnapshot) => void): Promise<SessionSnapshot>;
|
|
46
45
|
private requestSessions;
|