Stabilize Forge compiler workspaces

2ab95a2ebf1b · AtlantisPleb · · parent d4dce1d925d9

Stabilize Forge compiler workspaces

Deploy story

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

built
111 modules in 115.1 s
built
112 modules in 95.0 s
deployed
live · 111 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 111 modules on 0 nodes · push→live —
deployed
live · 112 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 112 modules on 0 nodes · push→live —

Changed files

  • modified docs/operations/forge-hot-loop.md
  • modified lib/openagents/forge/build_worker.ex
  • modified test/openagents/forge/build_worker_test.exs

Diff

3 files changed, +57 -3

docs/operations/forge-hot-loop.md modified +4

@@ -56,6 +56,10 @@ Every link below exists in code and runs in this order:

56 56
   changed set, and writes the response plus a digest-addressed artifact. The
57 57
   sidecar uses a fresh source checkout for isolation and a persistent `_build`
58 58
   and dependency cache for speed. A new pinned builder image seeds that cache.
59
   The checkout reuses one repository-scoped absolute path across serial build
60
   attempts. Mix records source paths in compiler manifests and BEAM line
61
   tables, so a build-ID-specific path would make unchanged modules appear
62
   different and force unnecessary rolling replacements.
59 63
4. **Verify**: `Builder` re-verifies digest and manifest, advances the target
60 64
   to `built` with module list and classification, then broadcasts
61 65
   `forge:builds`.
lib/openagents/forge/build_worker.ex modified +11 -1

@@ -76,7 +76,7 @@ defmodule OpenAgents.Forge.BuildWorker do

76 76
77 77
  defp execute(request, artifacts, builds, started, opts) do
78 78
    build_id = request["build_id"]
79
    workspace = Path.join([builds, "jobs", build_id])
79
    workspace = workspace_path(builds, request["repo"])
80 80
    output_tmp = Path.join([artifacts, "output", ".#{build_id}.tmp"])
81 81
82 82
    try do

@@ -579,6 +579,16 @@ defmodule OpenAgents.Forge.BuildWorker do

579 579
580 580
  defp build_id_from_path(path), do: path |> Path.basename(".json")
581 581
582
  # Mix records source paths in compiler manifests and BEAM line tables. A
583
  # build-ID workspace changes those paths on every attempt, which makes an
584
  # unchanged module look different and defeats direct-diff classification.
585
  # Each sidecar processes requests serially, so a repository-scoped stable
586
  # path preserves isolation between repositories and stable BEAM identities.
587
  defp workspace_path(builds, repo) do
588
    identity = :sha256 |> :crypto.hash(repo) |> Base.encode16(case: :lower)
589
    Path.join([builds, "jobs", "repo-" <> identity])
590
  end
591
582 592
  defp error_code(reason) do
583 593
    reason
584 594
    |> case do
test/openagents/forge/build_worker_test.exs modified +42 -2

@@ -35,7 +35,9 @@ defmodule OpenAgents.Forge.BuildWorkerTest do

35 35
    build_fun = fn claimed, workspace ->
36 36
      assert claimed["build_id"] == build_id
37 37
      assert claimed["source_sha"] == @sha
38
      assert Path.basename(workspace) == build_id
38
39
      assert Path.basename(workspace) ==
40
               "repo-" <> (:sha256 |> :crypto.hash(@repo) |> Base.encode16(case: :lower))
39 41
40 42
      {:ok, [%{module: Atom.to_string(module), binary: binary}],
41 43
       BuildArtifact.current_toolchain(), [], retained}

@@ -78,7 +80,45 @@ defmodule OpenAgents.Forge.BuildWorkerTest do

78 80
79 81
    assert {:ok, []} = File.ls(Path.join(queue, "requests"))
80 82
    assert {:ok, []} = File.ls(Path.join(queue, "running"))
81
    refute File.exists?(Path.join([builds, "jobs", build_id]))
83
    assert {:ok, []} = File.ls(Path.join(builds, "jobs"))
84
  end
85
86
  test "reuses one stable compiler workspace path across build attempts", context do
87
    baseline = ArtifactFixtures.create!(@repo, String.duplicate("b", 40), []).manifest
88
    first_id = Ecto.UUID.generate()
89
    second_id = Ecto.UUID.generate()
90
    [{module, binary}] = Code.compile_string("defmodule OpenAgents.Scratch.StablePath do\nend")
91
    :code.purge(module)
92
    :code.delete(module)
93
    parent = self()
94
95
    build_fun = fn _request, workspace ->
96
      send(parent, {:workspace, workspace})
97
98
      {:ok, [%{module: Atom.to_string(module), binary: binary}],
99
       BuildArtifact.current_toolchain(), [], "ok"}
100
    end
101
102
    write_request!(context.queue, request(first_id, baseline))
103
104
    assert :processed =
105
             BuildWorker.run_once(context.queue, context.artifacts, context.builds,
106
               build_fun: build_fun
107
             )
108
109
    assert_receive {:workspace, first_workspace}
110
111
    write_request!(context.queue, request(second_id, baseline))
112
113
    assert :processed =
114
             BuildWorker.run_once(context.queue, context.artifacts, context.builds,
115
               build_fun: build_fun
116
             )
117
118
    assert_receive {:workspace, second_workspace}
119
    assert first_workspace == second_workspace
120
    refute String.contains?(first_workspace, first_id)
121
    refute String.contains?(second_workspace, second_id)
82 122
  end
83 123
84 124
  test "unknown request fields fail before the build callback runs", context do

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