@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/agent/child-runtime.js +80 -0
  3. package/dist/agent/contracts.js +9 -0
  4. package/dist/agent/index.js +6 -20
  5. package/dist/agent/observation.js +4 -0
  6. package/dist/agent/parking.js +253 -0
  7. package/dist/agent/process.js +31 -4
  8. package/dist/agent/runner.js +124 -126
  9. package/dist/agent/storage.js +18 -0
  10. package/dist/build-info.js +1 -1
  11. package/dist/pi-intercom/broker/broker.js +42 -29
  12. package/dist/pi-intercom/broker/client-last.d.ts +12 -1
  13. package/dist/pi-intercom/broker/client-last.js +25 -0
  14. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  15. package/dist/pi-intercom/broker/client-park.js +21 -0
  16. package/dist/pi-intercom/broker/client.d.ts +2 -3
  17. package/dist/pi-intercom/broker/client.js +23 -28
  18. package/dist/pi-intercom/broker/last.d.ts +5 -1
  19. package/dist/pi-intercom/broker/last.js +34 -12
  20. package/dist/pi-intercom/broker/parked.js +107 -0
  21. package/dist/pi-intercom/broker/server-connection.js +1 -1
  22. package/dist/pi-intercom/connection.js +65 -22
  23. package/dist/pi-intercom/inbound.js +11 -0
  24. package/dist/pi-intercom/index.js +38 -0
  25. package/dist/shared/parking.js +12 -0
  26. package/docs/code-quality.md +5 -5
  27. package/docs/development.md +3 -3
  28. package/docs/intercom.md +2 -1
  29. package/docs/performance.md +31 -3
  30. package/docs/reference.md +12 -1
  31. package/package.json +1 -1
  32. package/scripts/measure-size.mjs +1 -1
  33. package/src/agent/child-runtime.ts +105 -0
  34. package/src/agent/contracts.ts +10 -0
  35. package/src/agent/host.ts +22 -2
  36. package/src/agent/index.ts +10 -17
  37. package/src/agent/observation.ts +4 -0
  38. package/src/agent/parking.ts +341 -0
  39. package/src/agent/process.ts +41 -4
  40. package/src/agent/runner.ts +124 -144
  41. package/src/agent/storage.ts +17 -0
  42. package/src/pi-intercom/broker/broker.ts +44 -37
  43. package/src/pi-intercom/broker/client-last.ts +41 -1
  44. package/src/pi-intercom/broker/client-park.ts +23 -0
  45. package/src/pi-intercom/broker/client.ts +24 -28
  46. package/src/pi-intercom/broker/last.ts +49 -14
  47. package/src/pi-intercom/broker/parked.ts +130 -0
  48. package/src/pi-intercom/broker/server-connection.ts +2 -2
  49. package/src/pi-intercom/connection.ts +87 -34
  50. package/src/pi-intercom/inbound.ts +14 -0
  51. package/src/pi-intercom/index.ts +41 -0
  52. package/src/pi-intercom/last-message.ts +1 -1
  53. 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
+ }
@@ -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()]),
@@ -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
- const next = this.successor(launch, input.prompt);
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
+ }
@@ -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 && fs.existsSync(path.join(dir, `stopping-${existing.token}.json`))) {
158
- const deadline = Date.now() + 5000;
159
- while (alive(existing)) {
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 {