mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
p2 never kept prompt caches warm: every request after more than five quiet
minutes rewrote the whole prompt (about a quarter of p2's spend in the
sessions analyzed). The new cache-warming plugin does what pi-coding-agent's
cache warmer does, per conversation.
- pi-ai: the warming policy (TTL, refresh delay, replayability, refresh
options, cost decision and constants) moves from pi-coding-agent's
cache warmer to shared helpers in pi-ai; pi-coding-agent uses them.
- pi-durable: afterResponse gets the request the attempt sent
(GenerationResponse { message, request }), so a hook can repeat it;
recordUsage is exported.
- p2: plugins/cache-warming registers a hook-only Durable extension,
keeps the worker alive while warming (liveness kind cacheWarming), and
adds the cacheWarming setting (idle by default, streaming, off).
802 lines
33 KiB
TypeScript
802 lines
33 KiB
TypeScript
// Locals, registries and natives a session worker host provides to plugins (Chord v2), and the worker's core facet.
|
|
import { existsSync } from "node:fs";
|
|
import { defineRegistry, local, type Native, type RegistryValue } from "@earendil-works/chord";
|
|
import type { Context } from "@earendil-works/chord/context";
|
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
import type { JsonValue } from "@earendil-works/chord/json";
|
|
import type { Watchable, WatchCall, WatchHandle } from "@earendil-works/chord/watch";
|
|
import type { Models } from "@earendil-works/pi-ai/models";
|
|
import {
|
|
type ConversationId,
|
|
configure,
|
|
type DocumentReader,
|
|
type Conversation as DurableConversation,
|
|
Harness as DurableHarness,
|
|
type HarnessSettings,
|
|
ROOT_CONVERSATION_ID,
|
|
} from "@earendil-works/pi-durable";
|
|
import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
|
|
import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
|
|
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
import { Connection, LocalExecutionEnv, localDaemon, RemoteExecutionEnv } from "@earendil-works/pi-env";
|
|
import {
|
|
type Ctx,
|
|
defineFacet,
|
|
type Facet,
|
|
type FacetHost,
|
|
forbidden,
|
|
keyedRegistry,
|
|
type Self,
|
|
serveKeyed,
|
|
} from "./chord.ts";
|
|
import { durableContext, durableRegistry, ExtensionSwitches, Extensions } from "./durable.ts";
|
|
import { isRooted, type Location, type LocationInput, sameLocation } from "./location.ts";
|
|
import { topLevelConversations } from "./main-conversation.ts";
|
|
import { type SessionInit, WorkerBoundary } from "./session-worker.ts";
|
|
|
|
export { ExtensionSwitches, Extensions } from "./durable.ts";
|
|
export { type Location, LocationSchema, locationText, sameLocation } from "./location.ts";
|
|
export { type SessionInit, WorkerBoundary } from "./session-worker.ts";
|
|
|
|
/** A worker's start data (JSON, from the server's `start`): `session` from the sessions plugin, more keys from others. */
|
|
export type WorkerInit = {
|
|
/** The p2 app directory (`apps/p2`): its sources, docs and vendored files, for facets that start or describe them. */
|
|
readonly app: string;
|
|
readonly dataDir: string;
|
|
readonly fake: boolean;
|
|
readonly generation: number;
|
|
readonly values: { readonly session: SessionInit } & Readonly<Record<string, JsonValue>>;
|
|
};
|
|
|
|
/**
|
|
* Where the session works now. A worker starts at its spawn location (`SessionInit`) and follows `Sessions.relocate`
|
|
* in place (`Harness.relocate`): plugins read `value` at each use instead of the spawn parameters, and subscribe to
|
|
* reopen what they keep for a location (prompt files, workspace watches). Listeners run once the move is committed:
|
|
* the Harness's environments and the conversations' agent cwds are already at the new location.
|
|
*
|
|
* During a move the Harness's environments switch before the location is committed, so `value` may still name the
|
|
* old location while `Harness.environments` already resolves on the new machine. Decide what depends on the machine
|
|
* from the environment itself (`isLocalEnvironment`), and key what is kept for a location by `version`: it changes
|
|
* when a move is committed, so anything opened before (also during the switch) is older and opened again.
|
|
*/
|
|
export class SessionLocation {
|
|
#value: Location;
|
|
#version = 0;
|
|
readonly #listeners = new Set<(location: Location) => void>();
|
|
constructor(value: Location) {
|
|
this.#value = value;
|
|
}
|
|
get value(): Location {
|
|
return this.#value;
|
|
}
|
|
/** Counts committed moves. */
|
|
get version(): number {
|
|
return this.#version;
|
|
}
|
|
subscribe(listener: (location: Location) => void): () => void {
|
|
this.#listeners.add(listener);
|
|
return () => this.#listeners.delete(listener);
|
|
}
|
|
/** The next move, or rejects when `signal` aborts first. */
|
|
next(signal: AbortSignal): Promise<Location> {
|
|
return new Promise((resolve, reject) => {
|
|
if (signal.aborted) return reject(signal.reason);
|
|
const done = (): void => {
|
|
stop();
|
|
signal.removeEventListener("abort", abort);
|
|
};
|
|
const abort = (): void => {
|
|
done();
|
|
reject(signal.reason);
|
|
};
|
|
const stop = this.subscribe((location) => {
|
|
done();
|
|
resolve(location);
|
|
});
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
});
|
|
}
|
|
/** Commits a move (the Harness, after switching its environments and the conversations). */
|
|
set(next: Location): void {
|
|
this.#value = next;
|
|
this.#version++;
|
|
for (const listener of [...this.#listeners]) {
|
|
try {
|
|
listener(next);
|
|
} catch (error) {
|
|
console.error("p2 worker: a location listener failed:", error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether `env` runs on this machine (not an SSH environment): p2's private data directory is denied there. Decided
|
|
* from the environment, not from `SessionLocation.value`, which lags behind the environments during a move.
|
|
*/
|
|
export const isLocalEnvironment = (env: ExecutionEnv): boolean => !(env instanceof RemoteExecutionEnv);
|
|
|
|
/** The spawn location of `session`. */
|
|
export const spawnLocation = (session: SessionInit): Location => ({ cwd: session.cwd, env: session.env ?? null });
|
|
|
|
/**
|
|
* Waits before input is submitted to a conversation (all entries, in order). A plugin whose part of the prompt loads
|
|
* asynchronously registers one, so the first message of a fresh worker never runs without it. Example: the prompt
|
|
* plugin waits for its first load of AGENTS.md and skills.
|
|
*/
|
|
export type InputGate = (ctx: Ctx) => Promise<void>;
|
|
export const InputGates = keyedRegistry<InputGate>("p2.input-gates");
|
|
|
|
/** Plugins synchronize metadata after a browser changes a conversation's agent configuration. */
|
|
export const ConversationConfigured =
|
|
keyedRegistry<(conversation: DurableConversation, ctx: Ctx) => Promise<void>>("p2.conversation-configured");
|
|
|
|
/**
|
|
* Plugins mark conversations read-only for people: a reason refuses `Chat`'s send, dequeue and configure (Stop stays
|
|
* allowed). Example: the subagents plugin's one-shot agents, which belong to the script waiting for their answer.
|
|
*/
|
|
export const ReadOnlyConversations =
|
|
keyedRegistry<(conversation: number, ctx: Ctx) => Promise<string | undefined>>("p2.read-only-conversations");
|
|
|
|
/** Model access and run settings for the Harness (the models plugin contributes key "default"). */
|
|
export type HarnessConfigEntry = { readonly models: () => Models; readonly settings: () => HarnessSettings };
|
|
export const HarnessConfig = keyedRegistry<HarnessConfigEntry>("p2.harness-config");
|
|
|
|
/**
|
|
* Where the conversations besides the main one work (the subagents plugin, key "subagents"). A conversation either
|
|
* follows the session (subagents spawned without a cwd, one-shot agents) or has its own location, environment and
|
|
* cwd, fixed at spawn (a subagent spawned with a cwd: on the caller's environment). A contributor must not need the
|
|
* Harness (it would wait for itself): it gets the opening Durable Harness, or a reader of its documents, as an
|
|
* argument. Contributed by a facet (no runtime state), so it is there when the Harness opens.
|
|
*/
|
|
export type ConversationPlacement = {
|
|
/**
|
|
* Commits what an older version left unplaced (a subagent's own cwd without its environment: the environment it was
|
|
* spawned on), then lists the conversations that follow the session and the own locations in use. The opening
|
|
* Harness calls it, and every relocation, and moves the followers whose agent cwd differs to the session's cwd with
|
|
* the root, in one commit, before it is live: nothing can run or resume in a stale cwd first.
|
|
*/
|
|
place(
|
|
harness: DurableHarness,
|
|
session: Location,
|
|
values: WorkerInit["values"],
|
|
context: Context,
|
|
): Promise<{ readonly followers: readonly ConversationId[]; readonly own: readonly Location[] }>;
|
|
/** A conversation's own location, or undefined: it follows the session. */
|
|
own(read: DocumentReader, id: ConversationId, context: Context): Promise<Location | undefined>;
|
|
};
|
|
export const ConversationLocations = keyedRegistry<ConversationPlacement>("p2.conversation-locations");
|
|
|
|
/** One machine's connection: environments at its directories (`at`), and `close` ends the connection. */
|
|
export type Machine = { at: (cwd: string) => ExecutionEnv; close: () => void };
|
|
|
|
/** Execution environments are plugin-provided (key "remote"); a remote location cannot use the local default. */
|
|
export type HarnessEnvironmentEntry = {
|
|
/** The machine of the remote environment `env`. Connecting may wait for its first operation. */
|
|
open: (env: string) => Promise<Machine>;
|
|
/** The SSH host environment `env`'s tools run on, for the prompt's `cwd` section. */
|
|
readonly host?: (env: string) => Promise<string | undefined>;
|
|
};
|
|
export const HarnessEnvironments = keyedRegistry<HarnessEnvironmentEntry>("p2.harness-environments");
|
|
|
|
/**
|
|
* What keeps the worker process alive besides held references (Chord's own count): its Durable tasks, calls under way
|
|
* (`call`), viewers plugins count themselves, conversations whose prompt cache is kept warm, and the relocation fence.
|
|
* A JSON watchable of those counts (`workerMain`'s `busy`): the worker is idle when every count is zero.
|
|
*/
|
|
export class Liveness implements Watchable<Readonly<Record<string, number>>> {
|
|
#relocating = false;
|
|
#stopping = false;
|
|
#counts: Readonly<Record<string, number>> = { activeCalls: 0, viewers: 0, durableTasks: 0, cacheWarming: 0 };
|
|
readonly #listeners = new Set<() => void>();
|
|
get value(): Readonly<Record<string, number>> {
|
|
return this.#counts;
|
|
}
|
|
/** A watch of the counts (only `workerMain` watches): it gets the newest counts at each change. */
|
|
async watch(): Promise<WatchHandle<Readonly<Record<string, number>>>> {
|
|
let listener:
|
|
| ((call: WatchCall<Readonly<Record<string, number>>>, context: Context) => Promise<void>)
|
|
| undefined;
|
|
const notify = (): void => {
|
|
void listener?.(
|
|
{ kind: "value", value: this.#counts, ops: [["r", this.#counts]], retired: false },
|
|
BACKGROUND_CONTEXT,
|
|
);
|
|
};
|
|
const closed = Promise.withResolvers<{ kind: "ended" }>();
|
|
const counts = this;
|
|
return {
|
|
get value() {
|
|
return counts.#counts;
|
|
},
|
|
start: (fn) => {
|
|
listener = fn;
|
|
this.#listeners.add(notify);
|
|
},
|
|
stop: async () => {
|
|
this.#listeners.delete(notify);
|
|
closed.resolve({ kind: "ended" });
|
|
return { kind: "ended" };
|
|
},
|
|
closed: closed.promise,
|
|
};
|
|
}
|
|
#set(kind: string, count: number): void {
|
|
this.#counts = { ...this.#counts, [kind]: count };
|
|
for (const listener of [...this.#listeners]) listener();
|
|
}
|
|
add(kind: "activeCalls" | "viewers" | "cacheWarming", delta: number): void {
|
|
this.#set(kind, (this.#counts[kind] ?? 0) + delta);
|
|
}
|
|
setDurableTasks(count: number): void {
|
|
if (this.#counts.durableTasks !== count) this.#set("durableTasks", count);
|
|
}
|
|
/**
|
|
* Whether the worker process is shutting down (set before its host closes). Disposal code tells a stop, after which
|
|
* Durable resumes interrupted work in the next worker, from a plugin removed by a reload, whose work must end now.
|
|
*/
|
|
get stopping(): boolean {
|
|
return this.#stopping;
|
|
}
|
|
stop(): void {
|
|
this.#stopping = true;
|
|
}
|
|
/** Whether admissions are fenced for a relocation (`WorkerBoundary.prepare` accepted, not yet moved or cancelled). */
|
|
get relocating(): boolean {
|
|
return this.#relocating;
|
|
}
|
|
/**
|
|
* Fence new admissions before checking Durable state; a failed check releases the fence. The fence counts as an
|
|
* active call, so the worker cannot stop idle between the server's prepare and its relocate.
|
|
*/
|
|
freeze(): boolean {
|
|
if (this.#relocating || (this.#counts.activeCalls ?? 0) > 0) return false;
|
|
this.#relocating = true;
|
|
this.add("activeCalls", 1);
|
|
return true;
|
|
}
|
|
unfreeze(): void {
|
|
if (!this.#relocating) return;
|
|
this.#relocating = false;
|
|
this.add("activeCalls", -1);
|
|
}
|
|
/** Runs `fn` as one active call. */
|
|
async call<T>(fn: () => Promise<T>): Promise<T> {
|
|
if (this.#relocating) throw new Error("Working directory is changing; wait before sending another message");
|
|
this.add("activeCalls", 1);
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
this.add("activeCalls", -1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The worker process: its start data, liveness counters, and where the session works now (read `location`). */
|
|
export type WorkerParts = {
|
|
readonly init: WorkerInit;
|
|
readonly liveness: Liveness;
|
|
readonly location: SessionLocation;
|
|
/**
|
|
* Runs `fn` in a root of the worker's own (`HOST`) under `context`: for code outside Chord calls (a Durable tool or
|
|
* section passes its Durable context), or a method acting as the worker (`withoutAbortSignal(ctx)`).
|
|
*/
|
|
readonly root: <T>(fn: (ctx: Ctx) => Promise<T>, context: Context) => Promise<T>;
|
|
};
|
|
|
|
/**
|
|
* The worker process's natives, contributed by the core facet from the start: `self.registry(WorkerProcess).value`
|
|
* (`.init.dataDir`, `.liveness.call(…)`, `.location.value`). Read `location`, not `init.values.session`, for the cwd.
|
|
*/
|
|
export const WorkerProcess = defineRegistry<WorkerParts, WorkerParts | undefined>("p2.worker-process", { version: 1 });
|
|
|
|
/**
|
|
* Refusals of a move to `to` (from the session's current location), checked under the fence before anything moves. A
|
|
* message refuses. Conversations with their own location never refuse: they stay where they are.
|
|
*/
|
|
export type RelocationCheck = (
|
|
harness: DurableHarness,
|
|
from: Location,
|
|
to: Location,
|
|
context: Context,
|
|
) => Promise<string | undefined>;
|
|
export const RelocationChecks = keyedRegistry<RelocationCheck>("p2.relocation-checks");
|
|
|
|
/** The session's Harness and its environments, opened by the core (`HarnessHost`), read at each use. */
|
|
export type HarnessParts = {
|
|
readonly durable: DurableHarness;
|
|
/**
|
|
* Every conversation's location and environment (also supplied to Durable): the session's for the main
|
|
* conversation and its followers, a subagent's own (its environment and cwd, e.g. a git worktree) otherwise.
|
|
* Resolve at each use: a relocation changes the session's.
|
|
*/
|
|
readonly environments: ExecutionEnvironments;
|
|
/**
|
|
* Moves the session to `location` in place (the server's `WorkerBoundary.relocate`, while admissions are fenced and
|
|
* nothing runs): opens the destination's environment, switches the session's location, commits the root and its
|
|
* followers to the new cwd, commits the `SessionLocation` (its listeners reopen what they keep), then retires the
|
|
* environments nothing uses any more.
|
|
*/
|
|
relocate(location: Location, context: Context): Promise<void>;
|
|
};
|
|
|
|
/** The session's Harness (the core's contribution once it is open). */
|
|
export const HarnessNative = defineRegistry<HarnessParts, HarnessParts | undefined>("p2.harness", { version: 1 });
|
|
|
|
/**
|
|
* The core's host-lifetime object that opens the Harness (an exclusive store: one per worker). A factory that needs the
|
|
* Harness at activation declares it: `harnessOf(self)` parks the attempt until the Harness is open.
|
|
*/
|
|
export const HarnessHost = local("p2.harness-host", { version: 1, params: {}, members: {} });
|
|
|
|
/** The session's Harness for a factory: declares `HarnessHost` (so the attempt waits for it) and reads it at each use. */
|
|
export function harnessOf(self: Self): () => HarnessParts {
|
|
self.get(HarnessHost, {});
|
|
const harness = self.registry(HarnessNative);
|
|
return () => {
|
|
const parts = harness.value;
|
|
if (parts === undefined) throw new Error("The session's Harness is not open");
|
|
return parts;
|
|
};
|
|
}
|
|
|
|
/** The worker process's natives for a factory (the core contributes them before anything activates). */
|
|
export function workerOf(self: Self): () => WorkerParts {
|
|
const worker = self.registry(WorkerProcess);
|
|
return () => {
|
|
const parts = worker.value;
|
|
if (parts === undefined) throw new Error("No worker process (the core facet contributes it)");
|
|
return parts;
|
|
};
|
|
}
|
|
|
|
/** Serves the base keyed registries of a worker (the core facet; test hosts without one). */
|
|
export function serveWorkerRegistries(host: FacetHost): void {
|
|
for (const registry of [
|
|
Extensions,
|
|
ExtensionSwitches,
|
|
InputGates,
|
|
ConversationConfigured,
|
|
ReadOnlyConversations,
|
|
HarnessConfig,
|
|
HarnessEnvironments,
|
|
RelocationChecks,
|
|
ConversationLocations,
|
|
])
|
|
serveKeyed(host, registry as typeof InputGates);
|
|
}
|
|
|
|
/** The worker's core facet: its registries, its natives, the Harness and the server's lifecycle calls. */
|
|
export function workerCore(
|
|
init: WorkerInit,
|
|
liveness: Liveness,
|
|
location: SessionLocation,
|
|
root: WorkerParts["root"],
|
|
): Facet {
|
|
return defineFacet((host, self) => {
|
|
serveWorkerRegistries(host);
|
|
host.serve(WorkerProcess, { compose: (table) => table[0]?.value });
|
|
self.contribute(WorkerProcess, { init, liveness, location, root });
|
|
host.serve(HarnessNative, { compose: (table) => table[0]?.value });
|
|
host.serve(HarnessHost, (_params, self) => {
|
|
self.contribute(HarnessNative, openHarness(self, init, location));
|
|
return {};
|
|
});
|
|
host.serve(WorkerBoundary, (_params, principal, self) => {
|
|
if (principal.kind !== "server") throw forbidden("server lifecycle only");
|
|
const harness = harnessOf(self);
|
|
const checks = self.registry(RelocationChecks);
|
|
return {
|
|
variant: "server",
|
|
members: {
|
|
prepare: async (input, ctx) => {
|
|
const frozen = liveness.freeze();
|
|
try {
|
|
const inspection = await harness().durable.inspect(ctx);
|
|
const busy = [
|
|
...new Set(
|
|
inspection.tasks
|
|
.filter((task) => !task.record.background)
|
|
.map((task) => task.record.conversationId),
|
|
),
|
|
].sort((a, b) => a - b);
|
|
const tasks = inspection.tasks.length + inspection.submissions.length;
|
|
let refusal: string | undefined;
|
|
// Under the fence: nothing can change what the checks read (no task runs, nothing is admitted).
|
|
if (frozen && tasks === 0 && input !== null)
|
|
for (const check of checks.value.values()) {
|
|
refusal = await check(harness().durable, location.value, input.to, ctx);
|
|
if (refusal !== undefined) break;
|
|
}
|
|
const accepted = frozen && tasks === 0 && refusal === undefined;
|
|
if (!accepted && frozen) liveness.unfreeze();
|
|
return { accepted, tasks, busy, ...(refusal === undefined ? {} : { refusal }) };
|
|
} catch (error) {
|
|
if (frozen) liveness.unfreeze();
|
|
throw error;
|
|
}
|
|
},
|
|
cancel: async () => {
|
|
liveness.unfreeze();
|
|
return null;
|
|
},
|
|
relocate: async (next, ctx) => {
|
|
if (!liveness.relocating) throw new Error("Relocation was not prepared");
|
|
await harness().relocate(next, ctx);
|
|
liveness.unfreeze();
|
|
return null;
|
|
},
|
|
},
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* This machine: `LocalExecutionEnv` where pi-env ships a daemon for it, so the file index lists through it
|
|
* (`listFiles`), else `NodeExecutionEnv`. The daemon starts at the first listing; `close` stops it.
|
|
*/
|
|
function localMachine(): Machine {
|
|
let daemon: string | undefined;
|
|
try {
|
|
daemon = localDaemon();
|
|
} catch {
|
|
// No daemon for this system.
|
|
}
|
|
if (daemon === undefined || !existsSync(daemon))
|
|
return { at: (cwd) => new NodeExecutionEnv({ cwd, shellPath: process.env.SHELL }), close: () => {} };
|
|
const connection = new Connection({ command: [daemon] });
|
|
return {
|
|
at: (cwd) => new LocalExecutionEnv({ cwd, shellPath: process.env.SHELL, connection }),
|
|
close: () => connection.close(),
|
|
};
|
|
}
|
|
|
|
/** The execution environments of a session's conversations, each at its conversation's location. */
|
|
export type ExecutionEnvironments = {
|
|
/** Where the session works now: its main conversation and the conversations following it. */
|
|
readonly session: Location;
|
|
/**
|
|
* Where conversation `id` works: its own location (a subagent spawned with a cwd), else the session's. `read` reads
|
|
* its documents (a tool's or a prompt section's reader; default: the Harness).
|
|
*/
|
|
locate(id: ConversationId, context: Context, read?: DocumentReader): Promise<Location>;
|
|
/** The environment at `location`: one per directory, on one connection per machine, opened at its first use. */
|
|
at(location: Location): Promise<ExecutionEnv>;
|
|
};
|
|
|
|
/** Where conversation `id` works and its environment there. */
|
|
export async function conversationEnvironment(
|
|
environments: ExecutionEnvironments,
|
|
id: ConversationId,
|
|
context: Context,
|
|
read?: DocumentReader,
|
|
): Promise<{ location: Location; env: ExecutionEnv }> {
|
|
const location = await environments.locate(id, context, read);
|
|
return { location, env: await environments.at(location) };
|
|
}
|
|
|
|
/**
|
|
* The session's execution environments: one machine (connection) per environment name, opened lazily and shared by
|
|
* every conversation working there, with one environment per directory. A relocation `switch`es the session's location
|
|
* (the conversations following it locate there from then on), then `retire`s what nothing uses any more: the old cwd's
|
|
* environment, the old machine unless a conversation with its own location works on it. Conversations with their own
|
|
* location keep their environments.
|
|
*/
|
|
class Execution implements ExecutionEnvironments {
|
|
#session: Location;
|
|
readonly #open: (env: string | null, signal: AbortSignal) => Promise<Machine>;
|
|
readonly #own: (
|
|
read: DocumentReader | undefined,
|
|
id: ConversationId,
|
|
context: Context,
|
|
) => Promise<Location | undefined>;
|
|
/** Machines by environment name ("" for this machine: names are never empty), with their environments by cwd. */
|
|
readonly #machines = new Map<
|
|
string,
|
|
{ readonly machine: Promise<Machine>; readonly envs: Map<string, ExecutionEnv> }
|
|
>();
|
|
readonly #closing = new AbortController();
|
|
constructor(
|
|
session: Location,
|
|
open: (env: string | null, signal: AbortSignal) => Promise<Machine>,
|
|
own: (read: DocumentReader | undefined, id: ConversationId, context: Context) => Promise<Location | undefined>,
|
|
) {
|
|
this.#session = session;
|
|
this.#open = open;
|
|
this.#own = own;
|
|
}
|
|
get session(): Location {
|
|
return this.#session;
|
|
}
|
|
async locate(id: ConversationId, context: Context, read?: DocumentReader): Promise<Location> {
|
|
if (id === ROOT_CONVERSATION_ID) return this.#session;
|
|
return (await this.#own(read, id, context)) ?? this.#session;
|
|
}
|
|
async at(location: Location): Promise<ExecutionEnv> {
|
|
if (this.#closing.signal.aborted) throw new Error("The session's environments are closed");
|
|
const key = location.env ?? "";
|
|
let entry = this.#machines.get(key);
|
|
if (!entry) {
|
|
const created = {
|
|
machine: this.#open(location.env, this.#closing.signal),
|
|
envs: new Map<string, ExecutionEnv>(),
|
|
};
|
|
entry = created;
|
|
this.#machines.set(key, created);
|
|
// A failed open is not kept: the next use tries again.
|
|
created.machine.catch(() => {
|
|
if (this.#machines.get(key) === created) this.#machines.delete(key);
|
|
});
|
|
}
|
|
const machine = await entry.machine;
|
|
// Retired while opening: open it again (or refuse, once closed).
|
|
if (this.#machines.get(key) !== entry) return this.at(location);
|
|
let env = entry.envs.get(location.cwd);
|
|
if (!env) {
|
|
env = machine.at(location.cwd);
|
|
entry.envs.set(location.cwd, env);
|
|
}
|
|
return env;
|
|
}
|
|
/** The session moves to `next`; the old environments stay until `retire`. */
|
|
switch(next: Location): void {
|
|
this.#session = next;
|
|
}
|
|
/**
|
|
* Ends the environments outside `used` (their running commands killed) and closes the machines left without any.
|
|
* Nothing runs meanwhile: the worker is fenced.
|
|
*/
|
|
async retire(used: readonly Location[]): Promise<void> {
|
|
const ending: ExecutionEnv[] = [];
|
|
const closing: Promise<Machine>[] = [];
|
|
for (const [key, entry] of this.#machines) {
|
|
const kept = used.filter((location) => (location.env ?? "") === key);
|
|
for (const [cwd, env] of entry.envs)
|
|
if (!kept.some((location) => location.cwd === cwd)) {
|
|
entry.envs.delete(cwd);
|
|
ending.push(env);
|
|
}
|
|
if (!kept.length) {
|
|
this.#machines.delete(key);
|
|
closing.push(entry.machine);
|
|
}
|
|
}
|
|
const context = durableContext(new AbortController().signal);
|
|
await Promise.all(ending.map((env) => env.cleanup(context).catch(() => {})));
|
|
for (const machine of await Promise.allSettled(closing))
|
|
if (machine.status === "fulfilled") machine.value.close();
|
|
}
|
|
close(): Promise<void> {
|
|
this.#closing.abort(new Error("The session's environments are closed"));
|
|
return this.retire([]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the top-level conversations (the root and its `/tree` branches) and the conversations following the session to
|
|
* `session.cwd` in one commit (those already there stay untouched), after the placement provider committed what an older
|
|
* version left unplaced. Children copied the session's
|
|
* cwd when created, so a move leaves them behind; this runs at every start (sessions moved before subagents tracked
|
|
* their own cwd still hold the old path) and at every in-place relocation. Nothing resolves against this process's cwd.
|
|
* Returns the own locations of the other conversations.
|
|
*/
|
|
async function followSession(
|
|
place:
|
|
| ((
|
|
harness: DurableHarness,
|
|
session: Location,
|
|
context: Context,
|
|
) => Promise<{ readonly followers: readonly ConversationId[]; readonly own: readonly Location[] }>)
|
|
| undefined,
|
|
harness: DurableHarness,
|
|
session: Location,
|
|
context: Context,
|
|
): Promise<readonly Location[]> {
|
|
const placed = place ? await place(harness, session, context) : { followers: [], own: [] };
|
|
const stale: ConversationId[] = [];
|
|
for (const id of [...(await topLevelConversations(harness, context)), ...placed.followers]) {
|
|
const current = (await (await harness.conversation(id, context))?.agent(context))?.cwd;
|
|
if (current !== undefined && current !== session.cwd && !stale.includes(id)) stale.push(id);
|
|
}
|
|
if (stale.length)
|
|
await harness.commit(async (tx) => {
|
|
for (const id of stale) await configure(tx, id, { cwd: session.cwd });
|
|
}, context);
|
|
return placed.own;
|
|
}
|
|
|
|
/**
|
|
* Opens the session's Harness in the `HarnessHost` object: the execution environments first (the session's machine
|
|
* opens now: a start fails visibly where it cannot), then Durable once the models plugin contributed its configuration,
|
|
* with the root conversation created from the session's model and cwd. Scheduling starts with `resume()` once the
|
|
* worker's plugins are loaded (the worker entry). Returns the contribution: the natives, read at each use.
|
|
*/
|
|
function openHarness(self: Self, init: WorkerInit, location: SessionLocation): HarnessParts {
|
|
const { dataDir, values } = init;
|
|
const session = values.session;
|
|
const config = self.registry(HarnessConfig);
|
|
const environments = self.registry(HarnessEnvironments);
|
|
const placements = self.registry(ConversationLocations);
|
|
const extensions = self.registry(Extensions);
|
|
const switches = self.registry(ExtensionSwitches);
|
|
const configured = (): HarnessConfigEntry => {
|
|
const entry = config.value.get("default");
|
|
if (entry === undefined) throw new Error("No model configuration installed (models plugin)");
|
|
return entry;
|
|
};
|
|
// Model access is looked up at each use: the models plugin may reload or replace its configuration.
|
|
const models = new Proxy({} as Models, {
|
|
get: (_target, key) => {
|
|
const current = configured().models();
|
|
const value: unknown = Reflect.get(current, key, current);
|
|
return typeof value === "function" ? value.bind(current) : value;
|
|
},
|
|
});
|
|
const settings = new Proxy({} as HarnessSettings, {
|
|
get: (_target, key) => {
|
|
const entry = config.value.get("default");
|
|
return entry === undefined ? undefined : Reflect.get(entry.settings(), key);
|
|
},
|
|
});
|
|
/** A machine: this one, or a remote environment's from the plugin providing them. */
|
|
const open = async (env: string | null, signal: AbortSignal): Promise<Machine> => {
|
|
if (env === null) return localMachine();
|
|
const remote = await whenPresent(environments, "remote", signal);
|
|
return remote.open(env);
|
|
};
|
|
const place = async (harness: DurableHarness, at: Location, context: Context) =>
|
|
(await placements.value.get("subagents")?.place(harness, at, values, context)) ?? { followers: [], own: [] };
|
|
let reader: DocumentReader | undefined;
|
|
const execution: Native<Execution> = self.open(
|
|
async () => {
|
|
const created = new Execution(location.value, open, async (read, id, context) => {
|
|
const documents = read ?? reader;
|
|
if (!documents) throw new Error("The session's Harness is not open");
|
|
return placements.value.get("subagents")?.own(documents, id, context);
|
|
});
|
|
try {
|
|
await created.at(location.value);
|
|
} catch (error) {
|
|
await created.close();
|
|
throw error;
|
|
}
|
|
return created;
|
|
},
|
|
(created) => created.close(),
|
|
);
|
|
const observing = new AbortController();
|
|
const durable: Native<DurableHarness> = self.open(
|
|
async (ctx) => {
|
|
// Generation needs model access: open once the models plugin has contributed it.
|
|
await whenPresent(config, "default", ctx.abortSignal ?? new AbortController().signal);
|
|
const storage = await openNodeSqliteStorage(`${dataDir}/sessions/${session.id}.sqlite`);
|
|
const harness = await DurableHarness.open(
|
|
storage,
|
|
{
|
|
models,
|
|
registry: durableRegistry(extensions, switches, durableContext(observing.signal)),
|
|
settings,
|
|
// The conversation's location, not its agent cwd alone: a subagent's own may be on another machine.
|
|
env: async (target, context) =>
|
|
execution.value.at(await execution.value.locate(target.conversationId, context, target.read)),
|
|
onReport: (report) => console.error(`durable: ${report.kind}:`, report.error),
|
|
},
|
|
ctx,
|
|
);
|
|
reader = harness;
|
|
const { cwd } = location.value;
|
|
const root = await harness.root(ctx, {
|
|
agent: { model: session.model, cwd, thinkingLevel: session.thinkingLevel },
|
|
});
|
|
const agent = await root.agent(ctx);
|
|
// Before the Harness is live: nothing can run or resume in a stale cwd first.
|
|
try {
|
|
await followSession(place, harness, location.value, ctx);
|
|
} catch (error) {
|
|
await harness.close(ctx).catch(() => {});
|
|
throw error;
|
|
}
|
|
// As Pi Mobile: a root model no longer in the configuration fails the start visibly (the session's worker
|
|
// error, with Retry), not on the first turn.
|
|
if (!agent.model || !models.getModel(agent.model.provider, agent.model.modelId)) {
|
|
await harness.close(ctx).catch(() => {});
|
|
throw new Error("Unknown model");
|
|
}
|
|
return harness;
|
|
},
|
|
async (harness) => {
|
|
observing.abort();
|
|
await harness.close(BACKGROUND_CONTEXT).catch(() => {});
|
|
},
|
|
);
|
|
return {
|
|
get durable() {
|
|
return durable.value;
|
|
},
|
|
get environments() {
|
|
return execution.value;
|
|
},
|
|
relocate: async (next, context) => {
|
|
if (sameLocation(next, location.value)) return;
|
|
const harness = durable.value;
|
|
const environments = execution.value;
|
|
// The destination first: a machine that cannot be opened refuses before anything moves. Then the session's
|
|
// location, the agent cwds and the `SessionLocation` (its listeners reopen on the new environments); what
|
|
// nothing uses any more goes last. A failure leaves the worker fenced: the server restarts it from the
|
|
// catalog.
|
|
await environments.at(next);
|
|
environments.switch(next);
|
|
const own = await followSession(place, harness, next, context);
|
|
location.set(next);
|
|
await environments
|
|
.retire([next, ...own])
|
|
.catch((error: unknown) => console.error("p2 worker: closing old environments:", error));
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Resolves with the entry `key` of a keyed registry once it is there; rejects when `signal` aborts first. */
|
|
async function whenPresent<V>(
|
|
registry: RegistryValue<ReadonlyMap<string, V>>,
|
|
key: string,
|
|
signal: AbortSignal,
|
|
): Promise<V> {
|
|
const present = registry.value.get(key);
|
|
if (present !== undefined) return present;
|
|
const watch = await registry.watch(durableContext(signal));
|
|
try {
|
|
return await new Promise<V>((resolve, reject) => {
|
|
const now = watch.value.get(key);
|
|
if (now !== undefined) return resolve(now);
|
|
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
watch.start(async (call) => {
|
|
const entry = call.kind === "value" ? call.value.get(key) : undefined;
|
|
if (entry !== undefined) resolve(entry);
|
|
});
|
|
});
|
|
} finally {
|
|
await watch.stop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Where conversation `id` works (`ExecutionEnvironments.locate`), for code running inside the Harness (a prompt section,
|
|
* a tool) that reads the Harness registry it declared.
|
|
*/
|
|
export function locateConversation(
|
|
harness: RegistryValue<HarnessParts | undefined>,
|
|
id: ConversationId,
|
|
context: Context,
|
|
read?: DocumentReader,
|
|
): Promise<Location> {
|
|
const parts = harness.value;
|
|
if (parts === undefined) throw new Error("The session's Harness is not open");
|
|
return parts.environments.locate(id, context, read);
|
|
}
|
|
|
|
/**
|
|
* `target` as a location of an existing directory, or why it is not one: on the caller's environment its path is
|
|
* relative to `from.cwd`, on another one relative to that environment's home (`~` expands on both). Resolved through
|
|
* the worker's environments, so another machine is opened (and kept until nothing uses it).
|
|
*/
|
|
export async function resolveDirectory(
|
|
environments: ExecutionEnvironments,
|
|
target: LocationInput,
|
|
from: Location,
|
|
context: Context,
|
|
): Promise<Location | { error: string }> {
|
|
const same = target.env === from.env;
|
|
const typed = target.path.trim();
|
|
const path = !typed ? (same ? from.cwd : "~") : same || isRooted(typed) ? typed : `~/${typed}`;
|
|
try {
|
|
const env = await environments.at({ env: target.env, cwd: same ? from.cwd : "/" });
|
|
const absolute = await env.absolutePath(path, context);
|
|
const canonical = absolute.ok ? await env.canonicalPath(absolute.value, context) : undefined;
|
|
const info = canonical?.ok ? await env.fileInfo(canonical.value, context) : undefined;
|
|
if (!absolute.ok || !info?.ok || info.value.kind !== "directory") return { error: "not an existing directory" };
|
|
return { env: target.env, cwd: absolute.value };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|