@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.
- package/CHANGELOG.md +29 -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 +15 -3
- package/docs/intercom.md +14 -0
- package/docs/performance.md +2 -2
- package/docs/reference.md +13 -5
- package/package.json +1 -1
- package/scripts/package-smoke.mjs +21 -3
- 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/src/agent/parking.ts
CHANGED
|
@@ -2,16 +2,26 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentSession, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
exitSchema,
|
|
7
|
+
parkedSchema,
|
|
8
|
+
readySchema,
|
|
9
|
+
wakeFailureSchema,
|
|
10
|
+
type Launch,
|
|
11
|
+
type Parked,
|
|
12
|
+
} from "./contracts.ts";
|
|
6
13
|
import { errorText, loadSDK, type WorkingState } from "./host.ts";
|
|
7
14
|
import { conversation, latest, launchAt, list, read, runDirectory, write } from "./storage.ts";
|
|
8
|
-
import { alive, shell, shells, wakeActor } from "./process.ts";
|
|
15
|
+
import { actor as claimedActor, alive, shell, shells, wakeActor } from "./process.ts";
|
|
16
|
+
import { DirectoryWatch, Wake } from "./wake.ts";
|
|
9
17
|
import {
|
|
10
18
|
isHistoryRequest,
|
|
19
|
+
isWakeRequest,
|
|
11
20
|
PARK,
|
|
12
21
|
PARKED_HISTORY,
|
|
13
22
|
WAKE,
|
|
14
23
|
type ParkRequest,
|
|
24
|
+
type WakeResult,
|
|
15
25
|
} from "../shared/parking.ts";
|
|
16
26
|
import { nativePeerId } from "../pi-intercom/session-targets.ts";
|
|
17
27
|
import type { NativeHistory } from "../pi-intercom/last-message.ts";
|
|
@@ -147,6 +157,8 @@ export function parkedState(
|
|
|
147
157
|
/** One actor generation's idle parking and exact restore; commands stay with the runner. */
|
|
148
158
|
export class Parking {
|
|
149
159
|
saved?: WorkingState;
|
|
160
|
+
/** Set before entering either native open, whose effects may outlive an exception. */
|
|
161
|
+
opened = false;
|
|
150
162
|
private restored?: string;
|
|
151
163
|
private since = 0;
|
|
152
164
|
private leaf?: string | null;
|
|
@@ -172,6 +184,7 @@ export class Parking {
|
|
|
172
184
|
const { sdk, launch } = this;
|
|
173
185
|
const { openWorkingSession: restore } = sdk;
|
|
174
186
|
const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
|
|
187
|
+
this.opened = true;
|
|
175
188
|
if (!parked || !restore) {
|
|
176
189
|
return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
|
|
177
190
|
}
|
|
@@ -328,6 +341,96 @@ async function history(runs: Runs | undefined, peer: string): Promise<NativeHist
|
|
|
328
341
|
return state && { getBranch: () => branchOf(state) };
|
|
329
342
|
}
|
|
330
343
|
|
|
344
|
+
function unavailableCwd(cwd: string): string | undefined {
|
|
345
|
+
try {
|
|
346
|
+
if (!fs.statSync(cwd).isDirectory()) {
|
|
347
|
+
return `Saved working directory ${cwd} is unavailable: not a directory.`;
|
|
348
|
+
}
|
|
349
|
+
fs.accessSync(cwd, fs.constants.X_OK);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
return `Saved working directory ${cwd} is unavailable: ${errorText(error)}.`;
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function exitedWake(
|
|
357
|
+
dir: string,
|
|
358
|
+
identity: NonNullable<ReturnType<typeof claimedActor>>,
|
|
359
|
+
legacy: boolean,
|
|
360
|
+
): string | undefined {
|
|
361
|
+
const exit = read(path.join(dir, `exit-${identity.token}.json`), exitSchema);
|
|
362
|
+
if (!exit && alive(identity)) {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
const failure = read(path.join(dir, `wake-failure-${identity.token}.json`), wakeFailureSchema);
|
|
366
|
+
return (
|
|
367
|
+
failure?.reason ??
|
|
368
|
+
`Actor exited before ${legacy ? "readiness" : "intercom registration"} (code ${exit?.code ?? "unknown"}, signal ${exit?.signal ?? "unknown"}). See ${path.join(dir, "runner.log")}.`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A spawned actor is only requested until its own readiness or exit is observed. */
|
|
373
|
+
async function awaitWake(
|
|
374
|
+
base: string,
|
|
375
|
+
launch: Launch,
|
|
376
|
+
signal: AbortSignal,
|
|
377
|
+
legacy: boolean,
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
const dir = conversation(base, launch);
|
|
380
|
+
const identity = claimedActor(base, launch);
|
|
381
|
+
if (!identity) {
|
|
382
|
+
throw new Error("The wake returned without an observable actor claim.");
|
|
383
|
+
}
|
|
384
|
+
const wake = new Wake();
|
|
385
|
+
const watch = new DirectoryWatch(dir, () => true, wake.raise);
|
|
386
|
+
try {
|
|
387
|
+
for (;;) {
|
|
388
|
+
signal.throwIfAborted();
|
|
389
|
+
wake.take();
|
|
390
|
+
watch.ensure();
|
|
391
|
+
if (
|
|
392
|
+
legacy &&
|
|
393
|
+
read(path.join(dir, "ready.json"), readySchema)?.token === identity.token &&
|
|
394
|
+
!fs.existsSync(path.join(dir, "parked.json"))
|
|
395
|
+
) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const failure = exitedWake(dir, identity, legacy);
|
|
399
|
+
if (failure !== undefined) {
|
|
400
|
+
throw new Error(failure);
|
|
401
|
+
}
|
|
402
|
+
// DirectoryWatch reconciles dropped hints without imposing a deadline on native startup.
|
|
403
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
404
|
+
await wake.sleep(200);
|
|
405
|
+
}
|
|
406
|
+
} finally {
|
|
407
|
+
watch.close();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function wakeFailed(
|
|
412
|
+
current: Runs | undefined,
|
|
413
|
+
launch: Launch | undefined,
|
|
414
|
+
error: unknown,
|
|
415
|
+
): WakeResult {
|
|
416
|
+
let reason = errorText(error);
|
|
417
|
+
const unavailable = launch && unavailableCwd(launch.cwd);
|
|
418
|
+
if (unavailable !== undefined) {
|
|
419
|
+
reason += ` ${unavailable}`;
|
|
420
|
+
}
|
|
421
|
+
if (current && launch) {
|
|
422
|
+
try {
|
|
423
|
+
write(path.join(conversation(current.base, launch), "wake-failure.json"), {
|
|
424
|
+
at: Date.now(),
|
|
425
|
+
reason,
|
|
426
|
+
});
|
|
427
|
+
} catch (recordError) {
|
|
428
|
+
reason += ` Wake failure could not be recorded: ${errorText(recordError)}.`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return { status: "failed", reason };
|
|
432
|
+
}
|
|
433
|
+
|
|
331
434
|
/**
|
|
332
435
|
* Wakes this owner's parked children for broker input and reads their saved history.
|
|
333
436
|
* `runs` is undefined while this session owns no children.
|
|
@@ -337,17 +440,50 @@ export function serveParked(
|
|
|
337
440
|
runs: () => Runs | undefined,
|
|
338
441
|
signal: AbortSignal,
|
|
339
442
|
): void {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
443
|
+
const pending = new Map<string, Promise<WakeResult>>();
|
|
444
|
+
const attempt = async (peer: string, registered?: AbortSignal): Promise<WakeResult> => {
|
|
445
|
+
let current: Runs | undefined;
|
|
446
|
+
let launch: Launch | undefined;
|
|
447
|
+
const cancelled = registered ? AbortSignal.any([signal, registered]) : signal;
|
|
448
|
+
try {
|
|
449
|
+
current = runs();
|
|
450
|
+
launch = ownedRun(current, peer);
|
|
451
|
+
if (!current || !launch) {
|
|
452
|
+
throw new Error("No active agent runtime owns this parked child.");
|
|
347
453
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
454
|
+
await wakeActor(current.base, launch, cancelled);
|
|
455
|
+
await awaitWake(current.base, launch, cancelled, registered === undefined);
|
|
456
|
+
return { status: "requested" };
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (registered?.aborted === true) {
|
|
459
|
+
return { status: "requested" };
|
|
460
|
+
}
|
|
461
|
+
return wakeFailed(current, launch, error);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
pi.events.on(WAKE, (request) => {
|
|
465
|
+
const peer = isWakeRequest(request) ? request.peer : request;
|
|
466
|
+
if (typeof peer !== "string") {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// Serialize wake requests; a successful in-flight wake already serves queued requests.
|
|
470
|
+
const previous = pending.get(peer);
|
|
471
|
+
const registered = isWakeRequest(request) ? request.signal : undefined;
|
|
472
|
+
const result = previous
|
|
473
|
+
? previous.then((outcome) =>
|
|
474
|
+
outcome.status === "requested" ? outcome : attempt(peer, registered),
|
|
475
|
+
)
|
|
476
|
+
: attempt(peer, registered);
|
|
477
|
+
pending.set(peer, result);
|
|
478
|
+
const clear = () => {
|
|
479
|
+
if (pending.get(peer) === result) {
|
|
480
|
+
pending.delete(peer);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
void result.then(clear, clear);
|
|
484
|
+
if (isWakeRequest(request)) {
|
|
485
|
+
request.reply(result);
|
|
486
|
+
}
|
|
351
487
|
});
|
|
352
488
|
pi.events.on(PARKED_HISTORY, (request) => {
|
|
353
489
|
if (isHistoryRequest(request)) {
|
package/src/agent/process.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
3
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -17,10 +17,11 @@ import {
|
|
|
17
17
|
attachmentSchema,
|
|
18
18
|
parkedSchema,
|
|
19
19
|
processSchema,
|
|
20
|
+
wakeFailureSchema,
|
|
20
21
|
type Launch,
|
|
21
22
|
type ProcessIdentity,
|
|
22
23
|
} from "./contracts.ts";
|
|
23
|
-
import { conversation, enqueue,
|
|
24
|
+
import { conversation, enqueue, read, write } from "./storage.ts";
|
|
24
25
|
import { errorText } from "./host.ts";
|
|
25
26
|
|
|
26
27
|
/** Darwin's in-memory publication bound; Android records carry their own native clock. */
|
|
@@ -149,34 +150,74 @@ function childEnvironment(launch: Launch): NodeJS.ProcessEnv {
|
|
|
149
150
|
PI_INTERCOM_PARENT_SESSION_ID: launch.owner,
|
|
150
151
|
});
|
|
151
152
|
}
|
|
152
|
-
|
|
153
|
+
interface LaunchClaim {
|
|
154
|
+
readonly spawned: () => void;
|
|
155
|
+
readonly release: () => void;
|
|
156
|
+
}
|
|
157
|
+
function saveSpawnFailure(dir: string, error: Error): Error {
|
|
158
|
+
try {
|
|
159
|
+
write(path.join(dir, "spawn-error.json"), { error: error.message });
|
|
160
|
+
return error;
|
|
161
|
+
} catch (failure) {
|
|
162
|
+
return Object.assign(
|
|
163
|
+
new Error(`${error.message}; could not save the spawn diagnostic: ${errorText(failure)}`, {
|
|
164
|
+
cause: error,
|
|
165
|
+
}),
|
|
166
|
+
"code" in error ? { code: error.code } : {},
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function spawnActor(
|
|
171
|
+
base: string,
|
|
172
|
+
launch: Launch,
|
|
173
|
+
token: string,
|
|
174
|
+
claim: LaunchClaim,
|
|
175
|
+
): Promise<void> {
|
|
153
176
|
const dir = conversation(base, launch);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
new URL(import.meta.url.endsWith(".ts") ? "./runner.ts" : "./runner.js", import.meta.url),
|
|
157
|
-
);
|
|
158
|
-
let child;
|
|
177
|
+
let child: ChildProcess;
|
|
178
|
+
let spawnError: Promise<Error> | undefined;
|
|
159
179
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
180
|
+
const log = fs.openSync(path.join(dir, "runner.log"), "a", 0o600);
|
|
181
|
+
try {
|
|
182
|
+
const runner = fileURLToPath(
|
|
183
|
+
new URL(import.meta.url.endsWith(".ts") ? "./runner.ts" : "./runner.js", import.meta.url),
|
|
184
|
+
);
|
|
185
|
+
child = spawn(process.execPath, [runner, base, launch.rootId, token, launch.id], {
|
|
186
|
+
cwd: launch.cwd,
|
|
187
|
+
env: childEnvironment(launch),
|
|
188
|
+
detached: true,
|
|
189
|
+
stdio: ["ignore", log, log],
|
|
190
|
+
});
|
|
191
|
+
if (child.pid === undefined) {
|
|
192
|
+
spawnError = new Promise((resolve) => {
|
|
193
|
+
child.once("error", (error) => resolve(saveSpawnFailure(dir, error)));
|
|
194
|
+
});
|
|
195
|
+
claim.release();
|
|
196
|
+
} else {
|
|
197
|
+
claim.spawned();
|
|
198
|
+
child.on("error", (error) => {
|
|
199
|
+
write(path.join(dir, "spawn-error.json"), { error: error.message });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
fs.closeSync(log);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
claim.release();
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
if (child.pid === undefined && spawnError) {
|
|
210
|
+
throw await spawnError;
|
|
211
|
+
}
|
|
212
|
+
if (child.pid === undefined) {
|
|
213
|
+
claim.spawned();
|
|
214
|
+
throw new Error("Actor spawn outcome is ambiguous.");
|
|
168
215
|
}
|
|
169
|
-
child.on("error", (error) => {
|
|
170
|
-
write(path.join(dir, "spawn-error.json"), { error: error.message });
|
|
171
|
-
});
|
|
172
216
|
child.on("exit", (code, signal) => {
|
|
173
217
|
write(path.join(dir, `exit-${token}.json`), { pid: child.pid, code, signal, at: Date.now() });
|
|
174
218
|
trySignalChildTree(child, "SIGKILL");
|
|
175
219
|
});
|
|
176
220
|
child.unref();
|
|
177
|
-
if (child.pid === undefined) {
|
|
178
|
-
throw new Error("Actor was not spawned; retained launch claim requires inspection.");
|
|
179
|
-
}
|
|
180
221
|
write(path.join(dir, "actor.lock", "process.json"), {
|
|
181
222
|
pid: child.pid,
|
|
182
223
|
identity: readChildProcessIdentity(child.pid) ?? "unverified",
|
|
@@ -205,6 +246,50 @@ async function awaitStopping(
|
|
|
205
246
|
}
|
|
206
247
|
}
|
|
207
248
|
}
|
|
249
|
+
function releaseClaim(
|
|
250
|
+
lock: string,
|
|
251
|
+
identity: fs.Stats | undefined,
|
|
252
|
+
token: string | undefined,
|
|
253
|
+
): void {
|
|
254
|
+
try {
|
|
255
|
+
const current = fs.lstatSync(lock, { throwIfNoEntry: false });
|
|
256
|
+
if (current && identity && current.dev === identity.dev && current.ino === identity.ino) {
|
|
257
|
+
const file = path.join(lock, "claim.json");
|
|
258
|
+
const claim = read(file, processSchema);
|
|
259
|
+
if (claim === undefined || claim.token === token) {
|
|
260
|
+
fs.rmSync(file, { force: true });
|
|
261
|
+
fs.rmdirSync(lock);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// Keep the original failure; an unremovable claim remains safely ambiguous.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function launchClaimedActor(base: string, launch: Launch, lock: string): Promise<void> {
|
|
269
|
+
let identity: fs.Stats | undefined;
|
|
270
|
+
let token: string | undefined;
|
|
271
|
+
let state: "claimed" | "spawned" | "released" = "claimed";
|
|
272
|
+
const claim: LaunchClaim = {
|
|
273
|
+
spawned: () => {
|
|
274
|
+
state = "spawned";
|
|
275
|
+
},
|
|
276
|
+
release: () => {
|
|
277
|
+
if (state === "claimed") {
|
|
278
|
+
state = "released";
|
|
279
|
+
releaseClaim(lock, identity, token);
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
identity = fs.lstatSync(lock);
|
|
285
|
+
token = randomUUID();
|
|
286
|
+
write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
|
|
287
|
+
await spawnActor(base, launch, token, claim);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
claim.release();
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
208
293
|
export async function ensureActor(
|
|
209
294
|
base: string,
|
|
210
295
|
launch: Launch,
|
|
@@ -237,16 +322,34 @@ export async function ensureActor(
|
|
|
237
322
|
}
|
|
238
323
|
throw error;
|
|
239
324
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
325
|
+
await launchClaimedActor(base, launch, lock);
|
|
326
|
+
}
|
|
327
|
+
/** A failed restore can hand the old cut forward only before native open and after death. */
|
|
328
|
+
function failedBeforeOpenToken(
|
|
329
|
+
dir: string,
|
|
330
|
+
exited: OwnedProcess,
|
|
331
|
+
current: OwnedProcess | undefined,
|
|
332
|
+
): string | undefined {
|
|
333
|
+
const failure = read(path.join(dir, `wake-failure-${exited.token}.json`), wakeFailureSchema);
|
|
334
|
+
if (
|
|
335
|
+
current?.token === exited.token &&
|
|
336
|
+
!alive(current) &&
|
|
337
|
+
failure?.beforeOpen?.actorToken === exited.token
|
|
338
|
+
) {
|
|
339
|
+
return failure.beforeOpen.parkedToken;
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
243
342
|
}
|
|
244
343
|
/** A failed park keeps its input queued; exact state is lost, so nothing restarts it implicitly. */
|
|
245
344
|
export function parkFailure(base: string, launch: Launch, exited?: OwnedProcess): void {
|
|
246
345
|
const dir = conversation(base, launch);
|
|
247
346
|
const parked = read(path.join(dir, "parked.json"), parkedSchema);
|
|
248
|
-
if (
|
|
249
|
-
|
|
347
|
+
if (
|
|
348
|
+
exited &&
|
|
349
|
+
parked &&
|
|
350
|
+
parked.token !== exited.token &&
|
|
351
|
+
failedBeforeOpenToken(dir, exited, actor(base, launch)) !== parked.token
|
|
352
|
+
) {
|
|
250
353
|
throw new Error(
|
|
251
354
|
"Parked working state belongs to another actor generation; nothing was reopened.",
|
|
252
355
|
);
|
|
@@ -354,45 +457,6 @@ export function requestStop(
|
|
|
354
457
|
});
|
|
355
458
|
return { targets: live, command };
|
|
356
459
|
}
|
|
357
|
-
export async function stopChildren(base: string, owner: string): Promise<void> {
|
|
358
|
-
const roots = new Set<string>();
|
|
359
|
-
const targets: OwnedProcess[] = [];
|
|
360
|
-
for (const id of list(base)) {
|
|
361
|
-
try {
|
|
362
|
-
const launch = launchAt(base, id, owner);
|
|
363
|
-
if (!roots.has(launch.rootId)) {
|
|
364
|
-
roots.add(launch.rootId);
|
|
365
|
-
targets.push(
|
|
366
|
-
...requestStop(base, launch, (error) => {
|
|
367
|
-
console.error(`Child shutdown ${id}: ${errorText(error)}`);
|
|
368
|
-
}).targets,
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
} catch (error) {
|
|
372
|
-
console.error(`Child shutdown ${id}: ${errorText(error)}`);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
await waitForExit(targets, 3000);
|
|
376
|
-
for (const target of targets) {
|
|
377
|
-
try {
|
|
378
|
-
signalProcess(target, "SIGKILL");
|
|
379
|
-
} catch (error) {
|
|
380
|
-
console.error(`Child shutdown ${target.pid}: ${errorText(error)}`);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
await waitForExit(targets, 1000);
|
|
384
|
-
if (targets.some(alive)) {
|
|
385
|
-
console.error("Child shutdown could not confirm every owned process exit.");
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
async function waitForExit(targets: readonly OwnedProcess[], timeout: number): Promise<void> {
|
|
389
|
-
const deadline = Date.now() + timeout;
|
|
390
|
-
while (Date.now() < deadline && targets.some(alive)) {
|
|
391
|
-
// Join actual process exit while native abort persists the interrupted turn.
|
|
392
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
393
|
-
await delay(25);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
460
|
export function shell(file: string, token: string): OwnedProcess | undefined {
|
|
397
461
|
const identity = published(file, read(file, processSchema));
|
|
398
462
|
if (identity && identity.token !== token) {
|
package/src/agent/receipts.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { scanJournal } from "../shared/journal-frames.ts";
|
|
3
4
|
import { journalStamp, nativeAppend } from "../shared/native-journal.ts";
|
|
4
5
|
import type { Projection } from "../shared/json-projection.ts";
|
|
@@ -157,6 +158,24 @@ function owned(file: string | undefined, owner: string): ReceiptIndex | undefine
|
|
|
157
158
|
const header = journal.entries.at(0);
|
|
158
159
|
return header?.type === "session" && header.id === owner ? journal : undefined;
|
|
159
160
|
}
|
|
161
|
+
/** Without a session file (`--no-session`), Pi's append-only memory is the whole native transcript. */
|
|
162
|
+
function remembered(
|
|
163
|
+
manager: ExtensionContext["sessionManager"],
|
|
164
|
+
owner: string,
|
|
165
|
+
): Prefix | undefined {
|
|
166
|
+
const header = manager.getHeader();
|
|
167
|
+
if (header?.id !== owner) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const journal: Prefix = { end: 0, entries: [], completions: new Map() };
|
|
171
|
+
// Index the same record frames the physical journal scan accepts.
|
|
172
|
+
for (const entry of [header, ...manager.getEntries()]) {
|
|
173
|
+
if (isRecord(entry)) {
|
|
174
|
+
addEntry(journal, entry);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return journal;
|
|
178
|
+
}
|
|
160
179
|
export function receiptId(
|
|
161
180
|
file: string | undefined,
|
|
162
181
|
owner: string,
|
|
@@ -167,12 +186,14 @@ export function receiptId(
|
|
|
167
186
|
);
|
|
168
187
|
return typeof found?.id === "string" ? found.id : undefined;
|
|
169
188
|
}
|
|
170
|
-
/**
|
|
171
|
-
* A matching entry without an ID is unpublished.
|
|
189
|
+
/** Completion IDs by run from the native transcript: the session file when Pi persists one,
|
|
190
|
+
* otherwise Pi's in-memory entries. A matching entry without an ID is unpublished.
|
|
172
191
|
*/
|
|
173
192
|
export function completionReceipts(
|
|
174
|
-
|
|
193
|
+
manager: ExtensionContext["sessionManager"],
|
|
175
194
|
owner: string,
|
|
176
195
|
): ReadonlyMap<string, string | undefined> {
|
|
177
|
-
|
|
196
|
+
const file = manager.getSessionFile();
|
|
197
|
+
const journal = file === undefined ? remembered(manager, owner) : owned(file, owner);
|
|
198
|
+
return journal?.completions ?? new Map<string, string>();
|
|
178
199
|
}
|
package/src/agent/runner.ts
CHANGED
|
@@ -2,7 +2,13 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
commandSchema,
|
|
7
|
+
parkedSchema,
|
|
8
|
+
type Command,
|
|
9
|
+
type Launch,
|
|
10
|
+
type Result,
|
|
11
|
+
} from "./contracts.ts";
|
|
6
12
|
import { launchAt, missing, read, runDirectory, write } from "./storage.ts";
|
|
7
13
|
import { ownIdentity, ParentCheck, shells, signalProcess } from "./process.ts";
|
|
8
14
|
import { errorText, loadSDK } from "./host.ts";
|
|
@@ -374,7 +380,7 @@ class Actor {
|
|
|
374
380
|
}
|
|
375
381
|
return this.parking;
|
|
376
382
|
}
|
|
377
|
-
private failure(error: unknown): void {
|
|
383
|
+
private failure(error: unknown, unopened?: string): void {
|
|
378
384
|
const launch = this.current ?? this.initial;
|
|
379
385
|
const result: Result = {
|
|
380
386
|
id: launch.id,
|
|
@@ -384,6 +390,16 @@ class Actor {
|
|
|
384
390
|
error: errorText(error),
|
|
385
391
|
};
|
|
386
392
|
const directory = runDirectory(this.base, launch.id);
|
|
393
|
+
if (fs.existsSync(path.join(this.dir, "parked.json"))) {
|
|
394
|
+
const parked = read(path.join(this.dir, "parked.json"), parkedSchema);
|
|
395
|
+
write(path.join(this.dir, `wake-failure-${this.token}.json`), {
|
|
396
|
+
at: Date.now(),
|
|
397
|
+
reason: errorText(error),
|
|
398
|
+
...(unopened !== undefined && parked?.token === unopened
|
|
399
|
+
? { beforeOpen: { actorToken: this.token, parkedToken: unopened } }
|
|
400
|
+
: {}),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
387
403
|
if (!fs.existsSync(path.join(directory, "result.json"))) {
|
|
388
404
|
fs.writeFileSync(path.join(directory, "output.txt"), "", { mode: 0o600 });
|
|
389
405
|
write(path.join(directory, "result.json"), result);
|
|
@@ -449,17 +465,21 @@ class Actor {
|
|
|
449
465
|
parked.adopt();
|
|
450
466
|
}
|
|
451
467
|
async serve(): Promise<void> {
|
|
452
|
-
const sdk = await loadSDK(this.initial.sdk);
|
|
453
|
-
const parked = new Parking(this.base, this.initial, this.token, sdk);
|
|
454
468
|
let runtime: Runtime | undefined;
|
|
469
|
+
let parked: Parking | undefined;
|
|
470
|
+
let parkedToken: string | undefined;
|
|
455
471
|
try {
|
|
472
|
+
// Capture the old generation before SDK import or any native working-state operation.
|
|
473
|
+
parkedToken = read(path.join(this.dir, "parked.json"), parkedSchema)?.token;
|
|
474
|
+
const sdk = await loadSDK(this.initial.sdk);
|
|
475
|
+
parked = new Parking(this.base, this.initial, this.token, sdk);
|
|
456
476
|
runtime = await this.start(sdk, parked);
|
|
457
477
|
process.on("SIGTERM", this.stop);
|
|
458
478
|
process.on("SIGINT", this.stop);
|
|
459
479
|
await this.admitCommands(runtime, parked);
|
|
460
480
|
await this.active;
|
|
461
481
|
} catch (error) {
|
|
462
|
-
this.failure(error);
|
|
482
|
+
this.failure(error, parked?.opened === true ? undefined : parkedToken);
|
|
463
483
|
} finally {
|
|
464
484
|
await this.finish(runtime);
|
|
465
485
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import type { ProcessIdentity } from "./contracts.ts";
|
|
3
|
+
import { errorText } from "./host.ts";
|
|
4
|
+
import { alive, requestStop, signalProcess } from "./process.ts";
|
|
5
|
+
import { launchAt, list } from "./storage.ts";
|
|
6
|
+
|
|
7
|
+
export async function stopChildren(
|
|
8
|
+
base: string,
|
|
9
|
+
owner: string,
|
|
10
|
+
report: (message: string) => void,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const roots = new Set<string>();
|
|
13
|
+
const targets: ProcessIdentity[] = [];
|
|
14
|
+
for (const id of list(base)) {
|
|
15
|
+
try {
|
|
16
|
+
const launch = launchAt(base, id, owner);
|
|
17
|
+
if (!roots.has(launch.rootId)) {
|
|
18
|
+
roots.add(launch.rootId);
|
|
19
|
+
targets.push(
|
|
20
|
+
...requestStop(base, launch, (error) => {
|
|
21
|
+
report(`Child shutdown ${id}: ${errorText(error)}`);
|
|
22
|
+
}).targets,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
report(`Child shutdown ${id}: ${errorText(error)}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await waitForExit(targets, 3000);
|
|
30
|
+
for (const target of targets) {
|
|
31
|
+
try {
|
|
32
|
+
signalProcess(target, "SIGKILL");
|
|
33
|
+
} catch (error) {
|
|
34
|
+
report(`Child shutdown ${target.pid}: ${errorText(error)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
await waitForExit(targets, 1000);
|
|
38
|
+
if (targets.some(alive)) {
|
|
39
|
+
report("Child shutdown could not confirm every owned process exit.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function waitForExit(targets: readonly ProcessIdentity[], timeout: number): Promise<void> {
|
|
44
|
+
const deadline = Date.now() + timeout;
|
|
45
|
+
while (Date.now() < deadline && targets.some(alive)) {
|
|
46
|
+
// Join actual process exit while native abort persists the interrupted turn.
|
|
47
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
48
|
+
await delay(25);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/agent/view.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
commandSchema,
|
|
9
9
|
exitSchema,
|
|
10
10
|
successorSchema,
|
|
11
|
+
wakeFailureSchema,
|
|
11
12
|
type Launch,
|
|
12
13
|
type Result,
|
|
13
14
|
} from "./contracts.ts";
|
|
@@ -47,6 +48,7 @@ interface RunSummary {
|
|
|
47
48
|
result?: Pick<Result, "status" | "endedAt" | "error">;
|
|
48
49
|
nativeReceipt: string | null;
|
|
49
50
|
process: ReturnType<typeof processView>;
|
|
51
|
+
lastWakeFailure?: { at: number; reason: string };
|
|
50
52
|
sessionId: string;
|
|
51
53
|
sessionFile: string;
|
|
52
54
|
outputFile: string;
|
|
@@ -175,6 +177,10 @@ function metadata(
|
|
|
175
177
|
result,
|
|
176
178
|
nativeReceipt: options.ctx ? (published(options.ctx, launch) ?? null) : null,
|
|
177
179
|
process,
|
|
180
|
+
lastWakeFailure: read(
|
|
181
|
+
path.join(conversation(base, launch), "wake-failure.json"),
|
|
182
|
+
wakeFailureSchema,
|
|
183
|
+
),
|
|
178
184
|
sessionId: launch.sessionId,
|
|
179
185
|
sessionFile: launch.sessionFile,
|
|
180
186
|
outputFile,
|
|
@@ -275,6 +281,9 @@ function resultText(details: RunView): string {
|
|
|
275
281
|
`Agent run ${details.id}: ${details.status}; admission ${details.admission}; process ${details.process.live ? "live" : "not observed live"}.`,
|
|
276
282
|
receiptText(details),
|
|
277
283
|
exitText(details),
|
|
284
|
+
details.lastWakeFailure
|
|
285
|
+
? `Last wake failure at ${new Date(details.lastWakeFailure.at).toISOString()}: ${details.lastWakeFailure.reason} Retained intercom messages may still arrive after a later wake.`
|
|
286
|
+
: "",
|
|
278
287
|
`Guidance (latest 20):\n${guidance.length > 0 ? guidance : " none"}`,
|
|
279
288
|
"Guidance states: pending = not yet admitted; accepted = native queue admission, not journal publication or model consumption; stale = rejected, no new work was started (retry the entire prompt as a continuation).",
|
|
280
289
|
details.result?.error ?? "",
|