@fitchmultz/pi-subagents 0.52.4 → 0.52.6

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 (65) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +5 -4
  3. package/dist/agent/completion-watches.js +61 -0
  4. package/dist/agent/contracts.js +9 -0
  5. package/dist/agent/host.js +5 -0
  6. package/dist/agent/index.js +20 -51
  7. package/dist/agent/legacy.js +41 -11
  8. package/dist/agent/notifications.js +2 -2
  9. package/dist/agent/parking.js +121 -13
  10. package/dist/agent/process.js +107 -63
  11. package/dist/agent/receipts.js +21 -4
  12. package/dist/agent/runner.js +19 -5
  13. package/dist/agent/shutdown.js +43 -0
  14. package/dist/agent/view.js +5 -1
  15. package/dist/build-info.js +1 -1
  16. package/dist/pi-intercom/broker/broker.js +7 -6
  17. package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
  18. package/dist/pi-intercom/broker/client-wake.js +54 -0
  19. package/dist/pi-intercom/broker/client.d.ts +2 -0
  20. package/dist/pi-intercom/broker/client.js +23 -16
  21. package/dist/pi-intercom/broker/parked.js +122 -5
  22. package/dist/pi-intercom/broker/spawn.js +10 -6
  23. package/dist/pi-intercom/config.js +3 -2
  24. package/dist/pi-intercom/connection.js +72 -11
  25. package/dist/pi-intercom/inbound-delivery.js +10 -5
  26. package/dist/pi-intercom/index.js +41 -6
  27. package/dist/pi-intercom/outbound.js +24 -7
  28. package/dist/pi-intercom/reply-wait.js +6 -1
  29. package/dist/pi-intercom/tool-results.js +2 -0
  30. package/dist/pi-intercom/types.d.ts +6 -0
  31. package/dist/shared/background-reporting.js +72 -0
  32. package/dist/shared/parking.js +6 -0
  33. package/docs/development.md +15 -3
  34. package/docs/intercom.md +14 -0
  35. package/docs/performance.md +2 -2
  36. package/docs/reference.md +13 -5
  37. package/package.json +1 -1
  38. package/scripts/package-smoke.mjs +21 -3
  39. package/src/agent/completion-watches.ts +73 -0
  40. package/src/agent/contracts.ts +11 -0
  41. package/src/agent/host.ts +6 -0
  42. package/src/agent/index.ts +24 -54
  43. package/src/agent/legacy.ts +44 -12
  44. package/src/agent/notifications.ts +2 -3
  45. package/src/agent/parking.ts +148 -12
  46. package/src/agent/process.ts +130 -66
  47. package/src/agent/receipts.ts +25 -4
  48. package/src/agent/runner.ts +25 -5
  49. package/src/agent/shutdown.ts +50 -0
  50. package/src/agent/view.ts +9 -0
  51. package/src/pi-intercom/broker/broker.ts +8 -7
  52. package/src/pi-intercom/broker/client-wake.ts +61 -0
  53. package/src/pi-intercom/broker/client.ts +23 -17
  54. package/src/pi-intercom/broker/parked.ts +152 -10
  55. package/src/pi-intercom/broker/spawn.ts +10 -9
  56. package/src/pi-intercom/config.ts +5 -2
  57. package/src/pi-intercom/connection.ts +88 -12
  58. package/src/pi-intercom/inbound-delivery.ts +21 -6
  59. package/src/pi-intercom/index.ts +64 -21
  60. package/src/pi-intercom/outbound.ts +30 -6
  61. package/src/pi-intercom/reply-wait.ts +6 -1
  62. package/src/pi-intercom/tool-results.ts +1 -0
  63. package/src/pi-intercom/types.ts +4 -0
  64. package/src/shared/background-reporting.ts +78 -0
  65. package/src/shared/parking.ts +16 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## 0.52.6 - 2026-11-01
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+
9
+ - Qualify a retained npm candidate through the native child and package-install smoke on the selected Pi hosts.
10
+ - Reconcile main-branch changes into reviewed patch releases and publish the qualified archive to npm and GitHub.
11
+
12
+ <!-- npm-release:unreleased:end -->
13
+
14
+ - Automate qualified npm releases (#315) ([e769f2d](https://github.com/fitchmultz/pi-subagents/commit/e769f2d62f4b731f56e301160b57929a3a0cec6b))
15
+
16
+ ### What's Changed
17
+
18
+ - Qualify retained Subagents candidates on native hosts (1/2) by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/314
19
+ - Automate qualified Subagents releases (2/2) by @fitchmultz in https://github.com/fitchmultz/pi-subagents/pull/315
20
+
21
+ **Full Changelog**: https://github.com/fitchmultz/pi-subagents/compare/v0.52.5...v0.52.6
22
+
23
+ ## [0.52.5] - 2026-10-31
24
+
25
+ ### Fixed
26
+
27
+ - Stop repeating finished-run completion messages in `--no-session` parents. Pi keeps an unsaved session's transcript only in memory, and the completion check looked only for a session file, so each settled turn delivered the same completion again and started another turn. The check now reads Pi's in-memory transcript when there is no session file. Saved sessions still use the session file. This also covers a result from a pre-0.50 background run that finishes after `/reload`; its saved result file is kept, because only a receipt in a session file can remove it.
28
+ - Report parked-child wake failures to waiting asks and run inspection without printing them into the parent TUI. Accepted questions remain queued and may still arrive; a later active message requests another wake.
29
+ - A failed restore can retry when its exact exited actor proves it never entered native open for the same parked generation. Ambiguous or possibly live claims remain protected.
30
+ - Release launch claims when a child process did not start, so later attempts can recover without a false ambiguity. Preserve claims when a process may have started.
31
+ - Show background errors as native notifications instead of writing over an active terminal. Report repeated failures once until the affected operation recovers; UI shutdown diagnostics are recorded separately when possible.
32
+ - Keep the `@fitchmultz/pi-subagents/intercom-client` type declarations independent of Pi so standalone Node consumers can type-check without installing the Pi SDK.
33
+
5
34
  ## [0.52.4] - 2026-10-29
6
35
 
7
36
  ### Fixed
package/README.md CHANGED
@@ -128,7 +128,8 @@ It never runs during startup/install, deletes data, or reclaims disk space.
128
128
  - [Diagnosis, baseline, and measurable rebuild gates](docs/single-agent-rebuild.md)
129
129
 
130
130
  Supports official Pi and the maintained `fitchmultz/pi` fork; the two hosts are
131
- qualified separately, including native Android/Termux. Node.js 24.21 or newer is
132
- required. Source installs on Android also need an Android-native `tsgo` on PATH;
133
- the prebuilt npm package does not need a compiler. See
134
- [development prerequisites](docs/development.md#native-androidtermux). MIT licensed.
131
+ qualified separately, including native Android/Termux. Node.js is required; see
132
+ [development prerequisites](docs/development.md). Source installs on Android also
133
+ need an Android-native `tsgo` on PATH; the prebuilt npm package does not need a
134
+ compiler. See [native Android/Termux](docs/development.md#native-androidtermux).
135
+ MIT licensed.
@@ -0,0 +1,61 @@
1
+ import * as path from "node:path";
2
+ import { errorMessage } from "../shared/unknown.js";
3
+ import { DirectoryWatch } from "./wake.js";
4
+ /** Owns change hints for completion inputs and reports failures without losing polling. */
5
+ export class CompletionWatches {
6
+ watches = new Map();
7
+ awaited = new Set();
8
+ reporter;
9
+ wake;
10
+ constructor(reporter, wake) {
11
+ this.reporter = reporter;
12
+ this.wake = wake;
13
+ }
14
+ update(base, legacy, awaited) {
15
+ this.awaited = awaited;
16
+ const directories = [
17
+ ...new Set([base, ...legacy, ...[...awaited].map((file) => path.dirname(file))]),
18
+ ];
19
+ for (const [directory, watch] of this.watches) {
20
+ if (!directories.includes(directory)) {
21
+ watch.close();
22
+ this.watches.delete(directory);
23
+ this.reporter.recover(`watch:${directory}`);
24
+ }
25
+ }
26
+ const states = directories.map((directory) => [
27
+ directory,
28
+ this.ensure(directory, directory === base || legacy.includes(directory)),
29
+ ]);
30
+ if (states.some(([directory, state]) => state === "unwatched" && !legacy.includes(directory))) {
31
+ return "poll";
32
+ }
33
+ return states.some(([, state]) => state === "armed") ? "rescan" : "hints";
34
+ }
35
+ ensure(directory, every) {
36
+ let watch = this.watches.get(directory);
37
+ if (watch === undefined) {
38
+ watch = new DirectoryWatch(directory, (file) => every || file === null || this.awaited.has(path.join(directory, file)), this.wake);
39
+ this.watches.set(directory, watch);
40
+ }
41
+ const owner = `watch:${directory}`;
42
+ try {
43
+ const state = watch.ensure();
44
+ this.reporter.recover(owner);
45
+ return state;
46
+ }
47
+ catch (error) {
48
+ watch.close();
49
+ this.reporter.report(owner, `Agent directory watch ${directory}: ${errorMessage(error)}`);
50
+ return "unwatched";
51
+ }
52
+ }
53
+ close() {
54
+ for (const [directory, watch] of this.watches) {
55
+ watch.close();
56
+ this.reporter.recover(`watch:${directory}`);
57
+ }
58
+ this.watches.clear();
59
+ this.awaited = new Set();
60
+ }
61
+ }
@@ -98,6 +98,15 @@ export const parkedSchema = Type.Object({
98
98
  sessionId: Type.String(),
99
99
  leafId: Type.Union([Type.String(), Type.Null()]),
100
100
  });
101
+ export const wakeFailureSchema = Type.Object({
102
+ at: Type.Number(),
103
+ reason: Type.String(),
104
+ beforeOpen: Type.Optional(Type.Object({
105
+ actorToken: Type.String(),
106
+ parkedToken: Type.String(),
107
+ })),
108
+ });
109
+ export const readySchema = Type.Object({ token: Type.String() });
101
110
  export const exitSchema = Type.Object({
102
111
  pid: Type.Integer(),
103
112
  code: Type.Union([Type.Integer(), Type.Null()]),
@@ -12,6 +12,11 @@ import { findPackageJSON } from "node:module";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
  import { readIntercomLineage } from "../pi-intercom/identity.js";
14
14
  export { errorMessage as errorText } from "../shared/unknown.js";
15
+ /** A launcher may still own children while handing off or replacing a worker. */
16
+ export function retainsChildren(event) {
17
+ const disposition = Reflect.get(event, "disposition");
18
+ return disposition === "handoff" || disposition === "provisional";
19
+ }
15
20
  function isPackage(root) {
16
21
  const file = path.join(root, "package.json");
17
22
  if (!fs.existsSync(file)) {
@@ -2,8 +2,9 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import * as loadedSDK from "@earendil-works/pi-coding-agent";
5
- import { actor, alive, ensureActor, ownIdentity, parkFailure, requestStop, signalProcess, stopChildren, } from "./process.js";
6
- import { isChild, errorText, rememberSDK } from "./host.js";
5
+ import { actor, alive, ensureActor, ownIdentity, parkFailure, requestStop, signalProcess, } from "./process.js";
6
+ import { stopChildren } from "./shutdown.js";
7
+ import { isChild, errorText, rememberSDK, retainsChildren } from "./host.js";
7
8
  import { serveParked } from "./parking.js";
8
9
  import { resultSchema, attachmentSchema, admissionSchema, commandSchema, } from "./contracts.js";
9
10
  import { delegateSchema, runsSchema } from "./tool-schemas.js";
@@ -11,17 +12,14 @@ import { enqueue, latest, launchAt, list, read, runDirectory, ownerDirectory, co
11
12
  import { notifications, deliver, settle } from "./notifications.js";
12
13
  import { prepareLaunch } from "./launch.js";
13
14
  import { renderReceipt, renderInventory, renderInspection } from "./view.js";
14
- import { legacyDirectory, legacyNotifications } from "./legacy.js";
15
- import { DirectoryWatch, pathStamp } from "./wake.js";
15
+ import { legacyDirectory, reconcileLegacy } from "./legacy.js";
16
+ import { pathStamp } from "./wake.js";
17
+ import { CompletionWatches } from "./completion-watches.js";
16
18
  import { savedContinuation } from "./saved.js";
17
19
  import { executionCwd } from "./cwd.js";
18
20
  import { getPiAgentDir } from "../pi-intercom/agent-dir.js";
19
21
  import { registerCompactMessages, renderCompactResult } from "../shared/compact-cards.js";
20
- /** `handoff` and `provisional` mean another worker under the same launcher may still own these children. */
21
- function retainsChildren(event) {
22
- const disposition = Reflect.get(event, "disposition");
23
- return disposition === "handoff" || disposition === "provisional";
24
- }
22
+ import { BackgroundReporter } from "../shared/background-reporting.js";
25
23
  function managedHost(context) {
26
24
  const host = Reflect.get(context, "managedHost");
27
25
  if (typeof host !== "object" || host === null) {
@@ -45,6 +43,7 @@ function legacyInput() {
45
43
  }
46
44
  class AgentRuntime {
47
45
  ctx;
46
+ reporter = new BackgroundReporter(() => this.ctx);
48
47
  // A managed launcher owns children across worker replacement; otherwise this process does.
49
48
  host;
50
49
  accepted = false;
@@ -55,7 +54,7 @@ class AgentRuntime {
55
54
  timer;
56
55
  woken;
57
56
  // Change hints for the owner directory, legacy results and every awaited canonical file.
58
- watches = new Map();
57
+ watches;
59
58
  awaited = new Set();
60
59
  // Input stamps the last reconciliation saw; a one-second probe recovers dropped hints.
61
60
  observed = new Map();
@@ -68,6 +67,7 @@ class AgentRuntime {
68
67
  pi;
69
68
  constructor(pi) {
70
69
  this.pi = pi;
70
+ this.watches = new CompletionWatches(this.reporter, this.wake);
71
71
  }
72
72
  checkMain() {
73
73
  if (isChild()) {
@@ -102,21 +102,17 @@ class AgentRuntime {
102
102
  try {
103
103
  const { notices, awaited } = notifications(this.ctx, this.base, this.owner);
104
104
  this.awaited = awaited;
105
- try {
106
- notices.unshift(...legacyNotifications(this.ctx));
107
- }
108
- catch (error) {
109
- console.error(`Legacy completion reconciliation: ${errorText(error)}`);
110
- }
105
+ notices.unshift(...reconcileLegacy(this.ctx, this.reporter));
111
106
  deliver(this.pi, this.ctx, notices);
107
+ this.reporter.recover("completion-scan");
112
108
  }
113
109
  catch (error) {
114
110
  reconciled = false;
115
- console.error(`Agent completion reconciliation: ${errorText(error)}`);
111
+ this.reporter.report("completion-scan", `Agent completion reconciliation: ${errorText(error)}`);
116
112
  }
117
113
  // Newly awaited files were absent when read, or are rechecked once by the probe.
118
114
  this.observed = new Map(inputs().map((file) => [file, before.get(file) ?? ""]));
119
- const next = this.watch();
115
+ const next = this.watches.update(this.base, legacyInput(), this.awaited);
120
116
  if (!reconciled || next === "poll") {
121
117
  this.timer = setTimeout(this.scan, 200).unref();
122
118
  }
@@ -125,45 +121,17 @@ class AgentRuntime {
125
121
  this.wake();
126
122
  }
127
123
  };
128
- /** Watches every directory that can publish an awaited input; legacy results may never exist. */
129
- watch() {
130
- const legacy = legacyInput();
131
- const directories = [
132
- ...new Set([this.base, ...legacy, ...[...this.awaited].map((file) => path.dirname(file))]),
133
- ];
134
- for (const [directory, watch] of this.watches) {
135
- if (!directories.includes(directory)) {
136
- watch.close();
137
- this.watches.delete(directory);
138
- }
139
- }
140
- const states = directories.map((directory) => {
141
- let watch = this.watches.get(directory);
142
- if (watch === undefined) {
143
- const every = directory === this.base || legacy.includes(directory);
144
- watch = new DirectoryWatch(directory, (file) => every || file === null || this.awaited.has(path.join(directory, file)), this.wake);
145
- this.watches.set(directory, watch);
146
- }
147
- return [directory, watch.ensure()];
148
- });
149
- if (states.some(([directory, state]) => state === "unwatched" && !legacy.includes(directory))) {
150
- return "poll";
151
- }
152
- return states.some(([, state]) => state === "armed") ? "rescan" : "hints";
153
- }
154
124
  unwatch() {
155
125
  clearTimeout(this.timer);
156
126
  clearInterval(this.probe);
157
127
  this.observed.clear();
158
128
  clearImmediate(this.woken);
159
129
  this.woken = undefined;
160
- for (const watch of this.watches.values()) {
161
- watch.close();
162
- }
163
- this.watches.clear();
130
+ this.watches.close();
164
131
  this.awaited = new Set();
165
132
  }
166
133
  start(context) {
134
+ this.reporter.start();
167
135
  this.host = managedHost(context);
168
136
  if (this.host === undefined || this.accepted || this.child) {
169
137
  this.reset = this.reset.then(() => this.attach(context));
@@ -182,7 +150,7 @@ class AgentRuntime {
182
150
  }
183
151
  })
184
152
  .catch((error) => {
185
- console.error(`Agent runtime attachment: ${errorText(error)}`);
153
+ this.reporter.report(undefined, `Agent runtime attachment: ${errorText(error)}`);
186
154
  });
187
155
  return undefined;
188
156
  }
@@ -212,6 +180,7 @@ class AgentRuntime {
212
180
  this.probe = setInterval(this.recover, 1000).unref();
213
181
  }
214
182
  async dispose(retain) {
183
+ this.reporter.shutdown();
215
184
  this.closing = true;
216
185
  this.unwatch();
217
186
  this.shutdown.abort();
@@ -225,7 +194,7 @@ class AgentRuntime {
225
194
  generation: attachment.generation,
226
195
  attached: false,
227
196
  });
228
- await stopChildren(this.base, this.owner);
197
+ await stopChildren(this.base, this.owner, (message) => this.reporter.report(undefined, message));
229
198
  }
230
199
  }
231
200
  this.ctx = undefined;
@@ -253,7 +222,7 @@ class AgentRuntime {
253
222
  });
254
223
  }
255
224
  catch (error) {
256
- console.error(`Agent stop escalation: ${errorText(error)}`);
225
+ this.reporter.report(undefined, `Agent stop escalation ${launch.id}: ${errorText(error)}`);
257
226
  }
258
227
  }, 3000);
259
228
  escalation.unref();
@@ -1,12 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { errorText } from "./host.js";
3
4
  import { journalStamp } from "../shared/native-journal.js";
4
5
  import { receiptId } from "./receipts.js";
5
6
  import { resolveTempRootDir } from "../shared/types.js";
6
7
  import { isRecord } from "../shared/unknown.js";
7
8
  import { release, isHeld } from "./notifications.js";
8
9
  import { missing } from "./storage.js";
9
- import { errorText } from "./host.js";
10
10
  // Retain only foreign classifications, never their saved output, for each live native manager.
11
11
  const ignoredInputs = new WeakMap();
12
12
  function fileStamp(file) {
@@ -91,16 +91,24 @@ function receipt(body, input) {
91
91
  }
92
92
  return body.type === "message" && legacyWait(body.message, input);
93
93
  }
94
+ /** Where Pi recorded this input's receipt: its session file, or memory under `--no-session`. */
94
95
  function published(ctx, input) {
95
- return (receiptId(ctx.sessionManager.getSessionFile(), input.owner, (body) => receipt(body, input)) !==
96
- undefined);
96
+ const file = ctx.sessionManager.getSessionFile();
97
+ const matches = (body) => receipt(body, input);
98
+ if (file === undefined) {
99
+ return ctx.sessionManager.getEntries().some(matches) ? "memory" : undefined;
100
+ }
101
+ return receiptId(file, input.owner, matches) === undefined ? undefined : "file";
97
102
  }
98
103
  function notification(ctx, input) {
99
104
  const identity = `legacy:${input.id}:${input.key}`;
100
- if (published(ctx, input)) {
105
+ const where = published(ctx, input);
106
+ if (where !== undefined) {
101
107
  release(input.owner, identity);
102
- // Retire only the unchanged recovery input whose actual native receipt exists.
103
- if (journalStamp(fs.lstatSync(input.file, { bigint: true })) === input.stamp &&
108
+ // Retire only an unchanged recovery input whose receipt is LF-published in the session file.
109
+ // An in-memory receipt stops redelivery but never removes the saved input.
110
+ if (where === "file" &&
111
+ journalStamp(fs.lstatSync(input.file, { bigint: true })) === input.stamp &&
104
112
  fs.readFileSync(input.file, "utf8") === input.raw) {
105
113
  fs.unlinkSync(input.file);
106
114
  }
@@ -121,13 +129,15 @@ function notification(ctx, input) {
121
129
  },
122
130
  };
123
131
  }
124
- function legacyNotice(ctx, file, scan) {
132
+ function legacyNotice(ctx, file, scan, report) {
125
133
  try {
126
134
  const input = legacyInput(file, scan);
127
- return input ? notification(ctx, input) : undefined;
135
+ const result = input ? notification(ctx, input) : undefined;
136
+ report(file);
137
+ return result;
128
138
  }
129
139
  catch (error) {
130
- console.error(`Skipped legacy completion ${file}: ${errorText(error)}`);
140
+ report(file, error);
131
141
  return;
132
142
  }
133
143
  }
@@ -149,7 +159,16 @@ function startScan(ctx, directory) {
149
159
  export function legacyDirectory() {
150
160
  return path.join(resolveTempRootDir(), "async-subagent-results");
151
161
  }
152
- export function legacyNotifications(ctx) {
162
+ export function legacyNotifications(ctx, reporter) {
163
+ const report = (file, error) => {
164
+ const owner = `legacy:${file}`;
165
+ if (error === undefined) {
166
+ reporter.recover(owner);
167
+ }
168
+ else {
169
+ reporter.report(owner, `Skipped legacy completion ${file}: ${errorText(error)}`);
170
+ }
171
+ };
153
172
  const notices = [];
154
173
  const directory = legacyDirectory();
155
174
  const root = path.dirname(directory);
@@ -174,10 +193,21 @@ export function legacyNotifications(ctx) {
174
193
  if (!name.endsWith(".json")) {
175
194
  continue;
176
195
  }
177
- const notice = legacyNotice(ctx, path.join(directory, name), scan);
196
+ const notice = legacyNotice(ctx, path.join(directory, name), scan, report);
178
197
  if (notice) {
179
198
  notices.push(notice);
180
199
  }
181
200
  }
182
201
  return notices;
183
202
  }
203
+ export function reconcileLegacy(ctx, reporter) {
204
+ try {
205
+ const notices = legacyNotifications(ctx, reporter);
206
+ reporter.recover("legacy-scan");
207
+ return notices;
208
+ }
209
+ catch (error) {
210
+ reporter.report("legacy-scan", `Legacy completion reconciliation: ${errorText(error)}`);
211
+ return [];
212
+ }
213
+ }
@@ -51,7 +51,7 @@ export function settle(owner, pending) {
51
51
  return released;
52
52
  }
53
53
  export function published(ctx, launch) {
54
- return completionReceipts(ctx.sessionManager.getSessionFile(), launch.owner).get(launch.id);
54
+ return completionReceipts(ctx.sessionManager, launch.owner).get(launch.id);
55
55
  }
56
56
  export function deliver(pi, ctx, notices) {
57
57
  const idle = ctx.isIdle();
@@ -194,7 +194,7 @@ export function notifications(ctx, base, owner) {
194
194
  const notices = [];
195
195
  const awaited = new Set();
196
196
  const runs = completed(base, owner, awaited);
197
- const receipts = runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager.getSessionFile(), owner);
197
+ const receipts = runs.length === 0 ? undefined : completionReceipts(ctx.sessionManager, owner);
198
198
  for (const run of runs) {
199
199
  const key = `${owner}/${run.id}`;
200
200
  if (receipts?.get(run.id) !== undefined) {
@@ -1,11 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { parkedSchema } from "./contracts.js";
4
+ import { exitSchema, parkedSchema, readySchema, wakeFailureSchema, } from "./contracts.js";
5
5
  import { errorText, loadSDK } from "./host.js";
6
6
  import { conversation, latest, launchAt, list, read, runDirectory, write } from "./storage.js";
7
- import { alive, shell, shells, wakeActor } from "./process.js";
8
- import { isHistoryRequest, PARK, PARKED_HISTORY, WAKE, } from "../shared/parking.js";
7
+ import { actor as claimedActor, alive, shell, shells, wakeActor } from "./process.js";
8
+ import { DirectoryWatch, Wake } from "./wake.js";
9
+ import { isHistoryRequest, isWakeRequest, PARK, PARKED_HISTORY, WAKE, } from "../shared/parking.js";
9
10
  import { nativePeerId } from "../pi-intercom/session-targets.js";
10
11
  function parkable(session) {
11
12
  return typeof Reflect.get(session, "acquireWorkingSession") === "function";
@@ -85,6 +86,8 @@ export function parkedState(dir, launch, restore) {
85
86
  /** One actor generation's idle parking and exact restore; commands stay with the runner. */
86
87
  export class Parking {
87
88
  saved;
89
+ /** Set before entering either native open, whose effects may outlive an exception. */
90
+ opened = false;
88
91
  restored;
89
92
  since = 0;
90
93
  leaf;
@@ -110,6 +113,7 @@ export class Parking {
110
113
  const { sdk, launch } = this;
111
114
  const { openWorkingSession: restore } = sdk;
112
115
  const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
116
+ this.opened = true;
113
117
  if (!parked || !restore) {
114
118
  return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
115
119
  }
@@ -248,22 +252,126 @@ async function history(runs, peer) {
248
252
  const state = runs && launch && parkedState(conversation(runs.base, launch), launch, restore)?.saved;
249
253
  return state && { getBranch: () => branchOf(state) };
250
254
  }
255
+ function unavailableCwd(cwd) {
256
+ try {
257
+ if (!fs.statSync(cwd).isDirectory()) {
258
+ return `Saved working directory ${cwd} is unavailable: not a directory.`;
259
+ }
260
+ fs.accessSync(cwd, fs.constants.X_OK);
261
+ }
262
+ catch (error) {
263
+ return `Saved working directory ${cwd} is unavailable: ${errorText(error)}.`;
264
+ }
265
+ return undefined;
266
+ }
267
+ function exitedWake(dir, identity, legacy) {
268
+ const exit = read(path.join(dir, `exit-${identity.token}.json`), exitSchema);
269
+ if (!exit && alive(identity)) {
270
+ return undefined;
271
+ }
272
+ const failure = read(path.join(dir, `wake-failure-${identity.token}.json`), wakeFailureSchema);
273
+ return (failure?.reason ??
274
+ `Actor exited before ${legacy ? "readiness" : "intercom registration"} (code ${exit?.code ?? "unknown"}, signal ${exit?.signal ?? "unknown"}). See ${path.join(dir, "runner.log")}.`);
275
+ }
276
+ /** A spawned actor is only requested until its own readiness or exit is observed. */
277
+ async function awaitWake(base, launch, signal, legacy) {
278
+ const dir = conversation(base, launch);
279
+ const identity = claimedActor(base, launch);
280
+ if (!identity) {
281
+ throw new Error("The wake returned without an observable actor claim.");
282
+ }
283
+ const wake = new Wake();
284
+ const watch = new DirectoryWatch(dir, () => true, wake.raise);
285
+ try {
286
+ for (;;) {
287
+ signal.throwIfAborted();
288
+ wake.take();
289
+ watch.ensure();
290
+ if (legacy &&
291
+ read(path.join(dir, "ready.json"), readySchema)?.token === identity.token &&
292
+ !fs.existsSync(path.join(dir, "parked.json"))) {
293
+ return;
294
+ }
295
+ const failure = exitedWake(dir, identity, legacy);
296
+ if (failure !== undefined) {
297
+ throw new Error(failure);
298
+ }
299
+ // DirectoryWatch reconciles dropped hints without imposing a deadline on native startup.
300
+ // oxlint-disable-next-line no-await-in-loop
301
+ await wake.sleep(200);
302
+ }
303
+ }
304
+ finally {
305
+ watch.close();
306
+ }
307
+ }
308
+ function wakeFailed(current, launch, error) {
309
+ let reason = errorText(error);
310
+ const unavailable = launch && unavailableCwd(launch.cwd);
311
+ if (unavailable !== undefined) {
312
+ reason += ` ${unavailable}`;
313
+ }
314
+ if (current && launch) {
315
+ try {
316
+ write(path.join(conversation(current.base, launch), "wake-failure.json"), {
317
+ at: Date.now(),
318
+ reason,
319
+ });
320
+ }
321
+ catch (recordError) {
322
+ reason += ` Wake failure could not be recorded: ${errorText(recordError)}.`;
323
+ }
324
+ }
325
+ return { status: "failed", reason };
326
+ }
251
327
  /**
252
328
  * Wakes this owner's parked children for broker input and reads their saved history.
253
329
  * `runs` is undefined while this session owns no children.
254
330
  */
255
331
  export function serveParked(pi, runs, signal) {
256
- pi.events.on(WAKE, (peer) => {
257
- // On failure, retained input stays queued at the broker until the next main registration.
258
- (async () => {
259
- const current = runs();
260
- const launch = ownedRun(current, peer);
261
- if (current && launch) {
262
- await wakeActor(current.base, launch, signal);
332
+ const pending = new Map();
333
+ const attempt = async (peer, registered) => {
334
+ let current;
335
+ let launch;
336
+ const cancelled = registered ? AbortSignal.any([signal, registered]) : signal;
337
+ try {
338
+ current = runs();
339
+ launch = ownedRun(current, peer);
340
+ if (!current || !launch) {
341
+ throw new Error("No active agent runtime owns this parked child.");
263
342
  }
264
- })().catch((error) => {
265
- console.error(`Agent wake: ${errorText(error)}`);
266
- });
343
+ await wakeActor(current.base, launch, cancelled);
344
+ await awaitWake(current.base, launch, cancelled, registered === undefined);
345
+ return { status: "requested" };
346
+ }
347
+ catch (error) {
348
+ if (registered?.aborted === true) {
349
+ return { status: "requested" };
350
+ }
351
+ return wakeFailed(current, launch, error);
352
+ }
353
+ };
354
+ pi.events.on(WAKE, (request) => {
355
+ const peer = isWakeRequest(request) ? request.peer : request;
356
+ if (typeof peer !== "string") {
357
+ return;
358
+ }
359
+ // Serialize wake requests; a successful in-flight wake already serves queued requests.
360
+ const previous = pending.get(peer);
361
+ const registered = isWakeRequest(request) ? request.signal : undefined;
362
+ const result = previous
363
+ ? previous.then((outcome) => outcome.status === "requested" ? outcome : attempt(peer, registered))
364
+ : attempt(peer, registered);
365
+ pending.set(peer, result);
366
+ const clear = () => {
367
+ if (pending.get(peer) === result) {
368
+ pending.delete(peer);
369
+ }
370
+ };
371
+ void result.then(clear, clear);
372
+ if (isWakeRequest(request)) {
373
+ request.reply(result);
374
+ }
267
375
  });
268
376
  pi.events.on(PARKED_HISTORY, (request) => {
269
377
  if (isHistoryRequest(request)) {