Restart on the current source with /reload

7cd7a3fc6b5f · AtlantisPleb · · parent fd352755294b

Restart on the current source with /reload

The interface is developed by running it, and every change meant leaving the
session, rebuilding, and starting again -- which loses the conversation that
prompted the change. `/reload` does those three things without the reader doing
them.

The work is split where the terminal is. The interface only asks, by exiting
with a code of its own; the runner rebuilds and restarts once it has the screen
back. A compiler writing over a live alt-screen is a session nobody can read.

A build that fails does not restart. The compiler's own output is printed and
the process stops, because a reload onto code that does not compile would
silently be a reload onto the build before it. "Build failed" on its own tells
the reader nothing they can act on.

Only where it means something. A published install ships `dist` and a manifest
but no `src`, and a manifest with no build script is nothing to rebuild with, so
there `/reload` says so rather than trying and failing. The check is a predicate
over a directory so a test can hand it one, rather than a lookup that can only
be exercised on the machine it runs on.

The transcript ends with the session. It is not written out on the way past:
reloading is something a reader does many times in a sitting, and a command that
quietly leaves a file behind each time is one they have to clean up after.
`/export` keeps a conversation when it is wanted.

Verified both directions on a real session: an edit to a source file that had
not been built appeared in `dist` after `/reload`, and reverting the file and
reloading again took it back out. A deliberate type error printed the compiler's
message and refused the restart.

335 tests pass, seven on this.

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
  • modified packages/openagents-cli/src/coder-plain.ts
  • added packages/openagents-cli/src/coder-reload.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-reload.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

8 files changed, +219 -6

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": 2432,
7
    "filesScanned": 2433,
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:5ecdc0a7a3a28075c8356671025554ef9c69e4cdc9c25e80eb9b6377bfb567af",
4
  "sourceDigest": "sha256:4850da44cf6c8cacbc5ec66d9405156d05c9008aed2fd97dbe17d9492041dec8",
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 (32 tracked test files)"
1879
          "ref": "packages/openagents-cli (33 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +27

@@ -36,6 +36,9 @@ import {

36 36
} from "./coder-ollama.js";
37 37
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
38 38
import { delegateTool, skillTool } from "./coder-tools.js";
39
import { spawnSync } from "node:child_process";
40
41
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
39 42
import { loadSkillSelection } from "./coder-skills.js";
40 43
import { describeWorkspace } from "./coder-workspace.js";
41 44
import { ComputerClient } from "./computer-client.js";

@@ -1835,6 +1838,30 @@ const coderCommand = Command.make(

1835 1838
        }
1836 1839
      });
1837 1840
1841
      // The interface asks for a restart by exiting with a code of its own. It
1842
      // is done here rather than there because the rebuild has to happen after
1843
      // the screen is given back: a compiler writing over a live alt-screen is
1844
      // a session the reader cannot read.
1845
      if (code === RELOAD_EXIT_CODE) {
1846
        const root = sourceCheckout();
1847
        if (root !== undefined) {
1848
          const built = rebuild(root);
1849
          if (built.ok) {
1850
            const restarted = spawnSync(process.execPath, process.argv.slice(1), {
1851
              stdio: "inherit",
1852
            });
1853
            process.exitCode = restarted.status ?? 0;
1854
            return;
1855
          }
1856
          // The compiler's own words, and the old session is not resumed: a
1857
          // reload onto code that does not build would be a reload onto the
1858
          // build before it, silently.
1859
          process.stderr.write(`${built.output}\n\nReload failed: the build did not pass.\n`);
1860
          process.exitCode = 1;
1861
          return;
1862
        }
1863
      }
1864
1838 1865
      if (code !== 0) {
1839 1866
        process.exitCode = code;
1840 1867
      }
packages/openagents-cli/src/coder-plain.ts modified +20 -2

@@ -19,6 +19,7 @@

19 19
import { createInterface } from "node:readline";
20 20
21 21
import type { CoderEntry, CoderSession } from "./coder-session.js";
22
import { RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
22 23
import type { SkillSelection } from "./coder-skills.js";
23 24
24 25
export interface CoderPlainOptions {

@@ -71,7 +72,23 @@ export async function runCoderPlain(

71 72
  const unsubscribe = session.onChange(flush);
72 73
  flush();
73 74
75
  /** Set when `/reload` asks the runner to rebuild and start again. */
76
  let reloading = false;
77
74 78
  const answer = async (line: string) => {
79
    // The same command as in the interface, and the same division of labour:
80
    // this only asks, and the runner rebuilds once it has the terminal back.
81
    if (/^\/reload\s*$/.test(line.trim())) {
82
      if (sourceCheckout() === undefined) {
83
        stdout.write(
84
          "\nThis session is not running from a source checkout, so there is nothing to rebuild.\n",
85
        );
86
        return;
87
      }
88
      reloading = true;
89
      return;
90
    }
91
75 92
    // `/skills` is a screen in the interface. There is no screen here, so it
76 93
    // reports instead: the same facts, without the switch. Saying nothing and
77 94
    // sending it to the model as a question would be worse than either.

@@ -99,15 +116,16 @@ export async function runCoderPlain(

99 116
  try {
100 117
    if (prompt !== undefined) {
101 118
      await answer(prompt);
102
      return 0;
119
      return reloading ? RELOAD_EXIT_CODE : 0;
103 120
    }
104 121
105 122
    const reader = createInterface({ input: stdin, terminal: false });
106 123
    for await (const line of reader) {
107 124
      if (line.trim().length === 0) continue;
108 125
      await answer(line);
126
      if (reloading) break;
109 127
    }
110
    return 0;
128
    return reloading ? RELOAD_EXIT_CODE : 0;
111 129
  } finally {
112 130
    unsubscribe();
113 131
  }
packages/openagents-cli/src/coder-reload.ts added +64

@@ -0,0 +1,64 @@

1
/**
2
 * Restarting a session on the code as it is now.
3
 *
4
 * The interface is developed by running it, and every change meant leaving the
5
 * session, rebuilding, and starting again -- which loses the conversation that
6
 * prompted the change. `/reload` does the same three things without the reader
7
 * doing them, and only where they mean something: a session running from a
8
 * source checkout.
9
 *
10
 * A published install has no `src` to build from and no build script to run, so
11
 * `/reload` there would be a command that could only fail. It reports that
12
 * instead of trying.
13
 */
14
15
import { spawnSync } from "node:child_process";
16
import { existsSync, readFileSync } from "node:fs";
17
import { dirname, join } from "node:path";
18
import { fileURLToPath } from "node:url";
19
20
/** The exit code the interface uses to ask its runner for a restart. */
21
export const RELOAD_EXIT_CODE = 75;
22
23
/**
24
 * Whether this directory is something `/reload` could rebuild.
25
 *
26
 * Both have to be there. A published tarball ships `dist` and a manifest but no
27
 * `src`, and a manifest with no build script is nothing to rebuild with.
28
 */
29
export function isSourceCheckout(root: string): boolean {
30
  if (!existsSync(join(root, "src"))) return false;
31
  try {
32
    const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
33
      scripts?: Record<string, unknown>;
34
    };
35
    return typeof manifest.scripts?.["build"] === "string";
36
  } catch {
37
    return false;
38
  }
39
}
40
41
/** The package this process is running out of, when it is a source checkout. */
42
export function sourceCheckout(): string | undefined {
43
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
44
  return isSourceCheckout(root) ? root : undefined;
45
}
46
47
export interface RebuildResult {
48
  readonly ok: boolean;
49
  /** What the compiler said, when it failed. */
50
  readonly output: string;
51
}
52
53
/**
54
 * Rebuild the checkout.
55
 *
56
 * The compiler's own output is carried back rather than a summary of it: a
57
 * reload that fails is a compile error the reader has to read, and "build
58
 * failed" tells them nothing they can act on.
59
 */
60
export function rebuild(root: string): RebuildResult {
61
  const result = spawnSync("pnpm", ["run", "build"], { cwd: root, encoding: "utf8" });
62
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
63
  return { ok: result.status === 0, output };
64
}
packages/openagents-cli/src/coder-ui.ts modified +23 -1

@@ -32,6 +32,7 @@

32 32
import { fleetPhrase, fleetRows } from "./coder-fleet.js";
33 33
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
34 34
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
35
import { RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
35 36
import type { SkillSelection } from "./coder-skills.js";
36 37
import type { CoderTaskStatus } from "./coder-tasks.js";
37 38

@@ -658,6 +659,27 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

658 659
      // A delegate line is not a turn: it returns as soon as the children are
659 660
      // submitted and each one reports later, so nothing here waits on it and
660 661
      // the ticker above keeps the fleet rows moving.
662
      // `/reload` restarts this session on the code as it is now. The rebuild
663
      // and the restart belong to the runner, which has the screen back by
664
      // then; the interface only asks, by exiting with a code of its own.
665
      if (/^\/reload\s*$/.test(prompt.trim())) {
666
        const root = sourceCheckout();
667
        if (root === undefined) {
668
          session.notice(
669
            "This session is not running from a source checkout, so there is nothing to " +
670
              "rebuild. `/reload` works where `openagents coder` was started from the repository.",
671
          );
672
          render();
673
          return;
674
        }
675
        // The transcript ends here. It is not written out on the way past:
676
        // reloading is something a reader does many times in a sitting, and a
677
        // command that quietly leaves a file behind each time is one they have
678
        // to clean up after. `/export` keeps a conversation when it is wanted.
679
        finish(RELOAD_EXIT_CODE);
680
        return;
681
      }
682
661 683
      // `/skills` opens a screen rather than sending a turn: it changes what
662 684
      // the next turn carries, so it is not something to say to the model.
663 685
      if (/^\/skills\s*$/.test(prompt.trim())) {

@@ -919,7 +941,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

919 941
      "openagents coder — development build. Type a message and press enter. " +
920 942
        "Ctrl+D quits, Esc interrupts a reply. `/system` shows what the model is told, " +
921 943
        "`/skills` chooses which skills it is offered, `/export` writes the conversation " +
922
        "as ATIF.",
944
        "as ATIF, `/reload` restarts on the current source.",
923 945
    );
924 946
    render();
925 947
  });
packages/openagents-cli/test/coder-reload.test.ts added +49

@@ -0,0 +1,49 @@

1
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import { isSourceCheckout, RELOAD_EXIT_CODE, sourceCheckout } from "../src/coder-reload.js";
7
8
const directory = (manifest?: Record<string, unknown>, withSource = true): string => {
9
  const root = mkdtempSync(join(tmpdir(), "coder-reload-"));
10
  if (withSource) mkdirSync(join(root, "src"));
11
  if (manifest !== undefined) {
12
    writeFileSync(join(root, "package.json"), JSON.stringify(manifest));
13
  }
14
  return root;
15
};
16
17
describe("deciding whether a session can reload itself", () => {
18
  it("recognizes a checkout with sources and a build script", () => {
19
    expect(isSourceCheckout(directory({ scripts: { build: "tsc" } }))).toBe(true);
20
  });
21
22
  it("refuses a published install, which ships no sources", () => {
23
    // `/reload` there could only fail, so it reports instead of trying.
24
    expect(isSourceCheckout(directory({ scripts: { build: "tsc" } }, false))).toBe(false);
25
  });
26
27
  it("refuses a checkout with nothing to build with", () => {
28
    expect(isSourceCheckout(directory({ scripts: {} }))).toBe(false);
29
    expect(isSourceCheckout(directory({}))).toBe(false);
30
  });
31
32
  it("refuses a directory with no manifest, or an unreadable one", () => {
33
    expect(isSourceCheckout(directory())).toBe(false);
34
    const broken = directory();
35
    writeFileSync(join(broken, "package.json"), "{not json");
36
    expect(isSourceCheckout(broken)).toBe(false);
37
  });
38
39
  it("recognizes the checkout these tests run from", () => {
40
    // The case the command exists for.
41
    expect(sourceCheckout()).toBeDefined();
42
  });
43
44
  it("asks for a restart with a code no ordinary exit uses", () => {
45
    expect(RELOAD_EXIT_CODE).not.toBe(0);
46
    expect(RELOAD_EXIT_CODE).not.toBe(1);
47
    expect(RELOAD_EXIT_CODE).not.toBe(130);
48
  });
49
});
packages/openagents-cli/test/coder-ui.test.ts modified +33

@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";

4 4
import { CODER_BACKENDS } from "../src/coder-backends.js";
5 5
import { CoderSession, type ReplyChunk, type ReplySource } from "../src/coder-session.js";
6 6
import { CoderTaskRegistry } from "../src/coder-tasks.js";
7
import { RELOAD_EXIT_CODE } from "../src/coder-reload.js";
7 8
import { runCoderUi } from "../src/coder-ui.js";
8 9
9 10
/** A writable that records what the interface painted. */

@@ -571,3 +572,35 @@ describe("the /skills screen", () => {

571 572
    await running;
572 573
  });
573 574
});
575
576
describe("the /reload command", () => {
577
  it("asks the runner to restart, and sends nothing to the model", async () => {
578
    const stdin = new FakeIn();
579
    const stdout = new FakeOut();
580
    const prompts: string[] = [];
581
    const session = new CoderSession(
582
      {
583
        model: "scripted",
584
        // eslint-disable-next-line require-yield -- a turn that must not happen
585
        async *reply(prompt: string) {
586
          prompts.push(prompt);
587
        },
588
      },
589
      "repo",
590
      "main",
591
    );
592
    const running = runCoderUi(session, {
593
      stdin: stdin as unknown as NodeJS.ReadStream,
594
      stdout: stdout as unknown as NodeJS.WriteStream,
595
    });
596
597
    stdin.emit("data", "/reload");
598
    stdin.emit("data", "\r");
599
600
    // These tests run from a source checkout, so the command applies: the
601
    // interface exits with the code its runner rebuilds on.
602
    await expect(running).resolves.toBe(RELOAD_EXIT_CODE);
603
    expect(prompts).toEqual([]);
604
    expect(session.snapshot().turns).toBe(0);
605
  });
606
});

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