@fitchmultz/pi-subagents 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/agent/child-runtime.js +80 -0
- package/dist/agent/contracts.js +9 -0
- package/dist/agent/index.js +6 -20
- package/dist/agent/observation.js +4 -0
- package/dist/agent/parking.js +253 -0
- package/dist/agent/process.js +31 -4
- package/dist/agent/runner.js +124 -126
- package/dist/agent/storage.js +18 -0
- package/dist/build-info.js +1 -1
- package/dist/pi-intercom/broker/broker.js +42 -29
- package/dist/pi-intercom/broker/client-last.d.ts +12 -1
- package/dist/pi-intercom/broker/client-last.js +25 -0
- 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 +2 -3
- package/dist/pi-intercom/broker/client.js +23 -28
- package/dist/pi-intercom/broker/last.d.ts +5 -1
- package/dist/pi-intercom/broker/last.js +34 -12
- package/dist/pi-intercom/broker/parked.js +107 -0
- package/dist/pi-intercom/broker/server-connection.js +1 -1
- package/dist/pi-intercom/connection.js +65 -22
- package/dist/pi-intercom/inbound.js +11 -0
- package/dist/pi-intercom/index.js +38 -0
- package/dist/shared/parking.js +12 -0
- package/docs/code-quality.md +5 -5
- package/docs/development.md +3 -3
- package/docs/intercom.md +2 -1
- package/docs/performance.md +31 -3
- package/docs/reference.md +12 -1
- package/package.json +1 -1
- package/scripts/measure-size.mjs +1 -1
- package/src/agent/child-runtime.ts +105 -0
- package/src/agent/contracts.ts +10 -0
- package/src/agent/host.ts +22 -2
- package/src/agent/index.ts +10 -17
- package/src/agent/observation.ts +4 -0
- package/src/agent/parking.ts +341 -0
- package/src/agent/process.ts +41 -4
- package/src/agent/runner.ts +124 -144
- package/src/agent/storage.ts +17 -0
- package/src/pi-intercom/broker/broker.ts +44 -37
- package/src/pi-intercom/broker/client-last.ts +41 -1
- package/src/pi-intercom/broker/client-park.ts +23 -0
- package/src/pi-intercom/broker/client.ts +24 -28
- package/src/pi-intercom/broker/last.ts +49 -14
- package/src/pi-intercom/broker/parked.ts +130 -0
- package/src/pi-intercom/broker/server-connection.ts +2 -2
- package/src/pi-intercom/connection.ts +87 -34
- package/src/pi-intercom/inbound.ts +14 -0
- package/src/pi-intercom/index.ts +41 -0
- package/src/pi-intercom/last-message.ts +1 -1
- package/src/shared/parking.ts +22 -0
package/docs/performance.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Rebuild measurements
|
|
4
4
|
|
|
5
|
-
The baseline
|
|
6
|
-
|
|
5
|
+
The baseline is in [the rebuild evidence](single-agent-rebuild.md); the only
|
|
6
|
+
enforced ceiling is the 1 MiB total production bytes. `node scripts/measure-size.mjs`
|
|
7
7
|
measures production source, schemas, generated code and scripts; tests are separate.
|
|
8
8
|
Line/byte reductions are not measurements of latency, resident memory, provider
|
|
9
9
|
cost, or throughput.
|
|
@@ -36,7 +36,13 @@ speedup claim. The long-lived costs remain explicit: a main reconciles receipts
|
|
|
36
36
|
when watched completion inputs change and stats those inputs each second, native
|
|
37
37
|
actors check their parent each second and scan durable commands on change, and the
|
|
38
38
|
broker maintains connections/presence. Completed actors stay messageable
|
|
39
|
-
while attached; observed owner detachment ends their lifetime.
|
|
39
|
+
while attached; observed owner detachment ends their lifetime. On the fork, when native
|
|
40
|
+
working-session capture and broker parking support are both available, a child
|
|
41
|
+
idle for five minutes parks its exact working state and exits, releasing its
|
|
42
|
+
process and heap. On official Pi, or with an older broker that lacks parking
|
|
43
|
+
support, idle children stay resident. A genuine message or continuation restores that exact
|
|
44
|
+
state at the cost of one cold actor start. No retained measurement quantifies
|
|
45
|
+
the memory released or the wake latency. Native lifecycle
|
|
40
46
|
and live acceptance checks verify disposal/exit, rather than inferring cleanup
|
|
41
47
|
from a timer or package size.
|
|
42
48
|
|
|
@@ -137,6 +143,28 @@ The [issue 248 evidence](evidence/issue-248/README.md) includes both waits,
|
|
|
137
143
|
switch-to-first-tool measurements, ranges, profiler results, exact runtime and
|
|
138
144
|
build identities, reproducible commands, and retained synthetic RPC transcripts.
|
|
139
145
|
|
|
146
|
+
### Empty-owner idle CPU (#238)
|
|
147
|
+
|
|
148
|
+
The issue 238 idle ceiling of 30 ms CPU per 10 s still fails against the
|
|
149
|
+
observer-only floor on official Pi 1.4.0 and the maintained fork. No runtime
|
|
150
|
+
change or waiver is claimed. In the sampled configurations, most of the
|
|
151
|
+
increment is a one-shot pair of V8 memory-reducer collections about 8 s after
|
|
152
|
+
`session_start`. An isolated package that only imports `typebox` showed a
|
|
153
|
+
128,968-byte C++ heap reading at `session_start`, followed by the same pair of
|
|
154
|
+
collections. That is consistent with Pi's bundled Jiti 2.7.0 loader compiling it
|
|
155
|
+
through `vm.runInThisContext`, which allocates on the C++ heap. Jiti
|
|
156
|
+
can try a native ESM import first when no transformation is needed, so this is
|
|
157
|
+
not a rule for every extension or host import. That two-line package added
|
|
158
|
+
38–41 ms in the same campaign. Against it,
|
|
159
|
+
the measured `bb38f682` artifact (build `fc084c36`) added 20.7 ms on official
|
|
160
|
+
Pi and 15.0 ms on the fork. Most of that difference is one-shot work right
|
|
161
|
+
after `session_start`, chiefly the intercom broker and owner attachment. In two
|
|
162
|
+
official-host 40 s floor/`0b6da9b` pairs, the later windows of about 10 s each
|
|
163
|
+
stayed within 1.1 ms of the paired floor. There is no fork long run, and this
|
|
164
|
+
is not an indefinite-idle bound. The
|
|
165
|
+
[issue 238 evidence](evidence/issue-238-idle/README.md) has the samples, host
|
|
166
|
+
hashes, native causal chain and limits.
|
|
167
|
+
|
|
140
168
|
## Necessary costs
|
|
141
169
|
|
|
142
170
|
- Native Pi owns context loading and model execution. The extension cannot remove
|
package/docs/reference.md
CHANGED
|
@@ -96,7 +96,18 @@ acceptance is not admission, and admission is not model consumption. Inspect the
|
|
|
96
96
|
if guidance became stale; do not infer acceptance from the old completed output.
|
|
97
97
|
|
|
98
98
|
A completed child can remain alive and messageable while its main remains
|
|
99
|
-
attached.
|
|
99
|
+
attached. On a Pi fork with native working-session capture, a child that stays
|
|
100
|
+
idle for five minutes (no run, turn, journal change, live viewer or owned shell)
|
|
101
|
+
parks: it saves its exact working state and its process exits, while its intercom
|
|
102
|
+
identity stays listed with no departure. Passive messages, topic updates and
|
|
103
|
+
`last` reads do not wake it; the main answers `last` from the saved history, and
|
|
104
|
+
without its main the read reports that history unavailable. Receipt reads also do
|
|
105
|
+
not wake it and report `publication_unavailable`. A
|
|
106
|
+
normal message, ask or continuation restores the exact saved state into a new
|
|
107
|
+
process, which then receives retained messages in order. If the final save
|
|
108
|
+
fails, the child records `park-failed.json` and nothing restarts implicitly;
|
|
109
|
+
retained messages stay queued and continuing reports the failure. Official Pi
|
|
110
|
+
keeps idle children resident. Parent exit or session replacement stops its actors and verified owned
|
|
100
111
|
native shells, allowing native abort a short grace period before escalation.
|
|
101
112
|
Actors also watch for parent death, including a hard kill. Ordinary extension
|
|
102
113
|
reload keeps children alive because the parent has not exited.
|
package/package.json
CHANGED
package/scripts/measure-size.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { extname, join } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const roots = ["src", "scripts", "generated"];
|
|
6
6
|
const extensions = new Set([".ts", ".js", ".mjs", ".cjs", ".json", ".sh"]);
|
|
7
|
-
const ceilings = {
|
|
7
|
+
const ceilings = { productionBytes: 1048576 };
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @param {string} directory
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import type {
|
|
3
|
+
CreateAgentSessionRuntimeFactory,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { Launch } from "./contracts.ts";
|
|
8
|
+
import type { loadSDK, WorkingState } from "./host.ts";
|
|
9
|
+
import { shellCommand } from "./process.ts";
|
|
10
|
+
import { localCwdOwner } from "./cwd.ts";
|
|
11
|
+
import type { ToolEvent } from "./observation-state.ts";
|
|
12
|
+
|
|
13
|
+
function guard(pi: ExtensionAPI, directory: string, token: string): void {
|
|
14
|
+
pi.on("tool_call", (event) => {
|
|
15
|
+
if (event.toolName === "delegate" || event.toolName === "agent_runs") {
|
|
16
|
+
return { block: true, reason: "Only a main session may delegate or control children." };
|
|
17
|
+
}
|
|
18
|
+
const owner = pi.getAllTools().find((tool) => tool.name === "bash")?.sourceInfo;
|
|
19
|
+
if (
|
|
20
|
+
event.toolName === "bash" &&
|
|
21
|
+
typeof event.input.command === "string" &&
|
|
22
|
+
owner &&
|
|
23
|
+
(owner.source === "builtin" || localCwdOwner(owner))
|
|
24
|
+
) {
|
|
25
|
+
// Preserve the selected local owner's cwd/env and leave remote/custom tools alone.
|
|
26
|
+
const input = event.input;
|
|
27
|
+
input.command = shellCommand(event.input.command, directory, token);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
interface ChildHooks {
|
|
33
|
+
readonly directory: string;
|
|
34
|
+
readonly token: string;
|
|
35
|
+
/** Exact parked working state; absent means the saved journal alone. */
|
|
36
|
+
readonly saved: WorkingState | undefined;
|
|
37
|
+
readonly started: (pi: ExtensionAPI, ctx: ExtensionContext) => void;
|
|
38
|
+
readonly tool: (event: ToolEvent) => void;
|
|
39
|
+
}
|
|
40
|
+
/** One native child session with this package's child policy and live-view hooks. */
|
|
41
|
+
export function childRuntime(
|
|
42
|
+
sdk: Awaited<ReturnType<typeof loadSDK>>,
|
|
43
|
+
launch: Launch,
|
|
44
|
+
hooks: ChildHooks,
|
|
45
|
+
): CreateAgentSessionRuntimeFactory {
|
|
46
|
+
return async ({ sessionManager }) => {
|
|
47
|
+
// Exact restore keeps settings, tool policy and accepted queues; it never replays input.
|
|
48
|
+
const restore = hooks.saved ? { workingSession: hooks.saved } : {};
|
|
49
|
+
if (!fs.existsSync(launch.sessionFile)) {
|
|
50
|
+
throw new Error("Missing saved native session; refusing to create another.");
|
|
51
|
+
}
|
|
52
|
+
const settingsManager = sdk.SettingsManager.create(launch.cwd, launch.agentDir, {
|
|
53
|
+
projectTrusted: launch.trusted,
|
|
54
|
+
});
|
|
55
|
+
const services = await sdk.createAgentSessionServices({
|
|
56
|
+
...restore,
|
|
57
|
+
cwd: launch.cwd,
|
|
58
|
+
agentDir: launch.agentDir,
|
|
59
|
+
settingsManager,
|
|
60
|
+
resourceLoaderOptions: {
|
|
61
|
+
additionalExtensionPaths: [launch.entry],
|
|
62
|
+
extensionFactories: [
|
|
63
|
+
{
|
|
64
|
+
name: "codemode",
|
|
65
|
+
builtin: true,
|
|
66
|
+
factory: sdk.createCodemodeExtension({ mode: "on" }),
|
|
67
|
+
},
|
|
68
|
+
{ name: "tool_search", builtin: true, factory: sdk.createToolSearchExtension() },
|
|
69
|
+
{ name: "mcp", builtin: true, factory: sdk.createMcpExtension() },
|
|
70
|
+
(pi) => {
|
|
71
|
+
guard(pi, hooks.directory, hooks.token);
|
|
72
|
+
pi.on("session_start", (_event, ctx) => {
|
|
73
|
+
hooks.started(pi, ctx);
|
|
74
|
+
});
|
|
75
|
+
// Sparse tool lifecycle keeps the live view's tools even while nobody watches.
|
|
76
|
+
pi.on("tool_execution_start", hooks.tool);
|
|
77
|
+
pi.on("tool_execution_update", hooks.tool);
|
|
78
|
+
pi.on("tool_execution_end", hooks.tool);
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const diagnostics = services.resourceLoader.getExtensions().errors;
|
|
84
|
+
if (diagnostics.length > 0) {
|
|
85
|
+
throw new Error(`Child extension load failed: ${JSON.stringify(diagnostics)}`);
|
|
86
|
+
}
|
|
87
|
+
const model = services.modelRuntime.getModel(launch.provider, launch.model);
|
|
88
|
+
if (!model) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Saved model ${launch.provider}/${launch.model} is unavailable; no fallback was used.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...(await sdk.createAgentSessionFromServices({
|
|
95
|
+
...restore,
|
|
96
|
+
services,
|
|
97
|
+
sessionManager,
|
|
98
|
+
model,
|
|
99
|
+
thinkingLevel: launch.thinking,
|
|
100
|
+
})),
|
|
101
|
+
services,
|
|
102
|
+
diagnostics: services.diagnostics,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/agent/contracts.ts
CHANGED
|
@@ -103,6 +103,16 @@ export const commandSchema = Type.Object({
|
|
|
103
103
|
});
|
|
104
104
|
export type Command = Static<typeof commandSchema>;
|
|
105
105
|
export const successorSchema = Type.Object({ id: Type.String() });
|
|
106
|
+
/** Authority to restore exact working state, valid only after the named actor has exited. */
|
|
107
|
+
/** Exact parked authority: one exited generation's state bytes for this session and leaf. */
|
|
108
|
+
export const parkedSchema = Type.Object({
|
|
109
|
+
token: Type.String(),
|
|
110
|
+
state: Type.String(),
|
|
111
|
+
digest: Type.String(),
|
|
112
|
+
sessionId: Type.String(),
|
|
113
|
+
leafId: Type.Union([Type.String(), Type.Null()]),
|
|
114
|
+
});
|
|
115
|
+
export type Parked = Static<typeof parkedSchema>;
|
|
106
116
|
export const exitSchema = Type.Object({
|
|
107
117
|
pid: Type.Integer(),
|
|
108
118
|
code: Type.Union([Type.Integer(), Type.Null()]),
|
package/src/agent/host.ts
CHANGED
|
@@ -9,8 +9,28 @@ import { readIntercomLineage } from "../pi-intercom/identity.ts";
|
|
|
9
9
|
|
|
10
10
|
export { errorMessage as errorText } from "../shared/unknown.ts";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** Fork-only exact native working state; official Pi lacks it, so its children stay resident. */
|
|
13
|
+
interface LocalWorkingState {
|
|
14
|
+
readonly header: { readonly id: string };
|
|
15
|
+
readonly sessionFile?: string;
|
|
16
|
+
readonly leafId: string | null;
|
|
17
|
+
readonly entries: readonly SDK.SessionEntry[];
|
|
18
|
+
}
|
|
19
|
+
type NativeWorkingState = typeof SDK extends { readWorkingSession: (file: string) => infer S }
|
|
20
|
+
? S
|
|
21
|
+
: never;
|
|
22
|
+
export type WorkingState = [NativeWorkingState] extends [never]
|
|
23
|
+
? LocalWorkingState
|
|
24
|
+
: NativeWorkingState;
|
|
25
|
+
// Working sessions stay optional even when the selected graph declares them, so both hosts
|
|
26
|
+
// keep one checked path. The native resolver is optional until official Pi exports it.
|
|
27
|
+
type HostSDK = Omit<
|
|
28
|
+
typeof SDK,
|
|
29
|
+
"readWorkingSession" | "writeWorkingSession" | "openWorkingSession"
|
|
30
|
+
> & {
|
|
31
|
+
readWorkingSession?: (file: string) => WorkingState;
|
|
32
|
+
writeWorkingSession?: (file: string, state: WorkingState) => void;
|
|
33
|
+
openWorkingSession?: (state: WorkingState) => SDK.SessionManager;
|
|
14
34
|
resolveModelReference?: (
|
|
15
35
|
request: { text: string; provider: string; thinkingSuffix: "strict"; fuzzy: false },
|
|
16
36
|
models: readonly Model<Api>[],
|
package/src/agent/index.ts
CHANGED
|
@@ -11,11 +11,13 @@ import {
|
|
|
11
11
|
alive,
|
|
12
12
|
ensureActor,
|
|
13
13
|
ownIdentity,
|
|
14
|
+
parkFailure,
|
|
14
15
|
requestStop,
|
|
15
16
|
signalProcess,
|
|
16
17
|
stopChildren,
|
|
17
18
|
} from "./process.ts";
|
|
18
19
|
import { isChild, errorText } from "./host.ts";
|
|
20
|
+
import { serveParked } from "./parking.ts";
|
|
19
21
|
import {
|
|
20
22
|
resultSchema,
|
|
21
23
|
attachmentSchema,
|
|
@@ -36,6 +38,7 @@ import {
|
|
|
36
38
|
runDirectory,
|
|
37
39
|
ownerDirectory,
|
|
38
40
|
conversation,
|
|
41
|
+
successor,
|
|
39
42
|
write,
|
|
40
43
|
} from "./storage.ts";
|
|
41
44
|
import { notifications, deliver, settle } from "./notifications.ts";
|
|
@@ -335,22 +338,6 @@ class AgentRuntime {
|
|
|
335
338
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
336
339
|
return renderInventory(this.base, all, this.ctx, input);
|
|
337
340
|
}
|
|
338
|
-
private successor(launch: Launch, prompt: string): Launch | undefined {
|
|
339
|
-
const id = randomUUID();
|
|
340
|
-
const next: Launch = { ...launch, id, predecessor: launch.id, prompt, createdAt: Date.now() };
|
|
341
|
-
fs.mkdirSync(runDirectory(this.base, id), { mode: 0o700 });
|
|
342
|
-
write(path.join(runDirectory(this.base, id), "launch.json"), next, true);
|
|
343
|
-
try {
|
|
344
|
-
write(path.join(runDirectory(this.base, launch.id), "successor.json"), { id }, true);
|
|
345
|
-
} catch (error) {
|
|
346
|
-
fs.rmSync(runDirectory(this.base, id), { recursive: true });
|
|
347
|
-
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) {
|
|
348
|
-
throw error;
|
|
349
|
-
}
|
|
350
|
-
return undefined;
|
|
351
|
-
}
|
|
352
|
-
return next;
|
|
353
|
-
}
|
|
354
341
|
private continuationResult(launch: Launch): Result | "pending" | undefined {
|
|
355
342
|
const result = read(path.join(runDirectory(this.base, launch.id), "result.json"), resultSchema);
|
|
356
343
|
if (result) {
|
|
@@ -406,7 +393,8 @@ class AgentRuntime {
|
|
|
406
393
|
note: "Guidance queued, not actual completion or model consumption.",
|
|
407
394
|
});
|
|
408
395
|
}
|
|
409
|
-
|
|
396
|
+
parkFailure(this.base, launch);
|
|
397
|
+
const next = successor(this.base, launch, input.prompt);
|
|
410
398
|
if (!next) {
|
|
411
399
|
return renderReceipt(this.base, latest(this.base, launch), this.ctx, {
|
|
412
400
|
receipt: "existing-successor",
|
|
@@ -465,6 +453,11 @@ class AgentRuntime {
|
|
|
465
453
|
}
|
|
466
454
|
register(): void {
|
|
467
455
|
registerCompactMessages(this.pi, ["agent-completion", "subagent-notify"]);
|
|
456
|
+
serveParked(
|
|
457
|
+
this.pi,
|
|
458
|
+
() => (this.child || !this.ctx ? undefined : { base: this.base, owner: this.owner }),
|
|
459
|
+
this.shutdown.signal,
|
|
460
|
+
);
|
|
468
461
|
this.pi.on("session_start", (_event, context) => this.start(context));
|
|
469
462
|
this.pi.on("turn_end", (event, context) => {
|
|
470
463
|
// The public pending flag covers user input; the preview also includes custom messages.
|
package/src/agent/observation.ts
CHANGED
|
@@ -168,6 +168,10 @@ export class ActorObservation {
|
|
|
168
168
|
const { session } = this;
|
|
169
169
|
return this.running || session.isStreaming || session.isCompacting || session.isRetrying;
|
|
170
170
|
}
|
|
171
|
+
/** A live viewer keeps its actor resident; parking would end the native stream it reads. */
|
|
172
|
+
get watched(): boolean {
|
|
173
|
+
return this.viewers.size + this.greeting.size > 0;
|
|
174
|
+
}
|
|
171
175
|
private details(): number {
|
|
172
176
|
return [...this.viewers].filter((viewer) => viewer.level === "detail").length;
|
|
173
177
|
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { AgentSession, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { parkedSchema, type Launch, type Parked } from "./contracts.ts";
|
|
6
|
+
import { errorText, loadSDK, type WorkingState } from "./host.ts";
|
|
7
|
+
import { conversation, latest, launchAt, list, read, runDirectory, write } from "./storage.ts";
|
|
8
|
+
import { alive, shells, wakeActor } from "./process.ts";
|
|
9
|
+
import {
|
|
10
|
+
isHistoryRequest,
|
|
11
|
+
PARK,
|
|
12
|
+
PARKED_HISTORY,
|
|
13
|
+
WAKE,
|
|
14
|
+
type ParkRequest,
|
|
15
|
+
} from "../shared/parking.ts";
|
|
16
|
+
import { nativePeerId } from "../pi-intercom/session-targets.ts";
|
|
17
|
+
import type { NativeHistory } from "../pi-intercom/last-message.ts";
|
|
18
|
+
|
|
19
|
+
type SDK = Awaited<ReturnType<typeof loadSDK>>;
|
|
20
|
+
type Restore = NonNullable<SDK["readWorkingSession"]>;
|
|
21
|
+
export type Runtime = Awaited<ReturnType<SDK["createAgentSessionRuntime"]>>;
|
|
22
|
+
type SessionManager = Parameters<SDK["createAgentSessionRuntime"]>[1]["sessionManager"];
|
|
23
|
+
interface WorkingHold {
|
|
24
|
+
readonly state: WorkingState;
|
|
25
|
+
readonly sleepReady: boolean;
|
|
26
|
+
readonly blockers: readonly string[];
|
|
27
|
+
readonly assertHeld: () => void;
|
|
28
|
+
readonly release: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** Fork-only native cut and strict final exit; official Pi children stay resident. */
|
|
31
|
+
type Parkable = AgentSession & {
|
|
32
|
+
readonly acquireWorkingSession: (options: {
|
|
33
|
+
boundary: "settled";
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}) => Promise<WorkingHold>;
|
|
36
|
+
};
|
|
37
|
+
/** The runner's view of its own work; parking never admits or runs commands itself. */
|
|
38
|
+
interface Resident {
|
|
39
|
+
readonly pi: ExtensionAPI | undefined;
|
|
40
|
+
readonly busy: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* False once queued commands, work, stopping or a viewer appeared. Never admits: a prompt
|
|
43
|
+
* started under the native hold fails, so the runner admits only after release.
|
|
44
|
+
*/
|
|
45
|
+
readonly quiet: () => boolean;
|
|
46
|
+
readonly errors: string[];
|
|
47
|
+
/** Aborts preparation once the owner stops or dies. */
|
|
48
|
+
readonly signal: AbortSignal;
|
|
49
|
+
}
|
|
50
|
+
interface Finalizing {
|
|
51
|
+
readonly setFinalization: (
|
|
52
|
+
finalization: (session: AgentSession) => Promise<void>,
|
|
53
|
+
options: { strictShutdown: boolean },
|
|
54
|
+
) => void;
|
|
55
|
+
}
|
|
56
|
+
function parkable(session: AgentSession): session is Parkable {
|
|
57
|
+
return typeof Reflect.get(session, "acquireWorkingSession") === "function";
|
|
58
|
+
}
|
|
59
|
+
function finalizing(runtime: Runtime): runtime is Runtime & Finalizing {
|
|
60
|
+
return typeof Reflect.get(runtime, "setFinalization") === "function";
|
|
61
|
+
}
|
|
62
|
+
function fence(pi: ExtensionAPI, park: boolean): Promise<boolean> {
|
|
63
|
+
let fenced = Promise.resolve(false);
|
|
64
|
+
pi.events.emit(PARK, {
|
|
65
|
+
park,
|
|
66
|
+
reply: (parked) => {
|
|
67
|
+
fenced = parked;
|
|
68
|
+
},
|
|
69
|
+
} satisfies ParkRequest);
|
|
70
|
+
return fenced;
|
|
71
|
+
}
|
|
72
|
+
async function cut(
|
|
73
|
+
pi: ExtensionAPI,
|
|
74
|
+
session: Parkable,
|
|
75
|
+
{ quiet, errors, signal }: Omit<Resident, "pi" | "busy">,
|
|
76
|
+
): Promise<WorkingHold | undefined> {
|
|
77
|
+
try {
|
|
78
|
+
// Save handlers may legitimately append entries; only the pre-cut leaf proves idleness.
|
|
79
|
+
if ((await fence(pi, true)) && quiet() && session.isIdle) {
|
|
80
|
+
return await session.acquireWorkingSession({ boundary: "settled", signal });
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
errors.push(errorText(error));
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const PARK_AFTER_MS = 5 * 60_000;
|
|
88
|
+
const digest = (file: string): string =>
|
|
89
|
+
createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
90
|
+
|
|
91
|
+
/** Parked authority names this session and exactly the saved generation's state bytes. */
|
|
92
|
+
function exact(
|
|
93
|
+
restore: Restore,
|
|
94
|
+
parked: Parked,
|
|
95
|
+
state: string,
|
|
96
|
+
launch: Launch,
|
|
97
|
+
): WorkingState | undefined {
|
|
98
|
+
if (
|
|
99
|
+
parked.sessionId !== launch.sessionId ||
|
|
100
|
+
parked.state !== state ||
|
|
101
|
+
digest(state) !== parked.digest
|
|
102
|
+
) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const saved = restore(state);
|
|
106
|
+
return saved.sessionFile === launch.sessionFile &&
|
|
107
|
+
saved.header.id === launch.sessionId &&
|
|
108
|
+
saved.leafId === parked.leafId
|
|
109
|
+
? saved
|
|
110
|
+
: undefined;
|
|
111
|
+
}
|
|
112
|
+
/** This child's exact parked generation, if any; mismatched authority restores nothing. */
|
|
113
|
+
export function parkedState(
|
|
114
|
+
dir: string,
|
|
115
|
+
launch: Launch,
|
|
116
|
+
restore: Restore | undefined,
|
|
117
|
+
): { readonly state: string; readonly saved: WorkingState } | undefined {
|
|
118
|
+
const parked = read(path.join(dir, "parked.json"), parkedSchema);
|
|
119
|
+
if (!parked) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (!restore) {
|
|
123
|
+
throw new Error("Parked working state needs the Pi fork that saved it; nothing was reopened.");
|
|
124
|
+
}
|
|
125
|
+
// Native working state requires a canonical parent, not a symlinked alias like macOS /var.
|
|
126
|
+
const state = path.join(fs.realpathSync(dir), `working-${parked.token}.json`);
|
|
127
|
+
const saved = exact(restore, parked, state, launch);
|
|
128
|
+
if (!saved) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"Parked working state is not this child's saved generation; nothing was reopened.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { state, saved };
|
|
134
|
+
}
|
|
135
|
+
/** One actor generation's idle parking and exact restore; commands stay with the runner. */
|
|
136
|
+
export class Parking {
|
|
137
|
+
saved?: WorkingState;
|
|
138
|
+
private restored?: string;
|
|
139
|
+
private since = 0;
|
|
140
|
+
private leaf?: string | null;
|
|
141
|
+
private readonly base: string;
|
|
142
|
+
private readonly launch: Launch;
|
|
143
|
+
private readonly token: string;
|
|
144
|
+
private readonly sdk: SDK;
|
|
145
|
+
constructor(base: string, launch: Launch, token: string, sdk: SDK) {
|
|
146
|
+
this.base = base;
|
|
147
|
+
this.launch = launch;
|
|
148
|
+
this.token = token;
|
|
149
|
+
this.sdk = sdk;
|
|
150
|
+
}
|
|
151
|
+
private get dir(): string {
|
|
152
|
+
return runDirectory(this.base, this.launch.rootId);
|
|
153
|
+
}
|
|
154
|
+
private get file(): string {
|
|
155
|
+
return path.join(this.dir, "parked.json");
|
|
156
|
+
}
|
|
157
|
+
/** Parked authority is exact state or nothing; never a journal-only reopen. */
|
|
158
|
+
open(): SessionManager {
|
|
159
|
+
const { sdk, launch } = this;
|
|
160
|
+
const { openWorkingSession: restore } = sdk;
|
|
161
|
+
const parked = parkedState(this.dir, launch, restore && sdk.readWorkingSession);
|
|
162
|
+
if (!parked || !restore) {
|
|
163
|
+
return sdk.SessionManager.open(launch.sessionFile, undefined, launch.cwd);
|
|
164
|
+
}
|
|
165
|
+
this.saved = parked.saved;
|
|
166
|
+
this.restored = parked.state;
|
|
167
|
+
return restore(parked.saved);
|
|
168
|
+
}
|
|
169
|
+
/** The ready restored generation owns this state now; a later park writes a new cut. */
|
|
170
|
+
adopt(): void {
|
|
171
|
+
if (this.restored !== undefined) {
|
|
172
|
+
fs.rmSync(this.file);
|
|
173
|
+
fs.rmSync(this.restored, { force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** True once this generation has exited its runtime with or without parked authority. */
|
|
177
|
+
async park(runtime: Runtime, actor: Resident): Promise<boolean> {
|
|
178
|
+
if (
|
|
179
|
+
!actor.pi ||
|
|
180
|
+
!this.due(runtime.session, actor.busy) ||
|
|
181
|
+
shells(this.base, this.launch, this.token).some(alive)
|
|
182
|
+
) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const hold = await this.prepare(runtime, actor.pi, actor);
|
|
186
|
+
if (hold) {
|
|
187
|
+
await this.commit(runtime, hold, actor.errors);
|
|
188
|
+
}
|
|
189
|
+
return hold !== undefined;
|
|
190
|
+
}
|
|
191
|
+
/** Final idle: no run, turn, journal change or viewer since the period began. */
|
|
192
|
+
private due(session: AgentSession, busy: boolean): boolean {
|
|
193
|
+
const leaf = session.sessionManager.getLeafId();
|
|
194
|
+
if (busy || !session.isIdle || leaf !== this.leaf) {
|
|
195
|
+
this.leaf = leaf;
|
|
196
|
+
this.since = performance.now();
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return performance.now() - this.since >= PARK_AFTER_MS;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Fence broker input, then hold a settled sleep-ready cut of the same quiet idle leaf. Any
|
|
203
|
+
* failure or raced command releases, unfences and keeps this actor resident.
|
|
204
|
+
*/
|
|
205
|
+
private async prepare(
|
|
206
|
+
runtime: Runtime,
|
|
207
|
+
pi: ExtensionAPI,
|
|
208
|
+
{ quiet, errors, signal }: Resident,
|
|
209
|
+
): Promise<WorkingHold | undefined> {
|
|
210
|
+
const { session } = runtime;
|
|
211
|
+
this.since = performance.now();
|
|
212
|
+
if (!parkable(session) || !this.sdk.writeWorkingSession || !finalizing(runtime)) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
// A launcher that enqueues after this marker waits for exit, then wakes the saved state.
|
|
216
|
+
const marker = path.join(this.dir, "actor.lock", "parking.json");
|
|
217
|
+
write(marker, { at: Date.now() });
|
|
218
|
+
const hold = await cut(pi, session, {
|
|
219
|
+
quiet: () => quiet() && session.sessionManager.getLeafId() === this.leaf,
|
|
220
|
+
errors,
|
|
221
|
+
signal,
|
|
222
|
+
});
|
|
223
|
+
// Revalidate after the awaited fence and save, immediately before the irreversible commit.
|
|
224
|
+
if (hold?.sleepReady === true && quiet()) {
|
|
225
|
+
return hold;
|
|
226
|
+
}
|
|
227
|
+
await hold?.release();
|
|
228
|
+
await fence(pi, false);
|
|
229
|
+
fs.rmSync(marker, { force: true });
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
/** Commit: native strict exit, then capture; failure leaves no parked authority. */
|
|
233
|
+
private async commit(runtime: Runtime, hold: WorkingHold, errors: string[]): Promise<void> {
|
|
234
|
+
const save = this.sdk.writeWorkingSession;
|
|
235
|
+
if (save && finalizing(runtime)) {
|
|
236
|
+
runtime.setFinalization((final) => this.capture(final, save), { strictShutdown: true });
|
|
237
|
+
}
|
|
238
|
+
// No await between release and dispose: native final exit fences new turns in this tick.
|
|
239
|
+
const released = hold.release();
|
|
240
|
+
const disposed = runtime.dispose();
|
|
241
|
+
try {
|
|
242
|
+
await Promise.all([released, disposed]);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
errors.push(errorText(error));
|
|
245
|
+
}
|
|
246
|
+
if (read(this.file, parkedSchema)?.token !== this.token) {
|
|
247
|
+
// Truthful boundary: retained input stays queued and nothing restarts implicitly.
|
|
248
|
+
write(path.join(this.dir, "park-failed.json"), { token: this.token, errors });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
private async capture(
|
|
252
|
+
session: AgentSession,
|
|
253
|
+
save: (file: string, state: WorkingState) => void,
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
if (!parkable(session)) {
|
|
256
|
+
throw new Error("Native working state is unavailable.");
|
|
257
|
+
}
|
|
258
|
+
const hold = await session.acquireWorkingSession({ boundary: "settled" });
|
|
259
|
+
try {
|
|
260
|
+
if (!hold.sleepReady) {
|
|
261
|
+
throw new Error(`Final working state is not sleep-ready: ${hold.blockers.join("; ")}`);
|
|
262
|
+
}
|
|
263
|
+
const state = path.join(fs.realpathSync(this.dir), `working-${this.token}.json`);
|
|
264
|
+
save(state, hold.state);
|
|
265
|
+
hold.assertHeld();
|
|
266
|
+
write(this.file, {
|
|
267
|
+
token: this.token,
|
|
268
|
+
state,
|
|
269
|
+
digest: digest(state),
|
|
270
|
+
sessionId: this.launch.sessionId,
|
|
271
|
+
leafId: hold.state.leafId,
|
|
272
|
+
});
|
|
273
|
+
} finally {
|
|
274
|
+
await hold.release();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
interface Runs {
|
|
279
|
+
readonly base: string;
|
|
280
|
+
readonly owner: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The broker names a parked child by native peer ID; only this owner's conversations match. */
|
|
284
|
+
function ownedRun(runs: Runs | undefined, peer: unknown): Launch | undefined {
|
|
285
|
+
const launch =
|
|
286
|
+
runs && typeof peer === "string"
|
|
287
|
+
? list(runs.base)
|
|
288
|
+
.map((id) => launchAt(runs.base, id, runs.owner))
|
|
289
|
+
.find((candidate) => nativePeerId(candidate.sessionId) === peer)
|
|
290
|
+
: undefined;
|
|
291
|
+
return launch && runs && latest(runs.base, launch);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The captured leaf's root-to-leaf path, as a live session manager reports it. */
|
|
295
|
+
function branchOf(state: WorkingState): ReturnType<NativeHistory["getBranch"]> {
|
|
296
|
+
const entries = new Map(state.entries.map((entry) => [entry.id, entry]));
|
|
297
|
+
const branch = [];
|
|
298
|
+
for (
|
|
299
|
+
let entry = entries.get(state.leafId ?? "");
|
|
300
|
+
entry !== undefined;
|
|
301
|
+
entry = entries.get(entry.parentId ?? "")
|
|
302
|
+
) {
|
|
303
|
+
branch.unshift(entry);
|
|
304
|
+
}
|
|
305
|
+
return branch;
|
|
306
|
+
}
|
|
307
|
+
async function history(runs: Runs | undefined, peer: string): Promise<NativeHistory | undefined> {
|
|
308
|
+
const launch = ownedRun(runs, peer);
|
|
309
|
+
const restore = launch && (await loadSDK(launch.sdk)).readWorkingSession;
|
|
310
|
+
const state =
|
|
311
|
+
runs && launch && parkedState(conversation(runs.base, launch), launch, restore)?.saved;
|
|
312
|
+
return state && { getBranch: () => branchOf(state) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Wakes this owner's parked children for broker input and reads their saved history.
|
|
317
|
+
* `runs` is undefined while this session owns no children.
|
|
318
|
+
*/
|
|
319
|
+
export function serveParked(
|
|
320
|
+
pi: ExtensionAPI,
|
|
321
|
+
runs: () => Runs | undefined,
|
|
322
|
+
signal: AbortSignal,
|
|
323
|
+
): void {
|
|
324
|
+
pi.events.on(WAKE, (peer) => {
|
|
325
|
+
// On failure, retained input stays queued at the broker until the next main registration.
|
|
326
|
+
(async () => {
|
|
327
|
+
const current = runs();
|
|
328
|
+
const launch = ownedRun(current, peer);
|
|
329
|
+
if (current && launch) {
|
|
330
|
+
await wakeActor(current.base, launch, signal);
|
|
331
|
+
}
|
|
332
|
+
})().catch((error: unknown) => {
|
|
333
|
+
console.error(`Agent wake: ${errorText(error)}`);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
pi.events.on(PARKED_HISTORY, (request) => {
|
|
337
|
+
if (isHistoryRequest(request)) {
|
|
338
|
+
request.reply(history(runs(), request.peer));
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|