@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/src/agent/process.ts
CHANGED
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
} from "../shared/post-exit-stdio-guard.ts";
|
|
14
14
|
import { agentObservationSocketPath } from "../pi-intercom/broker/paths.ts";
|
|
15
15
|
import { hasErrorCode } from "../shared/unknown.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
attachmentSchema,
|
|
18
|
+
parkedSchema,
|
|
19
|
+
processSchema,
|
|
20
|
+
type Launch,
|
|
21
|
+
type ProcessIdentity,
|
|
22
|
+
} from "./contracts.ts";
|
|
17
23
|
import { conversation, enqueue, launchAt, list, read, write } from "./storage.ts";
|
|
18
24
|
import { errorText } from "./host.ts";
|
|
19
25
|
|
|
@@ -183,9 +189,12 @@ async function awaitStopping(
|
|
|
183
189
|
existing: OwnedProcess | undefined,
|
|
184
190
|
signal?: AbortSignal,
|
|
185
191
|
): Promise<void> {
|
|
186
|
-
if (existing
|
|
187
|
-
const
|
|
188
|
-
|
|
192
|
+
if (existing) {
|
|
193
|
+
const stopping = path.join(dir, `stopping-${existing.token}.json`);
|
|
194
|
+
// Parking ends in exit or a removed marker, after native shutdown persistence.
|
|
195
|
+
const parking = path.join(dir, "actor.lock", "parking.json");
|
|
196
|
+
const deadline = Date.now() + (fs.existsSync(stopping) ? 5000 : 30_000);
|
|
197
|
+
while ((fs.existsSync(stopping) || fs.existsSync(parking)) && alive(existing)) {
|
|
189
198
|
signal?.throwIfAborted();
|
|
190
199
|
if (Date.now() > deadline) {
|
|
191
200
|
throw new Error("Previous actor is still stopping; no replacement was launched.");
|
|
@@ -210,6 +219,7 @@ export async function ensureActor(
|
|
|
210
219
|
if (existing && alive(existing)) {
|
|
211
220
|
return;
|
|
212
221
|
}
|
|
222
|
+
parkFailure(base, launch, existing);
|
|
213
223
|
if (existing) {
|
|
214
224
|
if (shells(base, launch, existing.token).some(alive)) {
|
|
215
225
|
throw new Error("Owned native shell work remains live; stop it before continuing.");
|
|
@@ -231,6 +241,33 @@ export async function ensureActor(
|
|
|
231
241
|
write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
|
|
232
242
|
spawnActor(base, launch, token);
|
|
233
243
|
}
|
|
244
|
+
/** A failed park keeps its input queued; exact state is lost, so nothing restarts it implicitly. */
|
|
245
|
+
export function parkFailure(base: string, launch: Launch, exited?: OwnedProcess): void {
|
|
246
|
+
const dir = conversation(base, launch);
|
|
247
|
+
const parked = read(path.join(dir, "parked.json"), parkedSchema);
|
|
248
|
+
if (exited && parked && parked.token !== exited.token) {
|
|
249
|
+
// Only the exited claim that saved this state may hand it to the next generation.
|
|
250
|
+
throw new Error(
|
|
251
|
+
"Parked working state belongs to another actor generation; nothing was reopened.",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const file = path.join(dir, "park-failed.json");
|
|
255
|
+
if (fs.existsSync(file)) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`This idle child exited without saving its exact working state (${file}). Nothing was restarted or admitted; messages for it stay queued. Inspect it, then delegate new work.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** A broker wake may only restore exact parked state, never reopen the journal alone. */
|
|
262
|
+
export async function wakeActor(base: string, launch: Launch, signal?: AbortSignal): Promise<void> {
|
|
263
|
+
const dir = conversation(base, launch);
|
|
264
|
+
await awaitStopping(dir, actor(base, launch), signal);
|
|
265
|
+
parkFailure(base, launch);
|
|
266
|
+
if (!fs.existsSync(path.join(dir, "parked.json"))) {
|
|
267
|
+
throw new Error("No parked working state; the child was not woken.");
|
|
268
|
+
}
|
|
269
|
+
await ensureActor(base, launch, signal);
|
|
270
|
+
}
|
|
234
271
|
/** A crashed generation cannot unlink its endpoint; its exact token names the leftover. */
|
|
235
272
|
function removeObservationEndpoint(token: string): void {
|
|
236
273
|
try {
|
package/src/agent/runner.ts
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type {
|
|
4
|
-
AgentSession,
|
|
5
|
-
CreateAgentSessionRuntimeFactory,
|
|
6
|
-
ExtensionAPI,
|
|
7
|
-
ExtensionContext,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
4
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
10
5
|
import { commandSchema, type Command, type Launch, type Result } from "./contracts.ts";
|
|
11
6
|
import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
|
|
12
|
-
import { ownIdentity, ParentCheck,
|
|
7
|
+
import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
|
|
13
8
|
import { errorText, loadSDK } from "./host.ts";
|
|
9
|
+
import { Parking, type Runtime } from "./parking.ts";
|
|
14
10
|
import { childUsage } from "./usage.ts";
|
|
15
|
-
import { initializeCwd
|
|
11
|
+
import { initializeCwd } from "./cwd.ts";
|
|
12
|
+
import { childRuntime } from "./child-runtime.ts";
|
|
16
13
|
import { ActorObservation, type ObservationStats } from "./observation.ts";
|
|
17
|
-
import type { ToolEvent } from "./observation-state.ts";
|
|
18
14
|
import { DirectoryWatch, Wake } from "./wake.ts";
|
|
19
15
|
|
|
20
16
|
function commandIdentity(file: string): string | undefined {
|
|
@@ -41,30 +37,12 @@ function completionStatus(
|
|
|
41
37
|
}
|
|
42
38
|
return "completed";
|
|
43
39
|
}
|
|
44
|
-
function guard(pi: ExtensionAPI, directory: string, token: string): void {
|
|
45
|
-
pi.on("tool_call", (event) => {
|
|
46
|
-
if (event.toolName === "delegate" || event.toolName === "agent_runs") {
|
|
47
|
-
return { block: true, reason: "Only a main session may delegate or control children." };
|
|
48
|
-
}
|
|
49
|
-
const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
|
|
50
|
-
if (
|
|
51
|
-
event.toolName === "bash" &&
|
|
52
|
-
typeof event.input.command === "string" &&
|
|
53
|
-
owner &&
|
|
54
|
-
(owner.source === "builtin" || localCwdOwner(owner))
|
|
55
|
-
) {
|
|
56
|
-
// Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
|
|
57
|
-
const input = event.input;
|
|
58
|
-
input.command = shellCommand(event.input.command, directory, token);
|
|
59
|
-
}
|
|
60
|
-
return;
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
40
|
class Actor {
|
|
64
41
|
private active?: Promise<void>;
|
|
65
42
|
private stopping = false;
|
|
66
43
|
private stopDeadline?: NodeJS.Timeout;
|
|
67
44
|
private nextParentCheck = 0;
|
|
45
|
+
private parking = false;
|
|
68
46
|
private readonly parent = new ParentCheck();
|
|
69
47
|
// Raised when durable commands may need admission: change hints and stop.
|
|
70
48
|
private readonly wake = new Wake();
|
|
@@ -151,67 +129,6 @@ class Actor {
|
|
|
151
129
|
.join("\n") ?? ""
|
|
152
130
|
);
|
|
153
131
|
}
|
|
154
|
-
private createRuntime(
|
|
155
|
-
sdk: Awaited<ReturnType<typeof loadSDK>>,
|
|
156
|
-
): CreateAgentSessionRuntimeFactory {
|
|
157
|
-
return async ({ sessionManager }) => {
|
|
158
|
-
const launch = this.initial;
|
|
159
|
-
if (!fs.existsSync(launch.sessionFile)) {
|
|
160
|
-
throw new Error("Missing saved native session; refusing to create another.");
|
|
161
|
-
}
|
|
162
|
-
const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
|
|
163
|
-
projectTrusted: launch.trusted,
|
|
164
|
-
});
|
|
165
|
-
const services = await sdk.createAgentSessionServices({
|
|
166
|
-
cwd: launch.cwd,
|
|
167
|
-
agentDir: launch.agentDir,
|
|
168
|
-
settingsManager,
|
|
169
|
-
resourceLoaderOptions: {
|
|
170
|
-
additionalExtensionPaths: [launch.entry],
|
|
171
|
-
extensionFactories: [
|
|
172
|
-
{
|
|
173
|
-
name: "codemode",
|
|
174
|
-
builtin: true,
|
|
175
|
-
factory: sdk.createCodemodeExtension({ mode: "on" }),
|
|
176
|
-
},
|
|
177
|
-
{ name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
|
|
178
|
-
{ name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
|
|
179
|
-
(pi) => {
|
|
180
|
-
guard(pi, this.dir, this.token);
|
|
181
|
-
pi.on("session_start", (_event, ctx) => {
|
|
182
|
-
this.context = { pi, ctx };
|
|
183
|
-
});
|
|
184
|
-
// Sparse tool lifecycle keeps the live view's tools even while nobody watches.
|
|
185
|
-
const tool = (event: ToolEvent): void => this.observation?.tool(event);
|
|
186
|
-
pi.on("tool_execution_start", tool);
|
|
187
|
-
pi.on("tool_execution_update", tool);
|
|
188
|
-
pi.on("tool_execution_end", tool);
|
|
189
|
-
},
|
|
190
|
-
],
|
|
191
|
-
},
|
|
192
|
-
});
|
|
193
|
-
const diagnostics = services.resourceLoader.getExtensions().errors;
|
|
194
|
-
if (diagnostics.length > 0) {
|
|
195
|
-
throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
|
|
196
|
-
}
|
|
197
|
-
const model = services.modelRuntime.getModel(launch.provider, launch.model);
|
|
198
|
-
if (!model) {
|
|
199
|
-
throw new Error(
|
|
200
|
-
`Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
return {
|
|
204
|
-
...(await sdk.createAgentSessionFromServices({
|
|
205
|
-
services,
|
|
206
|
-
sessionManager,
|
|
207
|
-
model,
|
|
208
|
-
thinkingLevel: launch.thinking,
|
|
209
|
-
})),
|
|
210
|
-
services,
|
|
211
|
-
diagnostics: services.diagnostics,
|
|
212
|
-
};
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
132
|
private readonly stop = (): void => {
|
|
216
133
|
if (this.stopping) {
|
|
217
134
|
return;
|
|
@@ -399,7 +316,7 @@ class Actor {
|
|
|
399
316
|
return this.stopping && this.active === undefined && (this.session?.isIdle ?? true);
|
|
400
317
|
}
|
|
401
318
|
/** Durable command readiness, not elapsed time, defines admission. */
|
|
402
|
-
private async admitCommands(): Promise<void> {
|
|
319
|
+
private async admitCommands(runtime: Runtime, parked: Parking): Promise<void> {
|
|
403
320
|
// Queued commands create this directory; otherwise pending() does and the next pass arms.
|
|
404
321
|
const directory = path.join(this.dir, "commands");
|
|
405
322
|
const commands = new DirectoryWatch(directory, () => true, this.wake.raise);
|
|
@@ -411,6 +328,10 @@ class Actor {
|
|
|
411
328
|
this.retry = false;
|
|
412
329
|
this.pending().forEach((command) => this.accept(command));
|
|
413
330
|
}
|
|
331
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
332
|
+
if (await this.park(runtime, parked)) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
414
335
|
const polling = this.retry || this.stopping || watch === "unwatched";
|
|
415
336
|
const tick = Math.max(1, this.nextParentCheck - performance.now());
|
|
416
337
|
// oxlint-disable-next-line no-await-in-loop
|
|
@@ -422,6 +343,35 @@ class Actor {
|
|
|
422
343
|
commands.close();
|
|
423
344
|
}
|
|
424
345
|
}
|
|
346
|
+
/** Parks a final-idle actor; the parent/stop owner stays live while preparation awaits. */
|
|
347
|
+
private async park(runtime: Runtime, parked: Parking): Promise<boolean> {
|
|
348
|
+
const owner = new AbortController();
|
|
349
|
+
const watch = setInterval(() => {
|
|
350
|
+
if (this.shouldExit() || this.stopping) {
|
|
351
|
+
owner.abort();
|
|
352
|
+
}
|
|
353
|
+
}, 100);
|
|
354
|
+
try {
|
|
355
|
+
this.parking = await parked.park(runtime, {
|
|
356
|
+
signal: owner.signal,
|
|
357
|
+
pi: this.context?.pi,
|
|
358
|
+
busy: this.stopping || this.active !== undefined || this.observation?.watched === true,
|
|
359
|
+
// Raced commands and viewers keep this actor resident; the loop admits after release.
|
|
360
|
+
quiet: () => {
|
|
361
|
+
const queued = this.pending().some((command) => this.commandLaunch(command));
|
|
362
|
+
if (queued) {
|
|
363
|
+
this.wake.raise();
|
|
364
|
+
}
|
|
365
|
+
const idle = this.active === undefined && !this.stopping;
|
|
366
|
+
return !queued && idle && this.observation?.watched !== true;
|
|
367
|
+
},
|
|
368
|
+
errors: this.errors,
|
|
369
|
+
});
|
|
370
|
+
} finally {
|
|
371
|
+
clearInterval(watch);
|
|
372
|
+
}
|
|
373
|
+
return this.parking;
|
|
374
|
+
}
|
|
425
375
|
private failure(error: unknown): void {
|
|
426
376
|
const launch = this.current ?? this.initial;
|
|
427
377
|
const result: Result = {
|
|
@@ -438,71 +388,101 @@ class Actor {
|
|
|
438
388
|
}
|
|
439
389
|
process.exitCode = 1;
|
|
440
390
|
}
|
|
391
|
+
private async start(sdk: Awaited<ReturnType<typeof loadSDK>>, parked: Parking): Promise<Runtime> {
|
|
392
|
+
const sessionManager = parked.open();
|
|
393
|
+
const factory = childRuntime(sdk, this.initial, {
|
|
394
|
+
directory: this.dir,
|
|
395
|
+
token: this.token,
|
|
396
|
+
saved: parked.saved,
|
|
397
|
+
started: (pi, ctx) => {
|
|
398
|
+
this.context = { pi, ctx };
|
|
399
|
+
},
|
|
400
|
+
tool: (event) => this.observation?.tool(event),
|
|
401
|
+
});
|
|
402
|
+
const runtime = await sdk.createAgentSessionRuntime(factory, {
|
|
403
|
+
cwd: this.initial.cwd,
|
|
404
|
+
agentDir: this.initial.agentDir,
|
|
405
|
+
sessionManager,
|
|
406
|
+
});
|
|
407
|
+
try {
|
|
408
|
+
await this.ready(runtime, parked);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
// A runtime that never became ready is still disposed, so the failed actor exits.
|
|
411
|
+
await runtime.dispose();
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
return runtime;
|
|
415
|
+
}
|
|
416
|
+
private async ready(runtime: Runtime, parked: Parking): Promise<void> {
|
|
417
|
+
this.session = runtime.session;
|
|
418
|
+
await this.session.bindExtensions({
|
|
419
|
+
mode: "print",
|
|
420
|
+
onError: (error) => {
|
|
421
|
+
this.errors.push(error.error);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
if (!this.context) {
|
|
425
|
+
throw new Error("Child extension context is unavailable.");
|
|
426
|
+
}
|
|
427
|
+
initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
|
|
428
|
+
if (this.initial.name !== undefined && !parked.saved) {
|
|
429
|
+
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
430
|
+
}
|
|
431
|
+
// Optional live view, bound before readiness; its faults never change native execution.
|
|
432
|
+
this.observation = await ActorObservation.open(this.session, {
|
|
433
|
+
token: this.token,
|
|
434
|
+
rootId: this.initial.rootId,
|
|
435
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
436
|
+
});
|
|
437
|
+
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
438
|
+
...ownIdentity(),
|
|
439
|
+
token: this.token,
|
|
440
|
+
});
|
|
441
|
+
write(path.join(this.dir, "ready.json"), {
|
|
442
|
+
pid: process.pid,
|
|
443
|
+
token: this.token,
|
|
444
|
+
sessionId: this.session.sessionManager.getSessionId(),
|
|
445
|
+
at: Date.now(),
|
|
446
|
+
});
|
|
447
|
+
parked.adopt();
|
|
448
|
+
}
|
|
441
449
|
async serve(): Promise<void> {
|
|
442
450
|
const sdk = await loadSDK(this.initial.sdk);
|
|
443
|
-
|
|
451
|
+
const parked = new Parking(this.base, this.initial, this.token, sdk);
|
|
452
|
+
let runtime: Runtime | undefined;
|
|
444
453
|
try {
|
|
445
|
-
runtime = await
|
|
446
|
-
cwd: this.initial.cwd,
|
|
447
|
-
agentDir: this.initial.agentDir,
|
|
448
|
-
sessionManager: sdk.SessionManager.open(
|
|
449
|
-
this.initial.sessionFile,
|
|
450
|
-
undefined,
|
|
451
|
-
this.initial.cwd,
|
|
452
|
-
),
|
|
453
|
-
});
|
|
454
|
-
this.session = runtime.session;
|
|
455
|
-
await this.session.bindExtensions({
|
|
456
|
-
mode: "print",
|
|
457
|
-
onError: (error) => {
|
|
458
|
-
this.errors.push(error.error);
|
|
459
|
-
},
|
|
460
|
-
});
|
|
461
|
-
if (!this.context) {
|
|
462
|
-
throw new Error("Child extension context is unavailable.");
|
|
463
|
-
}
|
|
464
|
-
initializeCwd(this.context.pi, this.context.ctx, this.initial.cwd, this.dir);
|
|
465
|
-
if (this.initial.name !== undefined) {
|
|
466
|
-
this.session.sessionManager.appendSessionInfo(this.initial.name);
|
|
467
|
-
}
|
|
468
|
-
// Optional live view, bound before readiness; its faults never change native execution.
|
|
469
|
-
this.observation = await ActorObservation.open(this.session, {
|
|
470
|
-
token: this.token,
|
|
471
|
-
rootId: this.initial.rootId,
|
|
472
|
-
sessionId: this.session.sessionManager.getSessionId(),
|
|
473
|
-
});
|
|
474
|
-
write(path.join(this.dir, "actor.lock", "process.json"), {
|
|
475
|
-
...ownIdentity(),
|
|
476
|
-
token: this.token,
|
|
477
|
-
});
|
|
478
|
-
write(path.join(this.dir, "ready.json"), {
|
|
479
|
-
pid: process.pid,
|
|
480
|
-
token: this.token,
|
|
481
|
-
sessionId: this.session.sessionManager.getSessionId(),
|
|
482
|
-
at: Date.now(),
|
|
483
|
-
});
|
|
454
|
+
runtime = await this.start(sdk, parked);
|
|
484
455
|
process.on("SIGTERM", this.stop);
|
|
485
456
|
process.on("SIGINT", this.stop);
|
|
486
|
-
await this.admitCommands();
|
|
457
|
+
await this.admitCommands(runtime, parked);
|
|
487
458
|
await this.active;
|
|
488
459
|
} catch (error) {
|
|
489
460
|
this.failure(error);
|
|
490
461
|
} finally {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
462
|
+
await this.finish(runtime);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
private async finish(runtime: Runtime | undefined): Promise<void> {
|
|
466
|
+
process.off("SIGTERM", this.stop);
|
|
467
|
+
process.off("SIGINT", this.stop);
|
|
468
|
+
const observation: ObservationStats | undefined = this.observation?.close();
|
|
469
|
+
if (!this.parking) {
|
|
494
470
|
await runtime?.dispose();
|
|
495
|
-
if (this.stopping) {
|
|
496
|
-
this.stopShells("SIGKILL");
|
|
497
|
-
}
|
|
498
|
-
clearTimeout(this.stopDeadline);
|
|
499
|
-
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
500
|
-
at: Date.now(),
|
|
501
|
-
errors: this.errors,
|
|
502
|
-
...(observation === undefined ? {} : { observation }),
|
|
503
|
-
});
|
|
504
|
-
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
505
471
|
}
|
|
472
|
+
if (this.stopping) {
|
|
473
|
+
this.stopShells("SIGKILL");
|
|
474
|
+
}
|
|
475
|
+
clearTimeout(this.stopDeadline);
|
|
476
|
+
write(path.join(this.dir, `shutdown-${this.token}.json`), {
|
|
477
|
+
at: Date.now(),
|
|
478
|
+
errors: this.errors,
|
|
479
|
+
...(observation === undefined ? {} : { observation }),
|
|
480
|
+
});
|
|
481
|
+
if (this.parking) {
|
|
482
|
+
// Parked authority requires actual exit; no leftover extension handle may keep it alive.
|
|
483
|
+
process.exit();
|
|
484
|
+
}
|
|
485
|
+
// Keep the process claim until a launcher proves this exact actor is gone.
|
|
506
486
|
}
|
|
507
487
|
}
|
|
508
488
|
const base = process.argv.at(2);
|
package/src/agent/storage.ts
CHANGED
|
@@ -84,6 +84,23 @@ export function launchAt(base: string, id: string, owner: string): Launch {
|
|
|
84
84
|
}
|
|
85
85
|
return launch;
|
|
86
86
|
}
|
|
87
|
+
/** Claims the single successor of a saved run; undefined when another caller already has. */
|
|
88
|
+
export function successor(base: string, launch: Launch, prompt: string): Launch | undefined {
|
|
89
|
+
const id = randomUUID();
|
|
90
|
+
const next: Launch = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
|
|
91
|
+
fs.mkdirSync(runDirectory(base, id), { mode: 0o700 });
|
|
92
|
+
write(path.join(runDirectory(base, id), "launch.json"), next, true);
|
|
93
|
+
try {
|
|
94
|
+
write(path.join(runDirectory(base, launch.id), "successor.json"), { id }, true);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
fs.rmSync(runDirectory(base, id), { recursive: true });
|
|
97
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return next;
|
|
103
|
+
}
|
|
87
104
|
export function latest(base: string, initial: Launch): Launch {
|
|
88
105
|
const seen = new Set<string>();
|
|
89
106
|
let launch = initial;
|
|
@@ -8,6 +8,7 @@ import { brokerPidRecord } from "./pid.ts";
|
|
|
8
8
|
import { assertBrokerCanStart } from "./spawn.ts";
|
|
9
9
|
import { LastMessageRelay } from "./last.ts";
|
|
10
10
|
import { canSeeSession, findSessions } from "./targets.ts";
|
|
11
|
+
import { ParkedSessions, reachable, type ParkablePeer } from "./parked.ts";
|
|
11
12
|
import { acceptBrokerConnection, registeredSessionInfo } from "./server-connection.ts";
|
|
12
13
|
import { guardedDeliveryError } from "./guard.ts";
|
|
13
14
|
import {
|
|
@@ -35,8 +36,7 @@ import {
|
|
|
35
36
|
const PID_PATH = join(getBrokerRuntimeDir(), "broker.pid");
|
|
36
37
|
const REPLACE_DELIVERY_DELAY_MS = 1500;
|
|
37
38
|
|
|
38
|
-
interface ConnectedSession {
|
|
39
|
-
readonly socket: net.Socket;
|
|
39
|
+
interface ConnectedSession extends ParkablePeer {
|
|
40
40
|
info: SessionInfo;
|
|
41
41
|
topics: TopicSnapshot;
|
|
42
42
|
readonly topicFrames: boolean;
|
|
@@ -54,6 +54,7 @@ interface PendingReplaceDelivery {
|
|
|
54
54
|
class IntercomBroker {
|
|
55
55
|
private readonly sessions = new Map<string, ConnectedSession>();
|
|
56
56
|
private readonly lastMessages = new LastMessageRelay(this.sessions);
|
|
57
|
+
private readonly parking = new ParkedSessions(this.sessions, this.deliverMessage.bind(this));
|
|
57
58
|
private readonly pendingReplaceDeliveries = new Map<string, PendingReplaceDelivery>();
|
|
58
59
|
private readonly server: net.Server;
|
|
59
60
|
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
@@ -96,7 +97,7 @@ class IntercomBroker {
|
|
|
96
97
|
|
|
97
98
|
this.shutdownTimer = setTimeout(() => {
|
|
98
99
|
this.shutdownTimer = null;
|
|
99
|
-
if (this.
|
|
100
|
+
if (this.parking.exited) {
|
|
100
101
|
console.log("No sessions connected, shutting down");
|
|
101
102
|
this.shutdown();
|
|
102
103
|
}
|
|
@@ -130,7 +131,7 @@ class IntercomBroker {
|
|
|
130
131
|
}
|
|
131
132
|
switch (msg.type) {
|
|
132
133
|
case "unregister":
|
|
133
|
-
this.removeSession(currentId);
|
|
134
|
+
this.removeSession(currentId, socket);
|
|
134
135
|
setId(null);
|
|
135
136
|
break;
|
|
136
137
|
case "list":
|
|
@@ -140,7 +141,8 @@ class IntercomBroker {
|
|
|
140
141
|
this.send(socket, msg, currentId);
|
|
141
142
|
break;
|
|
142
143
|
case "last_request":
|
|
143
|
-
|
|
144
|
+
// Reads never spawn a parked child.
|
|
145
|
+
this.lastMessages.request(currentId, msg, (id) => this.parking.reader(id));
|
|
144
146
|
break;
|
|
145
147
|
case "last_response":
|
|
146
148
|
this.lastMessages.respond(currentId, msg);
|
|
@@ -148,22 +150,29 @@ class IntercomBroker {
|
|
|
148
150
|
case "presence":
|
|
149
151
|
this.presence(msg, currentId);
|
|
150
152
|
break;
|
|
153
|
+
case "park":
|
|
154
|
+
case "unpark":
|
|
155
|
+
this.parking.park(socket, currentId, msg.type === "park");
|
|
156
|
+
break;
|
|
151
157
|
default:
|
|
152
158
|
throw new Error(`Unknown client message type: ${msg.type}`);
|
|
153
159
|
}
|
|
154
160
|
}
|
|
155
161
|
|
|
156
162
|
private register(socket: net.Socket, msg: UnknownRecord, setId: (id: string) => void): void {
|
|
157
|
-
const
|
|
158
|
-
const id =
|
|
163
|
+
const registered = registeredSessionInfo(msg, this.sessions);
|
|
164
|
+
const { id, resumed } = this.parking.claim(msg.requestedId, registered);
|
|
165
|
+
const info: SessionInfo = { ...registered, id };
|
|
159
166
|
snapshotFrames(randomUUID(), [info], true);
|
|
160
167
|
const topicFrames = msg.topicFrames === true;
|
|
161
168
|
this.sessions.set(id, {
|
|
162
169
|
socket,
|
|
163
170
|
info: messageSender(info),
|
|
164
|
-
topics
|
|
171
|
+
// A woken generation registers no topics; the parked identity's owner keeps them.
|
|
172
|
+
topics: resumed?.topics ?? new TopicSnapshot(info),
|
|
165
173
|
topicFrames,
|
|
166
174
|
lastMessages: msg.lastMessages === true,
|
|
175
|
+
parking: msg.parking === true,
|
|
167
176
|
receiptReads: msg.receiptReads === 1,
|
|
168
177
|
});
|
|
169
178
|
setId(id);
|
|
@@ -175,21 +184,25 @@ class IntercomBroker {
|
|
|
175
184
|
type: "registered",
|
|
176
185
|
sessionId: id,
|
|
177
186
|
lastMessages: true,
|
|
187
|
+
parking: msg.parking === true,
|
|
178
188
|
receiptReads: 1,
|
|
179
189
|
...(msg.guardedDelivery === 1 ? { guardedDelivery: 1 } : {}),
|
|
180
190
|
...(topicFrames ? { topicsSupported: true, topicFrames: true } : {}),
|
|
181
191
|
});
|
|
192
|
+
this.parking.registered(id, resumed);
|
|
182
193
|
}
|
|
183
194
|
|
|
184
|
-
private removeSession(sessionId: string): void {
|
|
195
|
+
private removeSession(sessionId: string, socket: net.Socket): void {
|
|
185
196
|
const session = this.sessions.get(sessionId);
|
|
186
|
-
if (
|
|
197
|
+
if (session?.socket !== socket) {
|
|
187
198
|
return;
|
|
188
199
|
}
|
|
189
|
-
this.sessions.delete(sessionId);
|
|
190
200
|
this.lastMessages.disconnected(sessionId);
|
|
191
|
-
this.
|
|
192
|
-
|
|
201
|
+
if (!this.parking.closed(session)) {
|
|
202
|
+
this.sessions.delete(sessionId);
|
|
203
|
+
this.clearPendingReplaceDeliveries(sessionId);
|
|
204
|
+
this.broadcast({ type: "session_left", sessionId }, session.info);
|
|
205
|
+
}
|
|
193
206
|
this.scheduleShutdownCheck();
|
|
194
207
|
}
|
|
195
208
|
|
|
@@ -208,7 +221,7 @@ class IntercomBroker {
|
|
|
208
221
|
continue;
|
|
209
222
|
}
|
|
210
223
|
const message = publicationMessage(publication, subscription.awaitRelease === true);
|
|
211
|
-
const error = this.validateDeliveryPayload(from, message, target
|
|
224
|
+
const error = this.validateDeliveryPayload(from, message, target);
|
|
212
225
|
if (error !== null) {
|
|
213
226
|
throw new Error(error);
|
|
214
227
|
}
|
|
@@ -374,14 +387,16 @@ class IntercomBroker {
|
|
|
374
387
|
message: Message,
|
|
375
388
|
): SendResult {
|
|
376
389
|
const target = this.sessions.get(toId);
|
|
377
|
-
if (target && shouldReplaceDelivery(message, target.info)) {
|
|
390
|
+
if (target && target.parked === undefined && shouldReplaceDelivery(message, target.info)) {
|
|
378
391
|
return this.queueReplaceDelivery(fromId, toId, from, message);
|
|
379
392
|
}
|
|
380
393
|
const reason = this.deliverMessage(toId, from, message);
|
|
394
|
+
const queued = reason === null && target?.parked !== undefined;
|
|
381
395
|
return {
|
|
382
396
|
id: message.id,
|
|
383
397
|
accepted: reason === null,
|
|
384
|
-
delivered: reason === null,
|
|
398
|
+
delivered: reason === null && !queued,
|
|
399
|
+
...(queued ? { queued } : {}),
|
|
385
400
|
...(reason !== null ? { reason } : {}),
|
|
386
401
|
};
|
|
387
402
|
}
|
|
@@ -410,11 +425,7 @@ class IntercomBroker {
|
|
|
410
425
|
from: SessionInfo,
|
|
411
426
|
message: Message,
|
|
412
427
|
): SendResult {
|
|
413
|
-
const validationError = this.validateDeliveryPayload(
|
|
414
|
-
from,
|
|
415
|
-
message,
|
|
416
|
-
this.sessions.get(toId)?.topicFrames === true,
|
|
417
|
-
);
|
|
428
|
+
const validationError = this.validateDeliveryPayload(from, message, this.sessions.get(toId));
|
|
418
429
|
if (validationError !== null) {
|
|
419
430
|
return { id: message.id, accepted: false, delivered: false, reason: validationError };
|
|
420
431
|
}
|
|
@@ -450,37 +461,33 @@ class IntercomBroker {
|
|
|
450
461
|
}
|
|
451
462
|
}
|
|
452
463
|
|
|
464
|
+
/** One admission rule; retained input is checked as the uncompacted envelope any later
|
|
465
|
+
* transport generation accepts. */
|
|
453
466
|
private validateDeliveryPayload(
|
|
454
467
|
from: SessionInfo,
|
|
455
468
|
message: Message,
|
|
456
|
-
|
|
469
|
+
target: ConnectedSession | undefined,
|
|
457
470
|
): string | null {
|
|
471
|
+
const compact = target?.parked === undefined && target?.topicFrames === true;
|
|
458
472
|
return (
|
|
459
473
|
validateIntercomMessageSize({
|
|
460
474
|
type: "message",
|
|
461
475
|
from: messageSender(from),
|
|
462
|
-
message:
|
|
476
|
+
message: compact ? compactTopicMessage(message) : message,
|
|
463
477
|
})?.message ?? null
|
|
464
478
|
);
|
|
465
479
|
}
|
|
466
480
|
|
|
467
481
|
private deliverMessage(toId: string, from: SessionInfo, message: Message): string | null {
|
|
468
|
-
const guardError = guardedDeliveryError(this.sessions, toId, message);
|
|
469
|
-
if (guardError !== null) {
|
|
470
|
-
return guardError;
|
|
471
|
-
}
|
|
472
482
|
const target = this.sessions.get(toId);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
) {
|
|
479
|
-
return "Recipient disconnected before delivery";
|
|
483
|
+
const refused =
|
|
484
|
+
guardedDeliveryError(this.sessions, toId, message) ??
|
|
485
|
+
this.validateDeliveryPayload(from, message, target);
|
|
486
|
+
if (refused !== null || this.parking.retain(target, from, message)) {
|
|
487
|
+
return refused;
|
|
480
488
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return validationError;
|
|
489
|
+
if (!reachable(target)) {
|
|
490
|
+
return "Recipient disconnected before delivery";
|
|
484
491
|
}
|
|
485
492
|
this.touchActivity(toId, true);
|
|
486
493
|
try {
|