@fitchmultz/pi-subagents 0.52.4 → 0.52.5
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 +11 -0
- package/README.md +5 -4
- package/dist/agent/completion-watches.js +61 -0
- package/dist/agent/contracts.js +9 -0
- package/dist/agent/host.js +5 -0
- package/dist/agent/index.js +20 -51
- package/dist/agent/legacy.js +41 -11
- package/dist/agent/notifications.js +2 -2
- package/dist/agent/parking.js +121 -13
- package/dist/agent/process.js +107 -63
- package/dist/agent/receipts.js +21 -4
- package/dist/agent/runner.js +19 -5
- package/dist/agent/shutdown.js +43 -0
- package/dist/agent/view.js +5 -1
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +7 -6
- package/dist/pi-intercom/broker/client-wake.d.ts +17 -0
- package/dist/pi-intercom/broker/client-wake.js +54 -0
- package/dist/pi-intercom/broker/client.d.ts +2 -0
- package/dist/pi-intercom/broker/client.js +23 -16
- package/dist/pi-intercom/broker/parked.js +122 -5
- package/dist/pi-intercom/broker/spawn.js +10 -6
- package/dist/pi-intercom/config.js +3 -2
- package/dist/pi-intercom/connection.js +72 -11
- package/dist/pi-intercom/inbound-delivery.js +10 -5
- package/dist/pi-intercom/index.js +41 -6
- package/dist/pi-intercom/outbound.js +24 -7
- package/dist/pi-intercom/reply-wait.js +6 -1
- package/dist/pi-intercom/tool-results.js +2 -0
- package/dist/pi-intercom/types.d.ts +6 -0
- package/dist/shared/background-reporting.js +72 -0
- package/dist/shared/parking.js +6 -0
- package/docs/development.md +2 -0
- package/docs/intercom.md +14 -0
- package/docs/performance.md +2 -2
- package/docs/reference.md +13 -5
- package/package.json +1 -1
- package/src/agent/completion-watches.ts +73 -0
- package/src/agent/contracts.ts +11 -0
- package/src/agent/host.ts +6 -0
- package/src/agent/index.ts +24 -54
- package/src/agent/legacy.ts +44 -12
- package/src/agent/notifications.ts +2 -3
- package/src/agent/parking.ts +148 -12
- package/src/agent/process.ts +130 -66
- package/src/agent/receipts.ts +25 -4
- package/src/agent/runner.ts +25 -5
- package/src/agent/shutdown.ts +50 -0
- package/src/agent/view.ts +9 -0
- package/src/pi-intercom/broker/broker.ts +8 -7
- package/src/pi-intercom/broker/client-wake.ts +61 -0
- package/src/pi-intercom/broker/client.ts +23 -17
- package/src/pi-intercom/broker/parked.ts +152 -10
- package/src/pi-intercom/broker/spawn.ts +10 -9
- package/src/pi-intercom/config.ts +5 -2
- package/src/pi-intercom/connection.ts +88 -12
- package/src/pi-intercom/inbound-delivery.ts +21 -6
- package/src/pi-intercom/index.ts +64 -21
- package/src/pi-intercom/outbound.ts +30 -6
- package/src/pi-intercom/reply-wait.ts +6 -1
- package/src/pi-intercom/tool-results.ts +1 -0
- package/src/pi-intercom/types.ts +4 -0
- package/src/shared/background-reporting.ts +78 -0
- package/src/shared/parking.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.52.5] - 2026-10-31
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- Keep the `@fitchmultz/pi-subagents/intercom-client` type declarations independent of Pi so standalone Node consumers can type-check without installing the Pi SDK.
|
|
15
|
+
|
|
5
16
|
## [0.52.4] - 2026-10-29
|
|
6
17
|
|
|
7
18
|
### 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
|
|
132
|
-
|
|
133
|
-
the prebuilt npm package does not need a
|
|
134
|
-
[
|
|
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
|
+
}
|
package/dist/agent/contracts.js
CHANGED
|
@@ -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()]),
|
package/dist/agent/host.js
CHANGED
|
@@ -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)) {
|
package/dist/agent/index.js
CHANGED
|
@@ -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,
|
|
6
|
-
import {
|
|
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,
|
|
15
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
+
this.reporter.report(undefined, `Agent stop escalation ${launch.id}: ${errorText(error)}`);
|
|
257
226
|
}
|
|
258
227
|
}, 3000);
|
|
259
228
|
escalation.unref();
|
package/dist/agent/legacy.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
105
|
+
const where = published(ctx, input);
|
|
106
|
+
if (where !== undefined) {
|
|
101
107
|
release(input.owner, identity);
|
|
102
|
-
// Retire only
|
|
103
|
-
|
|
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
|
-
|
|
135
|
+
const result = input ? notification(ctx, input) : undefined;
|
|
136
|
+
report(file);
|
|
137
|
+
return result;
|
|
128
138
|
}
|
|
129
139
|
catch (error) {
|
|
130
|
-
|
|
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
|
|
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
|
|
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) {
|
package/dist/agent/parking.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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)) {
|