@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.52.0] - 2026-10-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Park children that stay idle for five minutes on a Pi fork with native working-session capture. The child saves its exact working state and its process exits, freeing its memory. Its Intercom identity stays listed with no departure. Passive messages, topic updates and `last` reads do not wake it; its main answers `last` from the saved history. A normal message, ask or continuation restores the exact saved state into a new process, which then receives retained messages in order. A failed final save records `park-failed.json` and restarts nothing; continuing reports it. Official Pi keeps idle children resident.
|
|
10
|
+
|
|
11
|
+
### Known limitations
|
|
12
|
+
|
|
13
|
+
- The remaining idle CPU, receipt-reader, child-readiness and receipt-append performance targets are tracked in [#290](https://github.com/fitchmultz/pi-subagents/issues/290). This release does not claim those targets are met.
|
|
14
|
+
|
|
3
15
|
## [0.51.0] - 2026-10-26
|
|
4
16
|
|
|
5
17
|
### Added
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { shellCommand } from "./process.js";
|
|
3
|
+
import { localCwdOwner } from "./cwd.js";
|
|
4
|
+
function guard(pi, directory, token) {
|
|
5
|
+
pi.on("tool_call", (event) => {
|
|
6
|
+
if (event.toolName === "delegate" || event.toolName === "agent_runs") {
|
|
7
|
+
return { block: true, reason: "Only a main session may delegate or control children." };
|
|
8
|
+
}
|
|
9
|
+
const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
|
|
10
|
+
if (event.toolName === "bash" &&
|
|
11
|
+
typeof event.input.command === "string" &&
|
|
12
|
+
owner &&
|
|
13
|
+
(owner.source === "builtin" || localCwdOwner(owner))) {
|
|
14
|
+
// Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
|
|
15
|
+
const input = event.input;
|
|
16
|
+
input.command = shellCommand(event.input.command, directory, token);
|
|
17
|
+
}
|
|
18
|
+
return;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** One native child session with this package's child policy and live-view hooks. */
|
|
22
|
+
export function childRuntime(sdk, launch, hooks) {
|
|
23
|
+
return async ({ sessionManager }) => {
|
|
24
|
+
// Exact restore keeps settings, tool policy and accepted queues; it never replays input.
|
|
25
|
+
const restore = hooks.saved ? { workingSession: hooks.saved } : {};
|
|
26
|
+
if (!fs.existsSync(launch.sessionFile)) {
|
|
27
|
+
throw new Error("Missing saved native session; refusing to create another.");
|
|
28
|
+
}
|
|
29
|
+
const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
|
|
30
|
+
projectTrusted: launch.trusted,
|
|
31
|
+
});
|
|
32
|
+
const services = await sdk.createAgentSessionServices({
|
|
33
|
+
...restore,
|
|
34
|
+
cwd: launch.cwd,
|
|
35
|
+
agentDir: launch.agentDir,
|
|
36
|
+
settingsManager,
|
|
37
|
+
resourceLoaderOptions: {
|
|
38
|
+
additionalExtensionPaths: [launch.entry],
|
|
39
|
+
extensionFactories: [
|
|
40
|
+
{
|
|
41
|
+
name: "codemode",
|
|
42
|
+
builtin: true,
|
|
43
|
+
factory: sdk.createCodemodeExtension({ mode: "on" }),
|
|
44
|
+
},
|
|
45
|
+
{ name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
|
|
46
|
+
{ name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
|
|
47
|
+
(pi) => {
|
|
48
|
+
guard(pi, hooks.directory, hooks.token);
|
|
49
|
+
pi.on("session_start", (_event, ctx) => {
|
|
50
|
+
hooks.started(pi, ctx);
|
|
51
|
+
});
|
|
52
|
+
// Sparse tool lifecycle keeps the live view's tools even while nobody watches.
|
|
53
|
+
pi.on("tool_execution_start", hooks.tool);
|
|
54
|
+
pi.on("tool_execution_update", hooks.tool);
|
|
55
|
+
pi.on("tool_execution_end", hooks.tool);
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const diagnostics = services.resourceLoader.getExtensions().errors;
|
|
61
|
+
if (diagnostics.length > 0) {
|
|
62
|
+
throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
|
|
63
|
+
}
|
|
64
|
+
const model = services.modelRuntime.getModel(launch.provider, launch.model);
|
|
65
|
+
if (!model) {
|
|
66
|
+
throw new Error(`Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...(await sdk.createAgentSessionFromServices({
|
|
70
|
+
...restore,
|
|
71
|
+
services,
|
|
72
|
+
sessionManager,
|
|
73
|
+
model,
|
|
74
|
+
thinkingLevel: launch.thinking,
|
|
75
|
+
})),
|
|
76
|
+
services,
|
|
77
|
+
diagnostics: services.diagnostics,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/agent/contracts.js
CHANGED
|
@@ -89,6 +89,15 @@ export const commandSchema = Type.Object({
|
|
|
89
89
|
createdAt: Type.Number(),
|
|
90
90
|
});
|
|
91
91
|
export const successorSchema = Type.Object({ id: Type.String() });
|
|
92
|
+
/** Authority to restore exact working state, valid only after the named actor has exited. */
|
|
93
|
+
/** Exact parked authority: one exited generation's state bytes for this session and leaf. */
|
|
94
|
+
export const parkedSchema = Type.Object({
|
|
95
|
+
token: Type.String(),
|
|
96
|
+
state: Type.String(),
|
|
97
|
+
digest: Type.String(),
|
|
98
|
+
sessionId: Type.String(),
|
|
99
|
+
leafId: Type.Union([Type.String(), Type.Null()]),
|
|
100
|
+
});
|
|
92
101
|
export const exitSchema = Type.Object({
|
|
93
102
|
pid: Type.Integer(),
|
|
94
103
|
code: Type.Union([Type.Integer(), Type.Null()]),
|
package/dist/agent/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { actor, alive, ensureActor, ownIdentity, requestStop, signalProcess, stopChildren, } from "./process.js";
|
|
4
|
+
import { actor, alive, ensureActor, ownIdentity, parkFailure, requestStop, signalProcess, stopChildren, } from "./process.js";
|
|
5
5
|
import { isChild, errorText } from "./host.js";
|
|
6
|
+
import { serveParked } from "./parking.js";
|
|
6
7
|
import { resultSchema, attachmentSchema, admissionSchema, commandSchema, } from "./contracts.js";
|
|
7
8
|
import { delegateSchema, runsSchema } from "./tool-schemas.js";
|
|
8
|
-
import { enqueue, latest, launchAt, list, read, runDirectory, ownerDirectory, conversation, write, } from "./storage.js";
|
|
9
|
+
import { enqueue, latest, launchAt, list, read, runDirectory, ownerDirectory, conversation, successor, write, } from "./storage.js";
|
|
9
10
|
import { notifications, deliver, settle } from "./notifications.js";
|
|
10
11
|
import { prepareLaunch } from "./launch.js";
|
|
11
12
|
import { renderReceipt, renderInventory, renderInspection } from "./view.js";
|
|
@@ -290,23 +291,6 @@ class AgentRuntime {
|
|
|
290
291
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
291
292
|
return renderInventory(this.base, all, this.ctx, input);
|
|
292
293
|
}
|
|
293
|
-
successor(launch, prompt) {
|
|
294
|
-
const id = randomUUID();
|
|
295
|
-
const next = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
|
|
296
|
-
fs.mkdirSync(runDirectory(this.base, id), { mode: 0o700 });
|
|
297
|
-
write(path.join(runDirectory(this.base, id), "launch.json"), next, true);
|
|
298
|
-
try {
|
|
299
|
-
write(path.join(runDirectory(this.base, launch.id), "successor.json"), { id }, true);
|
|
300
|
-
}
|
|
301
|
-
catch (error) {
|
|
302
|
-
fs.rmSync(runDirectory(this.base, id), { recursive: true });
|
|
303
|
-
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
|
|
304
|
-
throw error;
|
|
305
|
-
}
|
|
306
|
-
return undefined;
|
|
307
|
-
}
|
|
308
|
-
return next;
|
|
309
|
-
}
|
|
310
294
|
continuationResult(launch) {
|
|
311
295
|
const result = read(path.join(runDirectory(this.base, launch.id), "result.json"), resultSchema);
|
|
312
296
|
if (result) {
|
|
@@ -352,7 +336,8 @@ class AgentRuntime {
|
|
|
352
336
|
note: "Guidance queued, not actual completion or model consumption.",
|
|
353
337
|
});
|
|
354
338
|
}
|
|
355
|
-
|
|
339
|
+
parkFailure(this.base, launch);
|
|
340
|
+
const next = successor(this.base, launch, input.prompt);
|
|
356
341
|
if (!next) {
|
|
357
342
|
return renderReceipt(this.base, latest(this.base, launch), this.ctx, {
|
|
358
343
|
receipt: "existing-successor",
|
|
@@ -409,6 +394,7 @@ class AgentRuntime {
|
|
|
409
394
|
}
|
|
410
395
|
register() {
|
|
411
396
|
registerCompactMessages(this.pi, ["agent-completion", "subagent-notify"]);
|
|
397
|
+
serveParked(this.pi, () => (this.child || !this.ctx ? undefined : { base: this.base, owner: this.owner }), this.shutdown.signal);
|
|
412
398
|
this.pi.on("session_start", (_event, context) => this.start(context));
|
|
413
399
|
this.pi.on("turn_end", (event, context) => {
|
|
414
400
|
// The public pending flag covers user input; the preview also includes custom messages.
|
|
@@ -117,6 +117,10 @@ export class ActorObservation {
|
|
|
117
117
|
const { session } = this;
|
|
118
118
|
return this.running || session.isStreaming || session.isCompacting || session.isRetrying;
|
|
119
119
|
}
|
|
120
|
+
/** A live viewer keeps its actor resident; parking would end the native stream it reads. */
|
|
121
|
+
get watched() {
|
|
122
|
+
return this.viewers.size + this.greeting.size > 0;
|
|
123
|
+
}
|
|
120
124
|
details() {
|
|
121
125
|
return [...this.viewers].filter((viewer) => viewer.level === "detail").length;
|
|
122
126
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { parkedSchema } from "./contracts.js";
|
|
5
|
+
import { errorText, loadSDK } from "./host.js";
|
|
6
|
+
import { conversation, latest, launchAt, list, read, runDirectory, write } from "./storage.js";
|
|
7
|
+
import { alive, shells, wakeActor } from "./process.js";
|
|
8
|
+
import { isHistoryRequest, PARK, PARKED_HISTORY, WAKE, } from "../shared/parking.js";
|
|
9
|
+
import { nativePeerId } from "../pi-intercom/session-targets.js";
|
|
10
|
+
function parkable(session) {
|
|
11
|
+
return typeof Reflect.get(session, "acquireWorkingSession") === "function";
|
|
12
|
+
}
|
|
13
|
+
function finalizing(runtime) {
|
|
14
|
+
return typeof Reflect.get(runtime, "setFinalization") === "function";
|
|
15
|
+
}
|
|
16
|
+
function fence(pi, park) {
|
|
17
|
+
let fenced = Promise.resolve(false);
|
|
18
|
+
pi.events.emit(PARK, {
|
|
19
|
+
park,
|
|
20
|
+
reply: (parked) => {
|
|
21
|
+
fenced = parked;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return fenced;
|
|
25
|
+
}
|
|
26
|
+
async function cut(pi, session, { quiet, errors, signal }) {
|
|
27
|
+
try {
|
|
28
|
+
// Save handlers may legitimately append entries; only the pre-cut leaf proves idleness.
|
|
29
|
+
if ((await fence(pi, true)) && quiet() && session.isIdle) {
|
|
30
|
+
return await session.acquireWorkingSession({ boundary: "settled", signal });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
errors.push(errorText(error));
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const PARK_AFTER_MS = 5 * 60_000;
|
|
39
|
+
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
40
|
+
/** Parked authority names this session and exactly the saved generation's state bytes. */
|
|
41
|
+
function exact(restore, parked, state, launch) {
|
|
42
|
+
if (parked.sessionId !== launch.sessionId ||
|
|
43
|
+
parked.state !== state ||
|
|
44
|
+
digest(state) !== parked.digest) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const saved = restore(state);
|
|
48
|
+
return saved.sessionFile === launch.sessionFile &&
|
|
49
|
+
saved.header.id === launch.sessionId &&
|
|
50
|
+
saved.leafId === parked.leafId
|
|
51
|
+
? saved
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
/** This child's exact parked generation, if any; mismatched authority restores nothing. */
|
|
55
|
+
export function parkedState(dir, launch, restore) {
|
|
56
|
+
const parked = read(path.join(dir, "parked.json"), parkedSchema);
|
|
57
|
+
if (!parked) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (!restore) {
|
|
61
|
+
throw new Error("Parked working state needs the Pi fork that saved it; nothing was reopened.");
|
|
62
|
+
}
|
|
63
|
+
// Native working state requires a canonical parent, not a symlinked alias like macOS /var.
|
|
64
|
+
const state = path.join(fs.realpathSync(dir), `working-${parked.token}.json`);
|
|
65
|
+
const saved = exact(restore, parked, state, launch);
|
|
66
|
+
if (!saved) {
|
|
67
|
+
throw new Error("Parked working state is not this child's saved generation; nothing was reopened.");
|
|
68
|
+
}
|
|
69
|
+
return { state, saved };
|
|
70
|
+
}
|
|
71
|
+
/** One actor generation's idle parking and exact restore; commands stay with the runner. */
|
|
72
|
+
export class Parking {
|
|
73
|
+
saved;
|
|
74
|
+
restored;
|
|
75
|
+
since = 0;
|
|
76
|
+
leaf;
|
|
77
|
+
base;
|
|
78
|
+
launch;
|
|
79
|
+
token;
|
|
80
|
+
sdk;
|
|
81
|
+
constructor(base, launch, token, sdk) {
|
|
82
|
+
this.base = base;
|
|
83
|
+
this.launch = launch;
|
|
84
|
+
this.token = token;
|
|
85
|
+
this.sdk = sdk;
|
|
86
|
+
}
|
|
87
|
+
get dir() {
|
|
88
|
+
return runDirectory(this.base, this.launch.rootId);
|
|
89
|
+
}
|
|
90
|
+
get file() {
|
|
91
|
+
return path.join(this.dir, "parked.json");
|
|
92
|
+
}
|
|
93
|
+
/** Parked authority is exact state or nothing; never a journal-only reopen. */
|
|
94
|
+
open() {
|
|
95
|
+
const { sdk, launch } = this;
|
|
96
|
+
const { openWorkingSession: restore } = sdk;
|
|
97
|
+
const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
|
|
98
|
+
if (!parked || !restore) {
|
|
99
|
+
return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
|
|
100
|
+
}
|
|
101
|
+
this.saved = parked.saved;
|
|
102
|
+
this.restored = parked.state;
|
|
103
|
+
return restore(parked.saved);
|
|
104
|
+
}
|
|
105
|
+
/** The ready restored generation owns this state now; a later park writes a new cut. */
|
|
106
|
+
adopt() {
|
|
107
|
+
if (this.restored !== undefined) {
|
|
108
|
+
fs.rmSync(this.file);
|
|
109
|
+
fs.rmSync(this.restored, { force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** True once this generation has exited its runtime with or without parked authority. */
|
|
113
|
+
async park(runtime, actor) {
|
|
114
|
+
if (!actor.pi ||
|
|
115
|
+
!this.due(runtime.session, actor.busy) ||
|
|
116
|
+
shells(this.base, this.launch, this.token).some(alive)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const hold = await this.prepare(runtime, actor.pi, actor);
|
|
120
|
+
if (hold) {
|
|
121
|
+
await this.commit(runtime, hold, actor.errors);
|
|
122
|
+
}
|
|
123
|
+
return hold !== undefined;
|
|
124
|
+
}
|
|
125
|
+
/** Final idle: no run, turn, journal change or viewer since the period began. */
|
|
126
|
+
due(session, busy) {
|
|
127
|
+
const leaf = session.sessionManager.getLeafId();
|
|
128
|
+
if (busy || !session.isIdle || leaf !== this.leaf) {
|
|
129
|
+
this.leaf = leaf;
|
|
130
|
+
this.since = performance.now();
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return performance.now() - this.since >= PARK_AFTER_MS;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Fence broker input, then hold a settled sleep-ready cut of the same quiet idle leaf. Any
|
|
137
|
+
* failure or raced command releases, unfences and keeps this actor resident.
|
|
138
|
+
*/
|
|
139
|
+
async prepare(runtime, pi, { quiet, errors, signal }) {
|
|
140
|
+
const { session } = runtime;
|
|
141
|
+
this.since = performance.now();
|
|
142
|
+
if (!parkable(session) || !this.sdk.writeWorkingSession || !finalizing(runtime)) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
// A launcher that enqueues after this marker waits for exit, then wakes the saved state.
|
|
146
|
+
const marker = path.join(this.dir, "actor.lock", "parking.json");
|
|
147
|
+
write(marker, { at: Date.now() });
|
|
148
|
+
const hold = await cut(pi, session, {
|
|
149
|
+
quiet: () => quiet() && session.sessionManager.getLeafId() === this.leaf,
|
|
150
|
+
errors,
|
|
151
|
+
signal,
|
|
152
|
+
});
|
|
153
|
+
// Revalidate after the awaited fence and save, immediately before the irreversible commit.
|
|
154
|
+
if (hold?.sleepReady === true && quiet()) {
|
|
155
|
+
return hold;
|
|
156
|
+
}
|
|
157
|
+
await hold?.release();
|
|
158
|
+
await fence(pi, false);
|
|
159
|
+
fs.rmSync(marker, { force: true });
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
/** Commit: native strict exit, then capture; failure leaves no parked authority. */
|
|
163
|
+
async commit(runtime, hold, errors) {
|
|
164
|
+
const save = this.sdk.writeWorkingSession;
|
|
165
|
+
if (save && finalizing(runtime)) {
|
|
166
|
+
runtime.setFinalization((final) => this.capture(final, save), { strictShutdown: true });
|
|
167
|
+
}
|
|
168
|
+
// No await between release and dispose: native final exit fences new turns in this tick.
|
|
169
|
+
const released = hold.release();
|
|
170
|
+
const disposed = runtime.dispose();
|
|
171
|
+
try {
|
|
172
|
+
await Promise.all([released, disposed]);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
errors.push(errorText(error));
|
|
176
|
+
}
|
|
177
|
+
if (read(this.file, parkedSchema)?.token !== this.token) {
|
|
178
|
+
// Truthful boundary: retained input stays queued and nothing restarts implicitly.
|
|
179
|
+
write(path.join(this.dir, "park-failed.json"), { token: this.token, errors });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async capture(session, save) {
|
|
183
|
+
if (!parkable(session)) {
|
|
184
|
+
throw new Error("Native working state is unavailable.");
|
|
185
|
+
}
|
|
186
|
+
const hold = await session.acquireWorkingSession({ boundary: "settled" });
|
|
187
|
+
try {
|
|
188
|
+
if (!hold.sleepReady) {
|
|
189
|
+
throw new Error(`Final working state is not sleep-ready: ${hold.blockers.join("; ")}`);
|
|
190
|
+
}
|
|
191
|
+
const state = path.join(fs.realpathSync(this.dir), `working-${this.token}.json`);
|
|
192
|
+
save(state, hold.state);
|
|
193
|
+
hold.assertHeld();
|
|
194
|
+
write(this.file, {
|
|
195
|
+
token: this.token,
|
|
196
|
+
state,
|
|
197
|
+
digest: digest(state),
|
|
198
|
+
sessionId: this.launch.sessionId,
|
|
199
|
+
leafId: hold.state.leafId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await hold.release();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** The broker names a parked child by native peer ID; only this owner's conversations match. */
|
|
208
|
+
function ownedRun(runs, peer) {
|
|
209
|
+
const launch = runs && typeof peer === "string"
|
|
210
|
+
? list(runs.base)
|
|
211
|
+
.map((id) => launchAt(runs.base, id, runs.owner))
|
|
212
|
+
.find((candidate) => nativePeerId(candidate.sessionId) === peer)
|
|
213
|
+
: undefined;
|
|
214
|
+
return launch && runs && latest(runs.base, launch);
|
|
215
|
+
}
|
|
216
|
+
/** The captured leaf's root-to-leaf path, as a live session manager reports it. */
|
|
217
|
+
function branchOf(state) {
|
|
218
|
+
const entries = new Map(state.entries.map((entry) => [entry.id, entry]));
|
|
219
|
+
const branch = [];
|
|
220
|
+
for (let entry = entries.get(state.leafId ?? ""); entry !== undefined; entry = entries.get(entry.parentId ?? "")) {
|
|
221
|
+
branch.unshift(entry);
|
|
222
|
+
}
|
|
223
|
+
return branch;
|
|
224
|
+
}
|
|
225
|
+
async function history(runs, peer) {
|
|
226
|
+
const launch = ownedRun(runs, peer);
|
|
227
|
+
const restore = launch && (await loadSDK(launch.sdk)).readWorkingSession;
|
|
228
|
+
const state = runs && launch && parkedState(conversation(runs.base, launch), launch, restore)?.saved;
|
|
229
|
+
return state && { getBranch: () => branchOf(state) };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Wakes this owner's parked children for broker input and reads their saved history.
|
|
233
|
+
* `runs` is undefined while this session owns no children.
|
|
234
|
+
*/
|
|
235
|
+
export function serveParked(pi, runs, signal) {
|
|
236
|
+
pi.events.on(WAKE, (peer) => {
|
|
237
|
+
// On failure, retained input stays queued at the broker until the next main registration.
|
|
238
|
+
(async () => {
|
|
239
|
+
const current = runs();
|
|
240
|
+
const launch = ownedRun(current, peer);
|
|
241
|
+
if (current && launch) {
|
|
242
|
+
await wakeActor(current.base, launch, signal);
|
|
243
|
+
}
|
|
244
|
+
})().catch((error) => {
|
|
245
|
+
console.error(`Agent wake: ${errorText(error)}`);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
pi.events.on(PARKED_HISTORY, (request) => {
|
|
249
|
+
if (isHistoryRequest(request)) {
|
|
250
|
+
request.reply(history(runs(), request.peer));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
package/dist/agent/process.js
CHANGED
|
@@ -7,7 +7,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
7
7
|
import { isChildProcessReplaced, observeChildProcessReplaced, readChildProcessIdentity, readProcessClock, trySignalChildTree, } from "../shared/post-exit-stdio-guard.js";
|
|
8
8
|
import { agentObservationSocketPath } from "../pi-intercom/broker/paths.js";
|
|
9
9
|
import { hasErrorCode } from "../shared/unknown.js";
|
|
10
|
-
import { attachmentSchema, processSchema } from "./contracts.js";
|
|
10
|
+
import { attachmentSchema, parkedSchema, processSchema, } from "./contracts.js";
|
|
11
11
|
import { conversation, enqueue, launchAt, list, read, write } from "./storage.js";
|
|
12
12
|
import { errorText } from "./host.js";
|
|
13
13
|
/** Owner records follow their process's birth; stat after read only moves the bound later. */
|
|
@@ -154,9 +154,12 @@ function spawnActor(base, launch, token) {
|
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
async function awaitStopping(dir, existing, signal) {
|
|
157
|
-
if (existing
|
|
158
|
-
const
|
|
159
|
-
|
|
157
|
+
if (existing) {
|
|
158
|
+
const stopping = path.join(dir, `stopping-${existing.token}.json`);
|
|
159
|
+
// Parking ends in exit or a removed marker, after native shutdown persistence.
|
|
160
|
+
const parking = path.join(dir, "actor.lock", "parking.json");
|
|
161
|
+
const deadline = Date.now() + (fs.existsSync(stopping) ? 5000 : 30_000);
|
|
162
|
+
while ((fs.existsSync(stopping) || fs.existsSync(parking)) && alive(existing)) {
|
|
160
163
|
signal?.throwIfAborted();
|
|
161
164
|
if (Date.now() > deadline) {
|
|
162
165
|
throw new Error("Previous actor is still stopping; no replacement was launched.");
|
|
@@ -177,6 +180,7 @@ export async function ensureActor(base, launch, signal) {
|
|
|
177
180
|
if (existing && alive(existing)) {
|
|
178
181
|
return;
|
|
179
182
|
}
|
|
183
|
+
parkFailure(base, launch, existing);
|
|
180
184
|
if (existing) {
|
|
181
185
|
if (shells(base, launch, existing.token).some(alive)) {
|
|
182
186
|
throw new Error("Owned native shell work remains live; stop it before continuing.");
|
|
@@ -199,6 +203,29 @@ export async function ensureActor(base, launch, signal) {
|
|
|
199
203
|
write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
|
|
200
204
|
spawnActor(base, launch, token);
|
|
201
205
|
}
|
|
206
|
+
/** A failed park keeps its input queued; exact state is lost, so nothing restarts it implicitly. */
|
|
207
|
+
export function parkFailure(base, launch, exited) {
|
|
208
|
+
const dir = conversation(base, launch);
|
|
209
|
+
const parked = read(path.join(dir, "parked.json"), parkedSchema);
|
|
210
|
+
if (exited && parked && parked.token !== exited.token) {
|
|
211
|
+
// Only the exited claim that saved this state may hand it to the next generation.
|
|
212
|
+
throw new Error("Parked working state belongs to another actor generation; nothing was reopened.");
|
|
213
|
+
}
|
|
214
|
+
const file = path.join(dir, "park-failed.json");
|
|
215
|
+
if (fs.existsSync(file)) {
|
|
216
|
+
throw new Error(`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.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** A broker wake may only restore exact parked state, never reopen the journal alone. */
|
|
220
|
+
export async function wakeActor(base, launch, signal) {
|
|
221
|
+
const dir = conversation(base, launch);
|
|
222
|
+
await awaitStopping(dir, actor(base, launch), signal);
|
|
223
|
+
parkFailure(base, launch);
|
|
224
|
+
if (!fs.existsSync(path.join(dir, "parked.json"))) {
|
|
225
|
+
throw new Error("No parked working state; the child was not woken.");
|
|
226
|
+
}
|
|
227
|
+
await ensureActor(base, launch, signal);
|
|
228
|
+
}
|
|
202
229
|
/** A crashed generation cannot unlink its endpoint; its exact token names the leftover. */
|
|
203
230
|
function removeObservationEndpoint(token) {
|
|
204
231
|
try {
|