@fitchmultz/pi-subagents 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -2
- package/README.md +36 -5
- package/dist/agent/child-runtime.js +80 -0
- package/dist/agent/contracts.js +15 -24
- package/dist/agent/host.js +3 -16
- package/dist/agent/index.js +212 -192
- package/dist/agent/journal-tail.js +115 -156
- package/dist/agent/launch.js +20 -8
- package/dist/agent/legacy.js +65 -23
- package/dist/agent/notifications.js +138 -47
- package/dist/agent/observation-state.js +246 -0
- package/dist/agent/observation-view.js +258 -0
- package/dist/agent/observation.js +350 -0
- package/dist/agent/observe-card.js +243 -0
- package/dist/agent/observe-chrome.js +85 -0
- package/dist/agent/observe-client.js +285 -0
- package/dist/agent/observe-layout.js +65 -0
- package/dist/agent/observe-reading.js +84 -0
- package/dist/agent/observe-screen.js +400 -96
- package/dist/agent/observe-transcript.js +382 -0
- package/dist/agent/observe.js +250 -43
- package/dist/agent/parking.js +253 -0
- package/dist/agent/process.js +228 -28
- package/dist/agent/receipts.js +92 -20
- package/dist/agent/runner.js +229 -126
- package/dist/agent/shell.js +2 -1
- package/dist/agent/storage.js +47 -12
- package/dist/agent/tool-schemas.js +20 -0
- package/dist/agent/typebox.js +10 -5
- package/dist/agent/usage.js +0 -115
- package/dist/agent/view.js +46 -16
- package/dist/agent/wake.js +99 -0
- package/dist/build-info.js +1 -1
- package/dist/intercom-client.d.ts +26 -0
- package/dist/intercom-client.js +45 -0
- package/dist/pi-intercom/agent-dir.d.ts +1 -0
- package/dist/pi-intercom/bridge-session.js +2 -1
- package/dist/pi-intercom/broker/broker.js +62 -43
- package/dist/pi-intercom/broker/client-last.d.ts +24 -0
- package/dist/pi-intercom/broker/client-last.js +42 -5
- package/dist/pi-intercom/broker/client-park.d.ts +9 -0
- package/dist/pi-intercom/broker/client-park.js +21 -0
- package/dist/pi-intercom/broker/client.d.ts +59 -0
- package/dist/pi-intercom/broker/client.js +55 -29
- package/dist/pi-intercom/broker/connection.d.ts +29 -0
- package/dist/pi-intercom/broker/framing.d.ts +17 -0
- package/dist/pi-intercom/broker/framing.js +10 -7
- package/dist/pi-intercom/broker/guard.js +23 -0
- package/dist/pi-intercom/broker/last.d.ts +30 -0
- package/dist/pi-intercom/broker/last.js +87 -40
- package/dist/pi-intercom/broker/parked.js +107 -0
- package/dist/pi-intercom/broker/paths.d.ts +13 -0
- package/dist/pi-intercom/broker/paths.js +35 -12
- package/dist/pi-intercom/broker/pid.js +5 -1
- package/dist/pi-intercom/broker/server-connection.js +20 -1
- package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
- package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
- package/dist/pi-intercom/broker/targets.d.ts +9 -0
- package/dist/pi-intercom/connection.js +71 -20
- package/dist/pi-intercom/identity.js +6 -5
- package/dist/pi-intercom/inbound-delivery.js +16 -60
- package/dist/pi-intercom/inbound-journal.js +83 -6
- package/dist/pi-intercom/inbound-record.js +18 -1
- package/dist/pi-intercom/inbound.js +22 -37
- package/dist/pi-intercom/index.js +41 -0
- package/dist/pi-intercom/last-message.js +4 -16
- package/dist/pi-intercom/lifecycle.js +88 -3
- package/dist/pi-intercom/outbound.js +5 -15
- package/dist/pi-intercom/reply-tracker.js +7 -6
- package/dist/pi-intercom/reply-wait.js +2 -2
- package/dist/pi-intercom/session-targets.d.ts +24 -0
- package/dist/pi-intercom/tool-registration.js +4 -1
- package/dist/pi-intercom/tool-results.js +5 -7
- package/dist/pi-intercom/tool-schemas.js +12 -26
- package/dist/pi-intercom/types.d.ts +212 -0
- package/dist/pi-intercom/types.js +54 -3
- package/dist/pi-intercom/validation.d.ts +12 -0
- package/dist/pi-intercom/validation.js +3 -0
- package/dist/shared/bounded-json.js +151 -0
- package/dist/shared/compact-cards.js +66 -0
- package/dist/shared/journal-frames.js +3 -6
- package/dist/shared/json-projection.js +267 -129
- package/dist/shared/native-journal.js +123 -56
- package/dist/shared/parking.js +12 -0
- package/dist/shared/post-exit-stdio-guard.js +120 -7
- package/dist/shared/tui-action.js +29 -0
- package/dist/shared/unknown.d.ts +9 -0
- package/docs/code-quality.md +5 -3
- package/docs/development.md +42 -3
- package/docs/intercom-bridge.md +9 -4
- package/docs/intercom.md +228 -7
- package/docs/performance.md +118 -14
- package/docs/reference.md +168 -20
- package/docs/retired-history-maintenance.md +252 -0
- package/package.json +28 -15
- package/scripts/archive-retired-history.mjs +464 -0
- package/scripts/build.mjs +48 -28
- package/scripts/measure-size.mjs +1 -1
- package/scripts/native-package-smoke.mjs +153 -12
- package/scripts/package-smoke.mjs +80 -11
- package/scripts/prepare.mjs +2 -2
- package/scripts/real-pi-live.mjs +377 -0
- package/scripts/real-pi-processes.mjs +6 -1
- package/scripts/real-pi-smoke.mjs +6 -282
- package/scripts/run-tests.mjs +4 -2
- package/scripts/typescript.mjs +35 -0
- package/skills/pi-intercom/SKILL.md +2 -1
- package/skills/pi-subagents/SKILL.md +23 -5
- package/src/agent/child-runtime.ts +105 -0
- package/src/agent/contracts.ts +18 -34
- package/src/agent/host.ts +39 -20
- package/src/agent/index.ts +234 -207
- package/src/agent/journal-tail.ts +141 -153
- package/src/agent/launch.ts +33 -9
- package/src/agent/legacy.ts +85 -31
- package/src/agent/notifications.ts +192 -67
- package/src/agent/observation-state.ts +277 -0
- package/src/agent/observation-view.ts +267 -0
- package/src/agent/observation.ts +409 -0
- package/src/agent/observe-card.ts +304 -0
- package/src/agent/observe-chrome.ts +170 -0
- package/src/agent/observe-client.ts +318 -0
- package/src/agent/observe-layout.ts +79 -0
- package/src/agent/observe-reading.ts +97 -0
- package/src/agent/observe-screen.ts +443 -129
- package/src/agent/observe-transcript.ts +434 -0
- package/src/agent/observe.ts +304 -71
- package/src/agent/parking.ts +341 -0
- package/src/agent/process.ts +280 -37
- package/src/agent/receipts.ts +114 -24
- package/src/agent/runner.ts +227 -149
- package/src/agent/shell.ts +2 -1
- package/src/agent/storage.ts +44 -11
- package/src/agent/tool-schemas.ts +33 -0
- package/src/agent/typebox.ts +12 -7
- package/src/agent/usage.ts +0 -130
- package/src/agent/view.ts +70 -20
- package/src/agent/wake.ts +107 -0
- package/src/intercom-client.ts +108 -0
- package/src/pi-intercom/bridge-session.ts +2 -2
- package/src/pi-intercom/broker/broker.ts +64 -53
- package/src/pi-intercom/broker/client-last.ts +65 -12
- package/src/pi-intercom/broker/client-park.ts +23 -0
- package/src/pi-intercom/broker/client.ts +63 -45
- package/src/pi-intercom/broker/framing.ts +11 -8
- package/src/pi-intercom/broker/guard.ts +33 -0
- package/src/pi-intercom/broker/last.ts +131 -43
- package/src/pi-intercom/broker/parked.ts +130 -0
- package/src/pi-intercom/broker/paths.ts +39 -12
- package/src/pi-intercom/broker/pid.ts +6 -1
- package/src/pi-intercom/broker/server-connection.ts +27 -2
- package/src/pi-intercom/connection.ts +103 -39
- package/src/pi-intercom/identity.ts +8 -5
- package/src/pi-intercom/inbound-delivery.ts +20 -66
- package/src/pi-intercom/inbound-journal.ts +103 -7
- package/src/pi-intercom/inbound-record.ts +21 -4
- package/src/pi-intercom/inbound.ts +32 -49
- package/src/pi-intercom/index.ts +44 -0
- package/src/pi-intercom/last-message.ts +11 -20
- package/src/pi-intercom/lifecycle.ts +117 -14
- package/src/pi-intercom/outbound.ts +7 -16
- package/src/pi-intercom/reply-tracker.ts +8 -6
- package/src/pi-intercom/reply-wait.ts +3 -2
- package/src/pi-intercom/runtime-types.ts +0 -1
- package/src/pi-intercom/tool-registration.ts +4 -1
- package/src/pi-intercom/tool-results.ts +10 -9
- package/src/pi-intercom/tool-schemas.ts +12 -44
- package/src/pi-intercom/types.ts +115 -4
- package/src/pi-intercom/validation.ts +4 -0
- package/src/shared/bounded-json.ts +162 -0
- package/src/shared/compact-cards.ts +118 -0
- package/src/shared/journal-frames.ts +3 -19
- package/src/shared/json-projection.ts +267 -147
- package/src/shared/native-journal.ts +157 -75
- package/src/shared/parking.ts +22 -0
- package/src/shared/post-exit-stdio-guard.ts +165 -7
- package/src/shared/tui-action.ts +39 -0
- package/tsconfig.client.json +8 -0
- package/tsconfig.json +1 -0
- package/src/shared/stream-json.d.ts +0 -10
package/src/agent/process.ts
CHANGED
|
@@ -4,28 +4,133 @@ import { spawn } 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";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
import {
|
|
8
|
+
isChildProcessReplaced,
|
|
9
|
+
observeChildProcessReplaced,
|
|
10
|
+
readChildProcessIdentity,
|
|
11
|
+
readProcessClock,
|
|
12
|
+
trySignalChildTree,
|
|
13
|
+
} from "../shared/post-exit-stdio-guard.ts";
|
|
14
|
+
import { agentObservationSocketPath } from "../pi-intercom/broker/paths.ts";
|
|
15
|
+
import { hasErrorCode } from "../shared/unknown.ts";
|
|
16
|
+
import {
|
|
17
|
+
attachmentSchema,
|
|
18
|
+
parkedSchema,
|
|
19
|
+
processSchema,
|
|
20
|
+
type Launch,
|
|
21
|
+
type ProcessIdentity,
|
|
22
|
+
} from "./contracts.ts";
|
|
23
|
+
import { conversation, enqueue, launchAt, list, read, write } from "./storage.ts";
|
|
24
|
+
import { errorText } from "./host.ts";
|
|
10
25
|
|
|
11
|
-
|
|
26
|
+
/** Darwin's in-memory publication bound; Android records carry their own native clock. */
|
|
27
|
+
interface Published {
|
|
28
|
+
readonly published?: number | undefined;
|
|
29
|
+
}
|
|
30
|
+
type OwnedProcess = ProcessIdentity & Published;
|
|
31
|
+
type ClaimedProcess = Pick<ProcessIdentity, "pid" | "identity" | "clock"> & Published;
|
|
32
|
+
/** Owner records follow their process's birth; stat after read only moves the bound later. */
|
|
33
|
+
export function published<T extends object>(
|
|
34
|
+
file: string,
|
|
35
|
+
record: T | undefined,
|
|
36
|
+
): (T & Published) | undefined {
|
|
37
|
+
return record && { ...record, published: fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs };
|
|
38
|
+
}
|
|
39
|
+
/** Settles liveness without a birth lookup; undefined means compare the recorded birth. */
|
|
40
|
+
function probe(identity: ClaimedProcess): boolean | undefined {
|
|
12
41
|
try {
|
|
13
42
|
process.kill(identity.pid, 0);
|
|
14
43
|
} catch (error) {
|
|
15
|
-
|
|
16
|
-
|
|
44
|
+
// EPERM still names a live PID, possibly another user's reuse; check its birth below.
|
|
45
|
+
if (!hasErrorCode(error, "EPERM")) {
|
|
46
|
+
return !hasErrorCode(error, "ESRCH");
|
|
17
47
|
}
|
|
18
|
-
return true;
|
|
19
48
|
}
|
|
20
|
-
|
|
21
|
-
|
|
49
|
+
return identity.identity === "unverified" ? true : undefined;
|
|
50
|
+
}
|
|
51
|
+
export function alive(identity: ClaimedProcess): boolean {
|
|
52
|
+
// Only a proven different birth disproves a live PID; ambiguous identities stay possibly owned.
|
|
53
|
+
return (
|
|
54
|
+
probe(identity) ??
|
|
55
|
+
(readChildProcessIdentity(identity.pid) === identity.identity || !replaced(identity))
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
/** Routine `alive` without waiting on `ps`; signals still use the synchronous proof. */
|
|
59
|
+
export async function observeAlive(identities: readonly ClaimedProcess[]): Promise<boolean[]> {
|
|
60
|
+
const verdicts: boolean[] = [];
|
|
61
|
+
for (const claim of identities) {
|
|
62
|
+
// One `ps` at a time: each spawn briefly holds the event loop, so concurrent spawns add up.
|
|
63
|
+
const { pid, identity, clock } = claim;
|
|
64
|
+
verdicts.push(
|
|
65
|
+
probe(claim) ??
|
|
66
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
67
|
+
!(await observeChildProcessReplaced(pid, identity, claim.published, clock)),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return verdicts;
|
|
22
71
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
72
|
+
/**
|
|
73
|
+
* A child's routine parent check, one observation at a time. A dead parent detaches the child
|
|
74
|
+
* only while its check is current and the attachment generation is unchanged: a newer parent
|
|
75
|
+
* may adopt the child meanwhile.
|
|
76
|
+
*/
|
|
77
|
+
export class ParentCheck {
|
|
78
|
+
private current?: object;
|
|
79
|
+
/**
|
|
80
|
+
* Reads the attachment synchronously, so an unreadable one still fails the caller at once.
|
|
81
|
+
* Missing or released attachments detach immediately; a check in flight makes this a no-op.
|
|
82
|
+
*/
|
|
83
|
+
check(file: string, detached: () => void): Promise<void> {
|
|
84
|
+
if (this.current !== undefined) {
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
}
|
|
87
|
+
const attachment = published(file, read(file, attachmentSchema));
|
|
88
|
+
if (attachment === undefined || !attachment.attached) {
|
|
89
|
+
detached();
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
}
|
|
92
|
+
const check = {};
|
|
93
|
+
this.current = check;
|
|
94
|
+
const settle = async () => {
|
|
95
|
+
try {
|
|
96
|
+
const [live] = await observeAlive([attachment]);
|
|
97
|
+
if (
|
|
98
|
+
this.current === check &&
|
|
99
|
+
!live &&
|
|
100
|
+
read(file, attachmentSchema)?.generation === attachment.generation
|
|
101
|
+
) {
|
|
102
|
+
detached();
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
if (this.current === check) {
|
|
106
|
+
this.current = undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return settle();
|
|
111
|
+
}
|
|
112
|
+
/** A check settling later no longer acts. */
|
|
113
|
+
cancel(): void {
|
|
114
|
+
this.current = undefined;
|
|
115
|
+
}
|
|
26
116
|
}
|
|
27
|
-
|
|
28
|
-
return
|
|
117
|
+
function replaced(identity: ClaimedProcess): boolean {
|
|
118
|
+
return (
|
|
119
|
+
identity.identity !== "unverified" &&
|
|
120
|
+
isChildProcessReplaced(identity.pid, identity.identity, identity.published, identity.clock)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
export function ownIdentity(pid = process.pid): Omit<ProcessIdentity, "token"> {
|
|
124
|
+
// An ordinary main or managed launcher need not be a group leader. Ambiguous live PIDs fail closed.
|
|
125
|
+
return {
|
|
126
|
+
pid,
|
|
127
|
+
identity: readChildProcessIdentity(pid) ?? "unverified",
|
|
128
|
+
clock: readProcessClock(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function actor(base: string, launch: Launch): OwnedProcess | undefined {
|
|
132
|
+
const file = path.join(conversation(base, launch), "actor.lock", "process.json");
|
|
133
|
+
return published(file, read(file, processSchema));
|
|
29
134
|
}
|
|
30
135
|
function childEnvironment(launch: Launch): NodeJS.ProcessEnv {
|
|
31
136
|
const env = { ...process.env };
|
|
@@ -76,12 +181,21 @@ function spawnActor(base: string, launch: Launch, token: string): void {
|
|
|
76
181
|
pid: child.pid,
|
|
77
182
|
identity: readChildProcessIdentity(child.pid) ?? "unverified",
|
|
78
183
|
token,
|
|
184
|
+
clock: readProcessClock(),
|
|
79
185
|
});
|
|
80
186
|
}
|
|
81
|
-
async function awaitStopping(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
187
|
+
async function awaitStopping(
|
|
188
|
+
dir: string,
|
|
189
|
+
existing: OwnedProcess | undefined,
|
|
190
|
+
signal?: AbortSignal,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
if (existing) {
|
|
193
|
+
const stopping = path.join(dir, `stopping-${existing.token}.json`);
|
|
194
|
+
// Parking ends in exit or a removed marker, after native shutdown persistence.
|
|
195
|
+
const parking = path.join(dir, "actor.lock", "parking.json");
|
|
196
|
+
const deadline = Date.now() + (fs.existsSync(stopping) ? 5000 : 30_000);
|
|
197
|
+
while ((fs.existsSync(stopping) || fs.existsSync(parking)) && alive(existing)) {
|
|
198
|
+
signal?.throwIfAborted();
|
|
85
199
|
if (Date.now() > deadline) {
|
|
86
200
|
throw new Error("Previous actor is still stopping; no replacement was launched.");
|
|
87
201
|
}
|
|
@@ -91,19 +205,26 @@ async function awaitStopping(dir: string, existing: ProcessIdentity | undefined)
|
|
|
91
205
|
}
|
|
92
206
|
}
|
|
93
207
|
}
|
|
94
|
-
export async function ensureActor(
|
|
208
|
+
export async function ensureActor(
|
|
209
|
+
base: string,
|
|
210
|
+
launch: Launch,
|
|
211
|
+
signal?: AbortSignal,
|
|
212
|
+
): Promise<void> {
|
|
95
213
|
const dir = conversation(base, launch);
|
|
96
214
|
const lock = path.join(dir, "actor.lock");
|
|
97
|
-
await awaitStopping(dir, actor(base, launch));
|
|
215
|
+
await awaitStopping(dir, actor(base, launch), signal);
|
|
216
|
+
signal?.throwIfAborted();
|
|
98
217
|
// Another parallel tool may replace the claim while this invocation yields.
|
|
99
218
|
const existing = actor(base, launch);
|
|
100
219
|
if (existing && alive(existing)) {
|
|
101
220
|
return;
|
|
102
221
|
}
|
|
222
|
+
parkFailure(base, launch, existing);
|
|
103
223
|
if (existing) {
|
|
104
224
|
if (shells(base, launch, existing.token).some(alive)) {
|
|
105
225
|
throw new Error("Owned native shell work remains live; stop it before continuing.");
|
|
106
226
|
}
|
|
227
|
+
removeObservationEndpoint(existing.token);
|
|
107
228
|
fs.rmSync(lock, { recursive: true });
|
|
108
229
|
}
|
|
109
230
|
try {
|
|
@@ -120,32 +241,77 @@ export async function ensureActor(base: string, launch: Launch): Promise<void> {
|
|
|
120
241
|
write(path.join(lock, "claim.json"), { ...ownIdentity(), token });
|
|
121
242
|
spawnActor(base, launch, token);
|
|
122
243
|
}
|
|
123
|
-
|
|
244
|
+
/** A failed park keeps its input queued; exact state is lost, so nothing restarts it implicitly. */
|
|
245
|
+
export function parkFailure(base: string, launch: Launch, exited?: OwnedProcess): void {
|
|
246
|
+
const dir = conversation(base, launch);
|
|
247
|
+
const parked = read(path.join(dir, "parked.json"), parkedSchema);
|
|
248
|
+
if (exited && parked && parked.token !== exited.token) {
|
|
249
|
+
// Only the exited claim that saved this state may hand it to the next generation.
|
|
250
|
+
throw new Error(
|
|
251
|
+
"Parked working state belongs to another actor generation; nothing was reopened.",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const file = path.join(dir, "park-failed.json");
|
|
255
|
+
if (fs.existsSync(file)) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`This idle child exited without saving its exact working state (${file}). Nothing was restarted or admitted; messages for it stay queued. Inspect it, then delegate new work.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** A broker wake may only restore exact parked state, never reopen the journal alone. */
|
|
262
|
+
export async function wakeActor(base: string, launch: Launch, signal?: AbortSignal): Promise<void> {
|
|
263
|
+
const dir = conversation(base, launch);
|
|
264
|
+
await awaitStopping(dir, actor(base, launch), signal);
|
|
265
|
+
parkFailure(base, launch);
|
|
266
|
+
if (!fs.existsSync(path.join(dir, "parked.json"))) {
|
|
267
|
+
throw new Error("No parked working state; the child was not woken.");
|
|
268
|
+
}
|
|
269
|
+
await ensureActor(base, launch, signal);
|
|
270
|
+
}
|
|
271
|
+
/** A crashed generation cannot unlink its endpoint; its exact token names the leftover. */
|
|
272
|
+
function removeObservationEndpoint(token: string): void {
|
|
273
|
+
try {
|
|
274
|
+
const socketPath = agentObservationSocketPath(token);
|
|
275
|
+
if (fs.lstatSync(socketPath, { throwIfNoEntry: false })?.isSocket() === true) {
|
|
276
|
+
fs.rmSync(socketPath, { force: true });
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// Observation leftovers are inert to a new token and must never block native work.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function verifyLiveness(identity: OwnedProcess): boolean {
|
|
124
283
|
try {
|
|
125
|
-
process.kill(pid, 0);
|
|
284
|
+
process.kill(identity.pid, 0);
|
|
126
285
|
} catch (error) {
|
|
127
|
-
|
|
286
|
+
// Another user's live PID is released only by the same proof as a signalable one.
|
|
287
|
+
if (hasErrorCode(error, "ESRCH") || (hasErrorCode(error, "EPERM") && replaced(identity))) {
|
|
128
288
|
return false;
|
|
129
289
|
}
|
|
130
290
|
throw new Error("Actor liveness is ambiguous; no stop was sent.", { cause: error });
|
|
131
291
|
}
|
|
132
292
|
return true;
|
|
133
293
|
}
|
|
134
|
-
export function verifyProcess(identity:
|
|
135
|
-
if (!verifyLiveness(identity
|
|
294
|
+
export function verifyProcess(identity: OwnedProcess): boolean {
|
|
295
|
+
if (!verifyLiveness(identity)) {
|
|
136
296
|
return false;
|
|
137
297
|
}
|
|
138
298
|
const actual = readChildProcessIdentity(identity.pid);
|
|
139
|
-
if (identity.identity !== "unverified" && actual
|
|
140
|
-
|
|
141
|
-
|
|
299
|
+
if (identity.identity !== "unverified" && actual !== identity.identity) {
|
|
300
|
+
if (actual === undefined && !verifyLiveness(identity)) {
|
|
301
|
+
// Native abort can reap a shell between the liveness probe and birth lookup.
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
// A different-birth process at the PID proves the recorded process is gone.
|
|
305
|
+
if (replaced(identity)) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
142
308
|
}
|
|
143
309
|
if (identity.identity === "unverified" || actual !== identity.identity) {
|
|
144
310
|
throw new Error("Cannot verify actor process identity; no signal sent.");
|
|
145
311
|
}
|
|
146
312
|
return true;
|
|
147
313
|
}
|
|
148
|
-
export function signalProcess(identity:
|
|
314
|
+
export function signalProcess(identity: OwnedProcess, signal: NodeJS.Signals): boolean {
|
|
149
315
|
if (!verifyProcess(identity)) {
|
|
150
316
|
return false;
|
|
151
317
|
}
|
|
@@ -154,18 +320,95 @@ export function signalProcess(identity: ProcessIdentity, signal: NodeJS.Signals)
|
|
|
154
320
|
signal,
|
|
155
321
|
);
|
|
156
322
|
}
|
|
157
|
-
export function
|
|
323
|
+
export function requestStop(
|
|
324
|
+
base: string,
|
|
325
|
+
launch: Launch,
|
|
326
|
+
onError?: (error: unknown) => void,
|
|
327
|
+
): { targets: OwnedProcess[]; command: string | undefined } {
|
|
328
|
+
const attempt = (operation: () => boolean): boolean => {
|
|
329
|
+
try {
|
|
330
|
+
return operation();
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (onError === undefined) {
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
onError(error);
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const identity = actor(base, launch);
|
|
340
|
+
const targets = identity ? [identity, ...shells(base, launch, identity.token)] : [];
|
|
341
|
+
// Interactive Stop validates the whole set; shutdown reports and skips only ambiguous targets.
|
|
342
|
+
const live = targets.filter((target) => attempt(() => verifyProcess(target)));
|
|
343
|
+
const command =
|
|
344
|
+
identity && live.includes(identity)
|
|
345
|
+
? enqueue(base, launch, "stop", { actorToken: identity.token })
|
|
346
|
+
: undefined;
|
|
347
|
+
if (identity && live.length > 0) {
|
|
348
|
+
write(path.join(conversation(base, launch), `stopping-${identity.token}.json`), {
|
|
349
|
+
at: Date.now(),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
live.forEach((target) => {
|
|
353
|
+
attempt(() => signalProcess(target, "SIGTERM"));
|
|
354
|
+
});
|
|
355
|
+
return { targets: live, command };
|
|
356
|
+
}
|
|
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
|
+
export function shells(base: string, launch: Launch, token: string): OwnedProcess[] {
|
|
158
397
|
const dir = path.join(conversation(base, launch), "shells", token);
|
|
159
398
|
if (!fs.existsSync(dir)) {
|
|
160
399
|
return [];
|
|
161
400
|
}
|
|
162
|
-
return fs
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
401
|
+
return fs
|
|
402
|
+
.readdirSync(dir)
|
|
403
|
+
.filter((name) => name.endsWith(".json"))
|
|
404
|
+
.map((name) => {
|
|
405
|
+
const file = path.join(dir, name);
|
|
406
|
+
const identity = published(file, read(file, processSchema));
|
|
407
|
+
if (!identity || identity.token !== token) {
|
|
408
|
+
throw new Error("Foreign or missing native shell identity.");
|
|
409
|
+
}
|
|
410
|
+
return identity;
|
|
411
|
+
});
|
|
169
412
|
}
|
|
170
413
|
function quote(value: string): string {
|
|
171
414
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
package/src/agent/receipts.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { scanJournal } from "../shared/journal-frames.ts";
|
|
3
|
-
import { journalStamp } from "../shared/native-journal.ts";
|
|
3
|
+
import { journalStamp, nativeAppend } from "../shared/native-journal.ts";
|
|
4
4
|
import type { Projection } from "../shared/json-projection.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
6
6
|
|
|
7
7
|
const detailFields = new Set([
|
|
8
8
|
"owner",
|
|
@@ -35,7 +35,7 @@ const receiptProjection: Projection = (keys) => {
|
|
|
35
35
|
if (keys.length === 0) {
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
|
-
if (["type", "id", "customType"].includes(String(keys[0]))) {
|
|
38
|
+
if (["type", "id", "customType", "version"].includes(String(keys[0]))) {
|
|
39
39
|
return keys.length === 1 ? 4096 : false;
|
|
40
40
|
}
|
|
41
41
|
if (keys[0] !== "message") {
|
|
@@ -50,39 +50,129 @@ const receiptProjection: Projection = (keys) => {
|
|
|
50
50
|
return detailsProjection(keys.slice(1));
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
interface ReceiptIndex {
|
|
54
|
+
stamp: string;
|
|
55
|
+
end: number;
|
|
56
|
+
stat: fs.BigIntStats;
|
|
57
|
+
entries: UnknownRecord[];
|
|
58
|
+
/** First completion receipt per owned run ID, built in the same LF pass. */
|
|
59
|
+
completions: Map<string, string | undefined>;
|
|
60
|
+
}
|
|
61
|
+
const indexes = new Map<string, ReceiptIndex>();
|
|
62
|
+
type Prefix = Pick<ReceiptIndex, "end" | "entries" | "completions">;
|
|
63
|
+
|
|
64
|
+
function toolCompletion(message: unknown): unknown {
|
|
65
|
+
return isRecord(message) &&
|
|
66
|
+
message.role === "toolResult" &&
|
|
67
|
+
(message.toolName === "delegate" || message.toolName === "agent_runs") &&
|
|
68
|
+
isRecord(message.details) &&
|
|
69
|
+
message.details.receivedResult === true
|
|
70
|
+
? message.details
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
function completionDetails(entry: UnknownRecord): unknown {
|
|
74
|
+
if (entry.type === "custom_message" && entry.customType === "agent-completion") {
|
|
75
|
+
return entry.details;
|
|
76
|
+
}
|
|
77
|
+
return entry.type === "message" ? toolCompletion(entry.message) : undefined;
|
|
78
|
+
}
|
|
79
|
+
function addEntry(journal: Prefix, entry: UnknownRecord) {
|
|
80
|
+
const header = journal.entries.at(0);
|
|
81
|
+
const owner = header?.type === "session" ? header.id : undefined;
|
|
82
|
+
const details = typeof owner === "string" ? completionDetails(entry) : undefined;
|
|
83
|
+
if (
|
|
84
|
+
isRecord(details) &&
|
|
85
|
+
details.owner === owner &&
|
|
86
|
+
details.completed === true &&
|
|
87
|
+
typeof details.id === "string" &&
|
|
88
|
+
!journal.completions.has(details.id)
|
|
89
|
+
) {
|
|
90
|
+
journal.completions.set(details.id, typeof entry.id === "string" ? entry.id : undefined);
|
|
91
|
+
}
|
|
92
|
+
journal.entries.push(entry);
|
|
93
|
+
}
|
|
94
|
+
function prefix(stat: fs.BigIntStats, cached: ReceiptIndex | undefined): Prefix {
|
|
95
|
+
return cached && nativeAppend(cached.stat, stat, cached.entries[0])
|
|
96
|
+
? cached
|
|
97
|
+
: { end: 0, entries: [], completions: new Map() };
|
|
98
|
+
}
|
|
99
|
+
function index(file: string, observed: fs.BigIntStats): ReceiptIndex {
|
|
100
|
+
const cached = indexes.get(file);
|
|
101
|
+
if (cached?.stamp === journalStamp(observed)) {
|
|
102
|
+
return cached;
|
|
61
103
|
}
|
|
62
104
|
const fd = fs.openSync(file, "r");
|
|
63
|
-
let first = true;
|
|
64
|
-
let owned = false;
|
|
65
|
-
let found: string | undefined;
|
|
66
105
|
try {
|
|
67
|
-
const
|
|
68
|
-
|
|
106
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
107
|
+
const stamp = journalStamp(stat);
|
|
108
|
+
if (cached?.stamp === stamp) {
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
const retained = prefix(stat, cached);
|
|
112
|
+
const appended: UnknownRecord[] = [];
|
|
113
|
+
const end = scanJournal(
|
|
69
114
|
fd,
|
|
70
115
|
receiptProjection,
|
|
71
116
|
({ value }) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
117
|
+
appended.push(value);
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
policy: "live",
|
|
121
|
+
requireNewline: true,
|
|
122
|
+
start: retained.end,
|
|
78
123
|
},
|
|
79
|
-
{ policy: "live", requireNewline: true },
|
|
80
124
|
);
|
|
81
125
|
if (journalStamp(fs.fstatSync(fd, { bigint: true })) !== stamp) {
|
|
82
126
|
throw new Error("Native receipt journal changed while reading.");
|
|
83
127
|
}
|
|
84
|
-
|
|
128
|
+
for (const entry of appended) {
|
|
129
|
+
addEntry(retained, entry);
|
|
130
|
+
}
|
|
131
|
+
const result = { ...retained, stamp, end, stat };
|
|
132
|
+
indexes.delete(file);
|
|
133
|
+
indexes.set(file, result);
|
|
134
|
+
if (indexes.size > 8) {
|
|
135
|
+
const oldest = indexes.keys().next().value;
|
|
136
|
+
if (oldest !== undefined) {
|
|
137
|
+
indexes.delete(oldest);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
indexes.delete(file);
|
|
143
|
+
throw error;
|
|
85
144
|
} finally {
|
|
86
145
|
fs.closeSync(fd);
|
|
87
146
|
}
|
|
88
147
|
}
|
|
148
|
+
|
|
149
|
+
/** Physical LF publication owns delivery, including when native memory is stale. */
|
|
150
|
+
function owned(file: string | undefined, owner: string): ReceiptIndex | undefined {
|
|
151
|
+
const stat =
|
|
152
|
+
file === undefined ? undefined : fs.statSync(file, { bigint: true, throwIfNoEntry: false });
|
|
153
|
+
if (file === undefined || stat === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const journal = index(file, stat);
|
|
157
|
+
const header = journal.entries.at(0);
|
|
158
|
+
return header?.type === "session" && header.id === owner ? journal : undefined;
|
|
159
|
+
}
|
|
160
|
+
export function receiptId(
|
|
161
|
+
file: string | undefined,
|
|
162
|
+
owner: string,
|
|
163
|
+
matches: (entry: UnknownRecord) => boolean,
|
|
164
|
+
): string | undefined {
|
|
165
|
+
const found = owned(file, owner)?.entries.find(
|
|
166
|
+
(entry) => entry.type !== "session" && matches(entry),
|
|
167
|
+
);
|
|
168
|
+
return typeof found?.id === "string" ? found.id : undefined;
|
|
169
|
+
}
|
|
170
|
+
/** Live read-only completion IDs by run; later appends extend this view.
|
|
171
|
+
* A matching entry without an ID is unpublished. Invalidations replace the view.
|
|
172
|
+
*/
|
|
173
|
+
export function completionReceipts(
|
|
174
|
+
file: string | undefined,
|
|
175
|
+
owner: string,
|
|
176
|
+
): ReadonlyMap<string, string | undefined> {
|
|
177
|
+
return owned(file, owner)?.completions ?? new Map<string, string>();
|
|
178
|
+
}
|