char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somexperiment: faster shutdowned34ebd

main
5.3 KiB142 linesraw
1import { homedir } from "node:os";
2import { serveDir } from "@std/http/file-server";
3import { fromFileUrl, join, resolve } from "@std/path";
4import { builtinModels } from "@earendil-works/pi-ai/providers/all";
5import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
6import { piAuthStore } from "./auth.ts";
7import { type Host, openBerry } from "./berry.ts";
8import { createHandler } from "./http.ts";
9import { denoCheck, extensionLoader } from "./loader.ts";
10import { RESTART } from "./supervisor.ts";
11import { extensionWeb } from "./web.ts";
12
13const token = Deno.env.get("BERRY_TOKEN");
14if (!token) {
15  console.error("BERRY_TOKEN must be set");
16  Deno.exit(1);
17}
18
19const root = fromFileUrl(new URL("../", import.meta.url));
20const directories = [
21  join(root, "extensions"),
22  resolve(Deno.env.get("XDG_CONFIG_HOME") || join(homedir(), ".config"), "berry", "extensions"),
23];
24const credentials = piAuthStore(Deno.env.get("BERRY_AUTH"));
25const models = builtinModels({ credentials });
26if (Deno.env.get("BERRY_DEV_MODEL")) (await import("./dev.ts")).installDevModel(models);
27const storage = await openNodeSqliteStorage(
28  Deno.env.get("BERRY_DB") ?? join(root, "data", "berry.sqlite"),
29);
30
31// a restart or a signal takes the process down; a check still running would hold it up
32const checks = new AbortController();
33
34const host: Host = {
35  root,
36  extensionDirectories: directories,
37  // the server, and the extensions it loads at startup, which it imports by name rather than statically
38  check: async () => {
39    const started = performance.now();
40    const failures = await denoCheck(
41      root,
42      [
43        "server/main.ts",
44        ...loader.files(),
45      ],
46      checks.signal,
47    );
48    // a restart's whole budget: this, then the stop below, then the new process saying it is listening
49    console.log(`[berry] checked in ${Math.round(performance.now() - started)}ms`);
50    return failures;
51  },
52  restart: () => void stop(RESTART),
53  extensions: () => loader.entries(),
54  reload: (name) => loader.reload(name),
55};
56
57const berry = await openBerry(storage, models, host);
58const web = extensionWeb({
59  cacheDirectory: join(
60    Deno.env.get("XDG_CACHE_HOME") || join(homedir(), ".cache"),
61    "berry",
62    "frontends",
63  ),
64});
65const loader = extensionLoader({ berry, web, root, directories });
66await loader.loadAll();
67loader.watch();
68berry.harness.resume();
69
70const handler = createHandler({
71  berry,
72  models,
73  credentials,
74  web,
75  loader,
76  host,
77  token,
78  // the built frontend, so one port serves everything; `deno task web` serves it with hmr instead
79  fallback: (request) => serveDir(request, { fsRoot: join(root, "web", "dist"), quiet: true }),
80});
81const server = Deno.serve({
82  hostname: Deno.env.get("BERRY_HOST") ?? "127.0.0.1",
83  port: Number(Deno.env.get("BERRY_PORT") ?? 8787),
84}, handler);
85// from process start, so a restart's budget is this and the checked/stopped lines beside it
86console.log(`[berry] ready in ${Math.round(performance.now())}ms`);
87
88// once, whatever asks first: a signal or a restart; another request waits for that one
89let stopping: Promise<never> | undefined;
90/**
91 * How long teardown may take before the process leaves anyway: the database is a WAL, and every child watches its own
92 * stdin, so what is still closing is left to end on its own rather than holding a restart.
93 */
94const STOP_DEADLINE_MS = 2_000;
95/** How long an in-flight request may take to finish before the process leaves without it. */
96const REQUEST_GRACE_MS = 500;
97function stop(code: number): Promise<never> {
98  stopping ??= (async () => {
99    checks.abort(new Error("berry is stopping"));
100    const started = performance.now();
101    // where a slow shutdown goes; the only place these numbers can be seen
102    const phases: string[] = [];
103    const phase = async (name: string, run: () => unknown) => {
104      const at = performance.now();
105      await run();
106      phases.push(`${name} ${Math.round(performance.now() - at)}ms`);
107    };
108    const closing = (async () => {
109      await phase("sockets", () => handler.closeSockets());
110      // a request of its own, such as a model refresh from a slow provider, ends with the process rather than holding it
111      await phase(
112        "http",
113        () =>
114          Promise.race([
115            server.shutdown(),
116            new Promise((resolve) => setTimeout(resolve, REQUEST_GRACE_MS)),
117          ]),
118      );
119      await phase("extensions", () => loader.close());
120      // signals every running call, and waits for them to end; durable resumes the work when berry opens again
121      await phase("berry", () => berry.close());
122    })().catch((error) => console.error("[berry] stopping:", error));
123    const overdue = await Promise.race([
124      closing.then(() => false),
125      new Promise<boolean>((resolve) => setTimeout(() => resolve(true), STOP_DEADLINE_MS)),
126    ]);
127    const left = ["sockets", "http", "extensions", "berry"].filter((name) =>
128      !phases.some((line) => line.startsWith(`${name} `))
129    );
130    console.log(
131      `[berry] stopped in ${Math.round(performance.now() - started)}ms (${phases.join(", ")})` +
132        (overdue && left.length
133          ? `; gave up on ${left.join(", ")} after ${STOP_DEADLINE_MS}ms`
134          : ""),
135    );
136    Deno.exit(code);
137  })();
138  return stopping;
139}
140for (const signal of ["SIGINT", "SIGTERM"] as const) {
141  Deno.addSignalListener(signal, () => void stop(0));
142}