Start the dev server rather than asking for one

afc81b5e4c40 · AtlantisPleb · · parent ebf48d7e063f

Start the dev server rather than asking for one

`coder --dev` checked that a development server was answering and, when none
was, told the reader to go and start one. That puts a second wait in front of
the first, which is the wait `--dev` exists to remove.

It starts one now. The checkout is found by walking up from the working
directory, so a session already inside `openagents.com` starts that copy rather
than another on the machine; failing that, `~/work/openagents.com`, and
`OPENAGENTS_COM_PATH` for a checkout in neither place. A `mix.exs` naming some
other application is not taken, because a directory that merely looks right
fails a minute later and less clearly.

A server whose database is behind is neither up nor down: Phoenix answers every
request, including `/healthz`, with its own pending-migration page. That is one
`mix ecto.migrate` away from working, so it runs it — which is what a person
would do next anyway — and keeps waiting.

The server is left running when the session ends. It compiles on boot, and that
cost is worth paying once rather than once per session: the next `--dev` finds
it already up and says nothing at all. Its output goes to a log the refusal
names, so a boot that fails can be read rather than guessed at.

Cold boot verified end to end: no server, `--dev` starts one, notices the
database is behind, migrates, waits, and answers. Warm start takes three
seconds and prints nothing. 640 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-dev-server.ts
  • added packages/openagents-cli/test/coder-dev-server.test.ts

Diff

5 files changed, +285 -19

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2452,
7
    "filesScanned": 2453,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:609044b7941afd31bd1343035391f97c9863c1c42c3419eecc2ebc4d4a68d585",
4
  "sourceDigest": "sha256:ac35b91f0360afc7210b7c55d483e80298724af060859b7f702fba112d503937",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (52 tracked test files)"
1879
          "ref": "packages/openagents-cli (53 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +14 -16

@@ -84,6 +84,7 @@ import { resolve as resolvePath } from "node:path";

84 84
85 85
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
86 86
import { loadSkillSelection, standingContext } from "./coder-skills.js";
87
import { startDevServer } from "./coder-dev-server.js";
87 88
import { describeWorkspace } from "./coder-workspace.js";
88 89
import { ComputerClient } from "./computer-client.js";
89 90
import { ComputerUp } from "./computer-up.js";

@@ -1850,24 +1851,21 @@ const coderCommand = Command.make(

1850 1851
      );
1851 1852
1852 1853
      if (dev) {
1853
        const reachable = yield* Effect.promise(async () => {
1854
          try {
1855
            const answer = await fetch(new URL("/healthz", endpoint.origin), {
1856
              signal: AbortSignal.timeout(1_500),
1857
            });
1858
            return answer.ok;
1859
          } catch {
1860
            return false;
1861
          }
1862
        });
1854
        // Started rather than asked for. `--dev` exists to remove a wait, and
1855
        // telling the reader to go and start a server puts a second wait in
1856
        // front of the first.
1857
        const boot = yield* Effect.promise(() =>
1858
          startDevServer(endpoint.origin, {
1859
            notice: (message) => {
1860
              process.stderr.write(`${message}\n`);
1861
            },
1862
          }),
1863
        );
1863 1864
1864
        if (!reachable) {
1865
          return yield* new InputError({
1866
            message:
1867
              `No development server is answering at ${endpoint.origin}. ` +
1868
              "Start it with `mix phx.server` in the openagents.com checkout, or drop --dev.",
1869
          });
1865
        if (boot.refusal !== undefined) {
1866
          return yield* new InputError({ message: boot.refusal });
1870 1867
        }
1868
        if (boot.started) process.stderr.write(`Dev server ready at ${endpoint.origin}.\n`);
1871 1869
      }
1872 1870
1873 1871
      // A `--model` value that starts with `ollama:` goes to the local Ollama
packages/openagents-cli/src/coder-dev-server.ts added +177

@@ -0,0 +1,177 @@

1
import { spawn } from "node:child_process";
2
import { existsSync, openSync, readFileSync } from "node:fs";
3
import { homedir, tmpdir } from "node:os";
4
import { dirname, join, resolve } from "node:path";
5
6
/**
7
 * Starting and waiting on the `openagents.com` development server for `--dev`.
8
 *
9
 * `--dev` exists because a deploy can take half an hour. Telling the reader to
10
 * go and start a server themselves puts a second wait in front of the first
11
 * one, so this starts it: a session that asks for the dev lane gets the dev
12
 * lane, and the only thing it has to be told is which log to read if the server
13
 * does not come up.
14
 *
15
 * The server is left running when the session ends. It compiles on boot and
16
 * that cost is worth paying once rather than once per session, and a reader who
17
 * wants it gone knows where it is.
18
 */
19
20
/** Where the server's output goes, so a failed boot can be read rather than guessed at. */
21
export const devServerLog = (): string => join(tmpdir(), "openagents-dev-server.log");
22
23
/**
24
 * The `openagents.com` checkout to start a server from.
25
 *
26
 * Walks up from the working directory first, so a session already inside the
27
 * repository starts that copy rather than one somewhere else on the machine.
28
 * `OPENAGENTS_COM_PATH` overrides for a checkout in neither place.
29
 */
30
export const findSiteCheckout = (
31
  from: string = process.cwd(),
32
  env: NodeJS.ProcessEnv = process.env,
33
): string | undefined => {
34
  const named = env["OPENAGENTS_COM_PATH"];
35
  if (named !== undefined && isSiteCheckout(named)) return resolve(named);
36
37
  for (let path = resolve(from); ; path = dirname(path)) {
38
    if (isSiteCheckout(path)) return path;
39
    if (dirname(path) === path) break;
40
  }
41
42
  const beside = join(homedir(), "work", "openagents.com");
43
  return isSiteCheckout(beside) ? beside : undefined;
44
};
45
46
/**
47
 * Whether this directory is the Phoenix application rather than some other Mix
48
 * project. Read from `mix.exs`, because a directory named `openagents.com` that
49
 * is not the app would fail later and less clearly.
50
 */
51
const isSiteCheckout = (path: string): boolean => {
52
  const manifest = join(path, "mix.exs");
53
  if (!existsSync(manifest)) return false;
54
  try {
55
    return /app:\s*:openagents\b/.test(readFileSync(manifest, "utf8"));
56
  } catch {
57
    return false;
58
  }
59
};
60
61
/** What `/healthz` says: serving, needs migrations, or not answering. */
62
type Health = "ok" | "pending_migrations" | "down";
63
64
const health = async (origin: string, timeoutMs = 1_500): Promise<Health> => {
65
  try {
66
    const answer = await fetch(new URL("/healthz", origin), {
67
      signal: AbortSignal.timeout(timeoutMs),
68
    });
69
    const body = await answer.text();
70
    if (answer.ok && body.includes(`"status"`)) return "ok";
71
    // Phoenix serves the pending-migration error as its own debug page, which
72
    // is a live server that cannot answer yet rather than a dead one.
73
    return body.includes("PendingMigrationError") ? "pending_migrations" : "down";
74
  } catch {
75
    return "down";
76
  }
77
};
78
79
/** Whether a server is already serving at this origin. */
80
export const devServerReady = async (origin: string): Promise<boolean> =>
81
  (await health(origin)) === "ok";
82
83
const run = (command: string, args: readonly string[], cwd: string, log: number) =>
84
  new Promise<number>((settle) => {
85
    const child = spawn(command, args, {
86
      cwd,
87
      stdio: ["ignore", log, log],
88
      env: { ...process.env, MIX_ENV: "dev" },
89
    });
90
    child.on("close", (code) => settle(code ?? 1));
91
    child.on("error", () => settle(1));
92
  });
93
94
export interface DevServerStart {
95
  readonly started: boolean;
96
  /** Why it could not be started, for a reader who has to act on it. */
97
  readonly refusal?: string;
98
}
99
100
/**
101
 * Bring a development server up at this origin, or report why not.
102
 *
103
 * Already serving is success and starts nothing. Otherwise it starts one in the
104
 * checkout it finds, migrates when the server says its database is behind, and
105
 * waits for `/healthz` to answer — a first boot compiles, so the wait is long
106
 * and says so rather than looking like a hang.
107
 */
108
export const startDevServer = async (
109
  origin: string,
110
  options: {
111
    readonly notice?: (message: string) => void;
112
    readonly timeoutMs?: number;
113
    readonly cwd?: string;
114
  } = {},
115
): Promise<DevServerStart> => {
116
  const notice = options.notice ?? (() => undefined);
117
  const deadline = Date.now() + (options.timeoutMs ?? 240_000);
118
119
  const current = await health(origin);
120
  if (current === "ok") return { started: false };
121
122
  const checkout = findSiteCheckout(options.cwd);
123
  if (checkout === undefined) {
124
    return {
125
      started: false,
126
      refusal:
127
        `No development server is answering at ${origin}, and no openagents.com ` +
128
        "checkout was found to start one from. Set OPENAGENTS_COM_PATH to it, or drop --dev.",
129
    };
130
  }
131
132
  const logPath = devServerLog();
133
  const log = openSync(logPath, "a");
134
135
  // A server that is up but refusing every request because its database is
136
  // behind is one migration away from working, and running it is what a person
137
  // would do next anyway.
138
  if (current === "pending_migrations") {
139
    notice("The dev server's database is behind. Migrating.");
140
    await run("mix", ["ecto.migrate"], checkout, log);
141
    if (await devServerReady(origin)) return { started: true };
142
  }
143
144
  notice(`Starting a dev server in ${checkout}. First boot compiles, so this can take a minute.`);
145
146
  // Detached, so the server outlives this session: the next `--dev` finds it
147
  // already up and pays no boot cost at all.
148
  const server = spawn("mix", ["phx.server"], {
149
    cwd: checkout,
150
    stdio: ["ignore", log, log],
151
    detached: true,
152
    env: { ...process.env, MIX_ENV: "dev" },
153
  });
154
  server.unref();
155
156
  let migrated = false;
157
  for (;;) {
158
    if (Date.now() > deadline) {
159
      return {
160
        started: false,
161
        refusal:
162
          `A dev server was started in ${checkout} but did not answer at ${origin} in time. ` +
163
          `Its output is in ${logPath}.`,
164
      };
165
    }
166
167
    await new Promise((wake) => setTimeout(wake, 1_000));
168
    const state = await health(origin);
169
    if (state === "ok") return { started: true };
170
171
    if (state === "pending_migrations" && !migrated) {
172
      migrated = true;
173
      notice("The dev server's database is behind. Migrating.");
174
      await run("mix", ["ecto.migrate"], checkout, log);
175
    }
176
  }
177
};
packages/openagents-cli/test/coder-dev-server.test.ts added +91

@@ -0,0 +1,91 @@

1
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { afterEach, describe, expect, it, vi } from "vitest";
5
6
import { devServerReady, findSiteCheckout, startDevServer } from "../src/coder-dev-server.js";
7
8
/** A directory tree with a Phoenix `mix.exs` at its root. */
9
const siteCheckout = () => {
10
  const root = mkdtempSync(join(tmpdir(), "oa-site-"));
11
  writeFileSync(join(root, "mix.exs"), "def project do\n  [app: :openagents, version: \"0.1.0\"]\nend\n");
12
  mkdirSync(join(root, "lib", "openagents_web"), { recursive: true });
13
  return root;
14
};
15
16
afterEach(() => {
17
  vi.unstubAllGlobals();
18
});
19
20
describe("finding the checkout to start a server from", () => {
21
  it("takes the checkout the session is already inside, from any depth", () => {
22
    const root = siteCheckout();
23
    expect(findSiteCheckout(join(root, "lib", "openagents_web"), {})).toBe(root);
24
  });
25
26
  it("prefers an explicitly named checkout over the search", () => {
27
    const named = siteCheckout();
28
    const inside = siteCheckout();
29
    expect(findSiteCheckout(inside, { OPENAGENTS_COM_PATH: named })).toBe(named);
30
  });
31
32
  it("ignores a named path that is not the application", () => {
33
    const root = siteCheckout();
34
    // Falls through to the search rather than starting `mix` somewhere that
35
    // will fail a minute later and less clearly.
36
    expect(findSiteCheckout(root, { OPENAGENTS_COM_PATH: mkdtempSync(join(tmpdir(), "oa-not-")) })).toBe(
37
      root,
38
    );
39
  });
40
41
  it("does not take a Mix project that is some other application", () => {
42
    const other = mkdtempSync(join(tmpdir(), "oa-other-"));
43
    writeFileSync(join(other, "mix.exs"), "def project do\n  [app: :something_else]\nend\n");
44
    // Not this one: the fallback may still find the real checkout on the
45
    // machine, but it must not have chosen the wrong Mix project.
46
    expect(findSiteCheckout(other, {})).not.toBe(other);
47
  });
48
});
49
50
describe("readiness", () => {
51
  const stub = (answer: () => Response | Promise<Response>) =>
52
    vi.stubGlobal("fetch", vi.fn(answer));
53
54
  it("counts a serving health endpoint as ready", async () => {
55
    stub(() => new Response(JSON.stringify({ status: "ok", revision: "image" }), { status: 200 }));
56
    expect(await devServerReady("http://localhost:4000")).toBe(true);
57
  });
58
59
  it("does not count a server whose database is behind as ready", async () => {
60
    // Phoenix answers this as its own debug page, so it is a live server that
61
    // cannot serve yet — neither ready nor absent.
62
    stub(() => new Response("<html>Phoenix.Ecto.PendingMigrationError at GET /healthz</html>", { status: 500 }));
63
    expect(await devServerReady("http://localhost:4000")).toBe(false);
64
  });
65
66
  it("does not count an unreachable server as ready", async () => {
67
    stub(() => {
68
      throw new Error("ECONNREFUSED");
69
    });
70
    expect(await devServerReady("http://localhost:4000")).toBe(false);
71
  });
72
});
73
74
describe("starting one", () => {
75
  it("starts nothing when a server is already serving", async () => {
76
    vi.stubGlobal(
77
      "fetch",
78
      vi.fn(() => new Response(JSON.stringify({ status: "ok" }), { status: 200 })),
79
    );
80
    const said: string[] = [];
81
82
    const result = await startDevServer("http://localhost:4000", {
83
      notice: (message) => said.push(message),
84
    });
85
86
    expect(result).toEqual({ started: false });
87
    // Nothing announced, because nothing happened: the warm path is the common
88
    // one and should be silent.
89
    expect(said).toEqual([]);
90
  });
91
});

This page updates live while a promote is in flight · changelog