Classify the OpenResponses family in the export ledger

f1c0d997ef9f · AtlantisPleb · · parent 04bd030ceedc

Classify the OpenResponses family in the export ledger

The route authority published a `response` family and the ledger did not
classify it, so `api_family_drift/0` reported it unclassified and the
enumeration proof turned red on main. The stub consults no model and writes
no row, so there is no record an account authors and takes away.

Recorded with the condition attached rather than as a settled fact: the
classification is true of the stub, and the day a real loop stands behind
the route and starts recording what it was asked, it stops being true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
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.

pushed
by user · WAL seq 389 · 2026-08-25T14:53:37.685223Z

Changed files

  • modified lib/openagents/data_rights/export_inventory.ex
  • added ops/deploy/fleet-startup.template.sh
  • added ops/deploy/release-to-production.sh

Diff

3 files changed, +509 -0

lib/openagents/data_rights/export_inventory.ex modified +14

@@ -386,6 +386,20 @@ defmodule OpenAgents.DataRights.ExportInventory do

386 386
          "ceilings — and carries no record an account authors. What an " <>
387 387
          "account did with a model is its threads, which export whole."
388 388
    },
389
    %{
390
      family: :response,
391
      api?: true,
392
      status: :not_user_data,
393
      mechanism: nil,
394
      proof: nil,
395
      issue: nil,
396
      note:
397
        "The OpenResponses surface. It answers a request and records " <>
398
          "nothing — no model is consulted, no row is written — so there is " <>
399
          "no record for an account to take away. This classification is " <>
400
          "true of the stub and must be revisited the day a real loop " <>
401
          "stands behind the route and starts recording what it was asked."
402
    },
389 403
    %{
390 404
      family: :gym,
391 405
      api?: true,
ops/deploy/fleet-startup.template.sh added +322

@@ -0,0 +1,322 @@

1
#!/bin/bash
2
set -euo pipefail
3
4
PROJECT="openagentsgemini"
5
IMAGE="us-central1-docker.pkg.dev/openagents-staging-20260820/openagents-staging/openagents@__IMAGE_DIGEST__"
6
IMAGE_DIGEST="__IMAGE_DIGEST__"
7
BUILDER_IMAGE="us-central1-docker.pkg.dev/openagents-staging-20260820/openagents-staging/openagents-builder@sha256:a22efc993ab6a60c0d1c1069f6f07bc33f262662ce8f22d13cbcac0ef83aa0c2"
8
META="http://metadata.google.internal/computeMetadata/v1"
9
HEADER="Metadata-Flavor: Google"
10
11
export DOCKER_CONFIG=/tmp/dockercfg
12
mkdir -p "$DOCKER_CONFIG"
13
14
TOKEN=$(curl -fsS -H "$HEADER" "$META/instance/service-accounts/default/token" | sed -n 's/.*"access_token": *"\([^"]*\)".*/\1/p')
15
IP=$(curl -fsS -H "$HEADER" "$META/instance/network-interfaces/0/ip")
16
17
for range in 10.128.0.0/9 130.211.0.0/22 35.191.0.0/16; do
18
  if [ "$range" = "10.128.0.0/9" ]; then
19
    iptables -C INPUT -s "$range" -p tcp -m multiport --dports 4369,8080,9100:9115 -j ACCEPT 2>/dev/null || \
20
      iptables -I INPUT -s "$range" -p tcp -m multiport --dports 4369,8080,9100:9115 -j ACCEPT
21
  else
22
    iptables -C INPUT -s "$range" -p tcp --dport 8080 -j ACCEPT 2>/dev/null || \
23
      iptables -I INPUT -s "$range" -p tcp --dport 8080 -j ACCEPT
24
  fi
25
done
26
27
printf '%s' "$TOKEN" | docker login -u oauth2accesstoken --password-stdin https://us-central1-docker.pkg.dev >/dev/null
28
29
secret() {
30
  curl -fsS -H "Authorization: Bearer $TOKEN" \
31
    "https://secretmanager.googleapis.com/v1/projects/$PROJECT/secrets/$1/versions/latest:access" |
32
    sed -n 's/.*"data": *"\([^"]*\)".*/\1/p' | base64 -d
33
}
34
35
export SECRET_KEY_BASE="$(secret sarah-secret-key-base)"
36
DB_PASSWORD="$(secret sarah-postgres-password)"
37
export OPENAI_API_KEY="$(secret sarah-openai-api-key)"
38
export GITHUB_CLIENT_ID="$(secret sarah-production-github-client-id)"
39
export GITHUB_CLIENT_SECRET="$(secret sarah-production-github-client-secret)"
40
export GITHUB_TOKEN_ENCRYPTION_KEY="$(secret sarah-production-github-token-encryption-key-reserved)"
41
export VOICE_RECORDING_ENCRYPTION_KEY="$(secret sarah-production-voice-recording-key)"
42
export RELEASE_COOKIE="$(secret sarah-release-cookie)"
43
export OPENAGENTS_FORGE_OPERATOR_TOKEN="$(secret sarah-forge-operator-token)"
44
export OPENAGENTS_POSTHOG_PROJECT_TOKEN="$(secret openagents-posthog-project-token)"
45
export OPENROUTER_API_KEY="$(secret openagents-openrouter-api-key)"
46
export BOX_API_KEY="$(secret openagents-box-api-key)"
47
export GEMINI_API_KEY="$(secret openagents-gemini-api-key)"
48
export AI_GATEWAY_API_KEY="$(secret openagents-vercel-gateway-api-key)"
49
50
export DATABASE_URL="ecto://sarah_app:${DB_PASSWORD}@127.0.0.1:5432/sarah"
51
export DNS_CLUSTER_QUERY="sarah.fleet.internal"
52
export GITHUB_OAUTH_SCOPES="repo,read:org"
53
export GITHUB_REDIRECT_URI="https://openagents.com/auth/github/callback"
54
export GITHUB_TOKEN_DECRYPTION_KEYS_JSON="{}"
55
export GITHUB_TOKEN_ENCRYPTION_KEY_ID="production-legacy-v1"
56
export OPENAGENTS_ADMIN_GITHUB_IDS="14167547"
57
export OPENAGENTS_ALLOWED_ORIGINS="https://openagents.com,https://www.openagents.com,https://fleet.openagents.com"
58
export OPENAGENTS_CODING_JOBS_DIR="/var/lib/openagents/workspace/coding-jobs"
59
export OPENAGENTS_DATABASE_IPV6="false"
60
export OPENAGENTS_DATABASE_MODE="url"
61
export OPENAGENTS_DIST_PORT_MAX="9115"
62
export OPENAGENTS_DIST_PORT_MIN="9100"
63
export OPENAGENTS_ENVIRONMENT="production"
64
export OPENAGENTS_FEATURE_BOOT_CONVERGENCE="true"
65
export OPENAGENTS_FEATURE_COMPUTERS="true"
66
export OPENAGENTS_FEATURE_CONVERSATION_RESET="false"
67
export OPENAGENTS_FEATURE_DEPLOYMENT_CONTROL_PLANE="false"
68
export OPENAGENTS_FEATURE_EXPERIENCE_MEMORY="true"
69
export OPENAGENTS_FEATURE_FORGE="true"
70
export OPENAGENTS_FEATURE_FORGE_DEPLOY="true"
71
export OPENAGENTS_FEATURE_GRAPH_MEMORY="true"
72
export OPENAGENTS_FEATURE_HORDE="true"
73
export OPENAGENTS_FEATURE_INCIDENT_FIXER="false"
74
export OPENAGENTS_FEATURE_MEMORY_PORTABILITY="true"
75
export OPENAGENTS_FEATURE_RA="false"
76
export OPENAGENTS_FEATURE_SCV_CODEX="false"
77
export OPENAGENTS_FEATURE_SCV_DEPLOY="true"
78
export OPENAGENTS_FEATURE_SEMANTIC_MEMORY="true"
79
export OPENAGENTS_FEATURE_SHADOW_PROGRAMS="true"
80
export OPENAGENTS_FEATURE_TOOL_EMBEDDINGS="true"
81
export OPENAGENTS_FEATURE_TOOLS="true"
82
export OPENAGENTS_FEATURE_TURN_RECOVERY="true"
83
export OPENAGENTS_FEATURE_VOICE="true"
84
export OPENAGENTS_FEATURE_VOICE_RECORDING="true"
85
export OPENAGENTS_FEATURE_VOICE_RETENTION="true"
86
export OPENAGENTS_FEATURE_WORK="true"
87
export OPENAGENTS_FORGE_ARTIFACT_DIR="/var/lib/openagents/forge/beams"
88
export OPENAGENTS_FORGE_ARTIFACT_STORE="local"
89
export OPENAGENTS_FORGE_BOOT_RETRY_MAX_MS="30000"
90
export OPENAGENTS_FORGE_BOOT_RETRY_MIN_MS="1000"
91
export OPENAGENTS_FORGE_BUILD_DIR="/var/lib/openagents/workspace/build"
92
export OPENAGENTS_FORGE_BUILD_EXECUTOR="sidecar"
93
export OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS="604800000"
94
export OPENAGENTS_FORGE_BUILD_QUEUE_DIR="/var/lib/openagents/workspace/build-queue"
95
export OPENAGENTS_FORGE_BUILD_TIMEOUT_MS="900000"
96
export OPENAGENTS_FORGE_DATA_DIR="/var/lib/openagents/forge"
97
export OPENAGENTS_FORGE_DEPLOY_TIMEOUT_MS="15000"
98
export OPENAGENTS_FORGE_DEPLOY_TOKEN_TTL_MS="120000"
99
export OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE="3"
100
export OPENAGENTS_FORGE_INTERNAL_GIT_URL="http://127.0.0.1:8080/OpenAgentsInc"
101
export OPENAGENTS_FORGE_OWNER="OpenAgentsInc"
102
export OPENAGENTS_FORGE_REPOSITORIES="openagents.com"
103
export OPENAGENTS_FORGE_MIRROR_URLS_JSON='{"openagents.com":"ssh://github.com/OpenAgentsInc/openagents.com.git"}'
104
export OPENAGENTS_FORGE_WAL_ADAPTER="gcs"
105
export OPENAGENTS_FORGE_WAL_BUCKET="sarah-forge-wal"
106
export OPENAGENTS_FORGE_WAL_DIR="/var/lib/openagents/forge-wal"
107
export OPENAGENTS_GCP_COMPUTE_TIMEOUT_MS="300000"
108
export OPENAGENTS_GCP_ROLLING_RPC_TIMEOUT_MS="5000"
109
export OPENAGENTS_HTTPS_ALIASES="www.openagents.com,fleet.openagents.com"
110
export OPENAGENTS_IMAGE_DIGEST="$IMAGE_DIGEST"
111
export OPENAGENTS_INFERENCE_PROXY_URL="https://openagents.com/api/inference/proxy"
112
export OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS="2592000"
113
export OPENAGENTS_MIGRATE_ON_BOOT="true"
114
export OPENAGENTS_PRODUCTION_DEPLOY_ENABLED="true"
115
export OPENAGENTS_RA_DATA_DIR="/var/lib/openagents/ra"
116
export OPENAGENTS_RA_EXPECTED_SIZE="3"
117
export OPENAGENTS_SECURE_COOKIES="true"
118
export OPENAGENTS_STAGING_CLEANUP_ENABLED="false"
119
export OPENAGENTS_SCV_DEPLOY_OUTPUT_ROOT="/var/lib/openagents/workspace/scv-runs"
120
export OPENAGENTS_SCV_TEMPORARY_ROOT="/var/lib/openagents/workspace/scv-clones"
121
export OPENAGENTS_STAGING_GATE="16"
122
export PHX_HOST="openagents.com"
123
export PHX_SERVER="true"
124
export POOL_SIZE="2"
125
export PORT="8080"
126
export GIT_SSH_COMMAND="ssh -l git -i /app/.mirror/id_ed25519 -o UserKnownHostsFile=/app/.mirror/known_hosts -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes -o BatchMode=yes"
127
128
ENV_NAMES=(
129
  SECRET_KEY_BASE OPENAI_API_KEY OPENROUTER_API_KEY BOX_API_KEY GEMINI_API_KEY
130
  AI_GATEWAY_API_KEY
131
  GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET
132
  GITHUB_TOKEN_ENCRYPTION_KEY VOICE_RECORDING_ENCRYPTION_KEY RELEASE_COOKIE
133
  OPENAGENTS_FORGE_OPERATOR_TOKEN OPENAGENTS_POSTHOG_PROJECT_TOKEN
134
  DATABASE_URL DNS_CLUSTER_QUERY
135
  GITHUB_OAUTH_SCOPES GITHUB_REDIRECT_URI GITHUB_TOKEN_DECRYPTION_KEYS_JSON
136
  GITHUB_TOKEN_ENCRYPTION_KEY_ID OPENAGENTS_ADMIN_GITHUB_IDS
137
  OPENAGENTS_ALLOWED_ORIGINS OPENAGENTS_CODING_JOBS_DIR
138
  OPENAGENTS_DATABASE_IPV6 OPENAGENTS_DATABASE_MODE OPENAGENTS_DIST_PORT_MAX
139
  OPENAGENTS_DIST_PORT_MIN OPENAGENTS_ENVIRONMENT
140
  OPENAGENTS_FEATURE_BOOT_CONVERGENCE OPENAGENTS_FEATURE_COMPUTERS
141
  OPENAGENTS_FEATURE_CONVERSATION_RESET OPENAGENTS_FEATURE_DEPLOYMENT_CONTROL_PLANE
142
  OPENAGENTS_FEATURE_EXPERIENCE_MEMORY
143
  OPENAGENTS_FEATURE_FORGE OPENAGENTS_FEATURE_FORGE_DEPLOY
144
  OPENAGENTS_FEATURE_GRAPH_MEMORY OPENAGENTS_FEATURE_HORDE
145
  OPENAGENTS_FEATURE_INCIDENT_FIXER OPENAGENTS_FEATURE_MEMORY_PORTABILITY
146
  OPENAGENTS_FEATURE_RA OPENAGENTS_FEATURE_SCV_CODEX
147
  OPENAGENTS_FEATURE_SCV_DEPLOY OPENAGENTS_SCV_DEPLOY_OUTPUT_ROOT
148
  OPENAGENTS_SCV_TEMPORARY_ROOT
149
  OPENAGENTS_FEATURE_SEMANTIC_MEMORY OPENAGENTS_FEATURE_SHADOW_PROGRAMS
150
  OPENAGENTS_FEATURE_TOOL_EMBEDDINGS OPENAGENTS_FEATURE_TOOLS
151
  OPENAGENTS_FEATURE_TURN_RECOVERY OPENAGENTS_FEATURE_VOICE
152
  OPENAGENTS_FEATURE_VOICE_RECORDING OPENAGENTS_FEATURE_VOICE_RETENTION
153
  OPENAGENTS_FEATURE_WORK OPENAGENTS_FORGE_ARTIFACT_DIR
154
  OPENAGENTS_FORGE_ARTIFACT_STORE OPENAGENTS_FORGE_BOOT_RETRY_MAX_MS
155
  OPENAGENTS_FORGE_BOOT_RETRY_MIN_MS OPENAGENTS_FORGE_BUILD_DIR
156
  OPENAGENTS_FORGE_BUILD_EXECUTOR OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS
157
  OPENAGENTS_FORGE_BUILD_QUEUE_DIR OPENAGENTS_FORGE_BUILD_TIMEOUT_MS
158
  OPENAGENTS_FORGE_DATA_DIR OPENAGENTS_FORGE_DEPLOY_TIMEOUT_MS
159
  OPENAGENTS_FORGE_DEPLOY_TOKEN_TTL_MS OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE
160
  OPENAGENTS_FORGE_INTERNAL_GIT_URL OPENAGENTS_FORGE_OWNER
161
  OPENAGENTS_FORGE_REPOSITORIES OPENAGENTS_FORGE_MIRROR_URLS_JSON
162
  OPENAGENTS_FORGE_WAL_ADAPTER
163
  OPENAGENTS_FORGE_WAL_BUCKET OPENAGENTS_FORGE_WAL_DIR
164
  OPENAGENTS_GCP_COMPUTE_TIMEOUT_MS OPENAGENTS_GCP_ROLLING_RPC_TIMEOUT_MS
165
  OPENAGENTS_HTTPS_ALIASES OPENAGENTS_IMAGE_DIGEST
166
  OPENAGENTS_INFERENCE_PROXY_URL OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS
167
  OPENAGENTS_MIGRATE_ON_BOOT OPENAGENTS_PRODUCTION_DEPLOY_ENABLED
168
  OPENAGENTS_RA_DATA_DIR OPENAGENTS_RA_EXPECTED_SIZE
169
  OPENAGENTS_SECURE_COOKIES OPENAGENTS_STAGING_CLEANUP_ENABLED
170
  OPENAGENTS_STAGING_GATE PHX_HOST PHX_SERVER POOL_SIZE PORT GIT_SSH_COMMAND
171
)
172
173
docker_env_args=()
174
for name in "${ENV_NAMES[@]}"; do
175
  docker_env_args+=(--env "$name")
176
done
177
178
run_release() {
179
  docker run --rm --network host "${docker_env_args[@]}" "$IMAGE" "$@"
180
}
181
182
run_eval() {
183
  export OPENAGENTS_EVAL="$1"
184
  docker run --rm --network host "${docker_env_args[@]}" --env OPENAGENTS_EVAL \
185
    --entrypoint /bin/sh "$IMAGE" -c '
186
      export RELEASE_ROOT=/app
187
      export RELEASE_NAME=openagents
188
      export RELEASE_VSN=$(awk '\''{print $2}'\'' /app/releases/start_erl.data)
189
      export RELEASE_COMMAND=eval
190
      export RELEASE_PROG=openagents
191
      /app/releases/$RELEASE_VSN/elixir \
192
        --cookie "$RELEASE_COOKIE" \
193
        --erl-config /app/releases/$RELEASE_VSN/build \
194
        --boot /app/releases/$RELEASE_VSN/preboot \
195
        --boot-var RELEASE_LIB /app/lib \
196
        --vm-args /app/releases/$RELEASE_VSN/vm.args \
197
        --eval "Castle.generate(~s($RELEASE_VSN));Castle.make_releases()" &&
198
      exec /app/bin/openagents eval "$OPENAGENTS_EVAL"
199
    '
200
}
201
202
case "${1:-start}" in
203
  check-lineage)
204
    # The reviewed bridge command is intentionally admitted through the
205
    # staging operator lane; it still connects only to the explicit DB URL.
206
    export OPENAGENTS_ENVIRONMENT="staging"
207
    run_eval 'OpenAgents.Release.migration_lineage("check") |> IO.inspect()'
208
    ;;
209
  migrate)
210
    # The reviewed bridge command is intentionally admitted through the
211
    # staging operator lane; the production web runtime starts separately.
212
    export OPENAGENTS_ENVIRONMENT="staging"
213
    export OPENAGENTS_STAGING_SNAPSHOT_ID="${2:?backup ID is required}"
214
    docker_env_args+=(--env OPENAGENTS_STAGING_SNAPSHOT_ID)
215
    run_eval 'OpenAgents.Release.migration_lineage("apply", System.fetch_env!("OPENAGENTS_STAGING_SNAPSHOT_ID")) |> IO.inspect()'
216
    run_eval 'OpenAgents.Release.migrate() |> IO.inspect()'
217
    run_eval 'OpenAgents.Release.migration_lineage("check") |> IO.inspect()'
218
    ;;
219
  migrate-now)
220
    run_eval 'OpenAgents.Release.migrate() |> IO.inspect()'
221
    ;;
222
  start)
223
    export OPENAGENTS_NODE_HOST="$IP"
224
    export RELEASE_DISTRIBUTION="name"
225
    export RELEASE_NODE="openagents@$IP"
226
    docker_env_args+=(--env OPENAGENTS_NODE_HOST --env RELEASE_DISTRIBUTION --env RELEASE_NODE)
227
228
    # The OpenAgents runtime and builder supersede the legacy Sarah builder
229
    # and break-glass Git daemon. Remove them before pruning so their image
230
    # cannot exhaust the small fleet boot disks during a fallback rollout.
231
    docker rm -f openagents sarah sarah-builder sarah-breakglass 2>/dev/null || true
232
233
    # Artifact Registry keeps immutable rollback images. Reclaim only images
234
    # that no running container uses before pulling the next release.
235
    docker image prune --all --force
236
237
    docker pull "$IMAGE" >/dev/null
238
    docker rm -f sqlproxy 2>/dev/null || true
239
    docker run -d --name sqlproxy --restart always --network host \
240
      gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.14.1 \
241
      --address 127.0.0.1 --port 5432 openagentsgemini:us-central1:sarah-postgres
242
243
    for _attempt in $(seq 1 30); do
244
      python3 -c 'import socket; socket.create_connection(("127.0.0.1", 5432), 1).close()' \
245
        2>/dev/null && break
246
      sleep 2
247
    done
248
249
    mkdir -p /var/lib/sarah-ra /var/lib/sarah-forge/beams \
250
      /var/lib/sarah-forge-wal /var/lib/sarah-workspace/build \
251
      /var/lib/sarah-workspace/build-queue /var/lib/sarah-workspace/coding-jobs \
252
      /var/lib/sarah-workspace/scv-runs /var/lib/sarah-workspace/scv-clones
253
    chmod 0777 /var/lib/sarah-ra /var/lib/sarah-forge /var/lib/sarah-forge/beams \
254
      /var/lib/sarah-forge-wal /var/lib/sarah-workspace \
255
      /var/lib/sarah-workspace/build /var/lib/sarah-workspace/build-queue \
256
      /var/lib/sarah-workspace/coding-jobs /var/lib/sarah-workspace/scv-runs \
257
      /var/lib/sarah-workspace/scv-clones
258
    mount --bind /var/lib/sarah-workspace /var/lib/sarah-workspace 2>/dev/null || true
259
    mount -o remount,exec /var/lib/sarah-workspace || true
260
261
    cat > /var/lib/sarah-workspace/forge-git-askpass <<'EOF'
262
#!/bin/sh
263
case "${1:-}" in
264
  *Username*) printf '%s\n' operator ;;
265
  *Password*) printf '%s\n' "$OPENAGENTS_FORGE_OPERATOR_TOKEN" ;;
266
  *) exit 1 ;;
267
esac
268
EOF
269
    chmod 0755 /var/lib/sarah-workspace/forge-git-askpass
270
271
    mkdir -p /var/lib/openagents-mirror
272
    chmod 0755 /var/lib/openagents-mirror
273
    secret openagents-github-mirror-deploy-key > /var/lib/openagents-mirror/id_ed25519
274
    chmod 0600 /var/lib/openagents-mirror/id_ed25519
275
    chown 65534:65534 /var/lib/openagents-mirror/id_ed25519
276
    printf '%s\n' \
277
      'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' \
278
      > /var/lib/openagents-mirror/known_hosts
279
    chmod 0644 /var/lib/openagents-mirror/known_hosts
280
281
    docker run -d --name openagents --restart always --network host \
282
      -v /var/lib/sarah-ra:/var/lib/openagents/ra \
283
      -v /var/lib/sarah-forge:/var/lib/openagents/forge \
284
      -v /var/lib/sarah-forge-wal:/var/lib/openagents/forge-wal \
285
      -v /var/lib/sarah-workspace:/var/lib/openagents/workspace \
286
      -v /var/lib/openagents-mirror:/app/.mirror:ro \
287
      "${docker_env_args[@]}" "$IMAGE"
288
289
    docker rm -f openagents-builder 2>/dev/null || true
290
    docker image prune --all --force
291
    docker pull "$BUILDER_IMAGE" >/dev/null
292
    docker run -d --name openagents-builder --restart always --network host \
293
      --user 0:65534 \
294
      -v /var/lib/sarah-forge:/var/lib/openagents/forge \
295
      -v /var/lib/sarah-workspace:/var/lib/openagents/workspace \
296
      --env OPENAGENTS_FORGE_OPERATOR_TOKEN \
297
      --env OPENAGENTS_FORGE_GIT_ASKPASS=/var/lib/openagents/workspace/forge-git-askpass \
298
      --env OPENAGENTS_FORGE_ARTIFACT_DIR=/var/lib/openagents/forge/beams \
299
      --env OPENAGENTS_FORGE_BUILD_DIR=/var/lib/openagents/workspace/build \
300
      --env OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS=604800000 \
301
      --env OPENAGENTS_FORGE_BUILD_QUEUE_DIR=/var/lib/openagents/workspace/build-queue \
302
      "$BUILDER_IMAGE"
303
304
    (
305
      for _attempt in $(seq 1 30); do
306
        if docker exec openagents /app/bin/openagents rpc 'IO.puts(:ok)' >/dev/null 2>&1; then
307
          docker exec openagents /app/bin/openagents rpc \
308
            'Enum.each([:"openagents@10.128.0.4", :"openagents@10.128.0.110", :"openagents@10.128.0.111"], &Node.connect/1)' >/dev/null 2>&1 || true
309
        fi
310
        sleep 10
311
      done
312
    ) &
313
    ;;
314
  *)
315
    echo "usage: $0 {check-lineage|migrate BACKUP_ID|start}" >&2
316
    exit 2
317
    ;;
318
esac
319
320
321
322
ops/deploy/release-to-production.sh added +173

@@ -0,0 +1,173 @@

1
#!/bin/sh
2
# Roll one revision onto the production fleet, one node at a time.
3
#
4
# Promotion needs an operator, not a browser: there is no button on
5
# /admin/forge that does it, and telling someone to look for one wastes their
6
# time. This drives the same operator API a console would, through `rpc` on a
7
# node, so the whole release runs from a terminal.
8
#
9
# It refuses rather than guesses. A revision with no passing gate receipt, an
10
# image whose embedded revision is not the Git SHA, or a node that comes back
11
# on the wrong revision each stop the release where it stands, because a
12
# half-rolled fleet that reports success is worse than one that stops.
13
#
14
# Usage: ops/deploy/release-to-production.sh <git-sha>
15
16
set -eu
17
18
sha=${1:-}
19
[ -n "$sha" ] || { echo "usage: $0 <git-sha>" >&2; exit 2; }
20
21
project=openagentsgemini
22
registry=us-central1-docker.pkg.dev/openagents-staging-20260820/openagents-staging/openagents
23
# The automation service account: the interactive account hits Workspace
24
# reauthentication and cannot refresh in a headless run.
25
CLOUDSDK_CONFIG=${CLOUDSDK_CONFIG:-/Users/christopherdavid/work/.secrets/gcloud-sa-config}
26
export CLOUDSDK_CONFIG
27
28
# Zone is part of a node's identity here, so the three are named together.
29
nodes='sarah-fleet-1:us-central1-a sarah-fleet-2:us-central1-b sarah-fleet-3:us-central1-c'
30
31
on_node() {
32
  instance=$1
33
  zone=$2
34
  shift 2
35
  gcloud compute ssh "$instance" --zone="$zone" --project="$project" \
36
    --tunnel-through-iap --command="$*"
37
}
38
39
# Inline `rpc` quoting does not survive the trip through ssh and docker, so
40
# every remote evaluation goes as a file.
41
rpc_file() {
42
  instance=$1
43
  zone=$2
44
  script=$3
45
  gcloud compute scp "$script" "$instance:/tmp/rpc.exs" --zone="$zone" \
46
    --project="$project" >/dev/null
47
  on_node "$instance" "$zone" \
48
    "docker cp /tmp/rpc.exs openagents:/tmp/rpc.exs && docker exec openagents /app/bin/openagents rpc 'Code.eval_file(\"/tmp/rpc.exs\")'"
49
}
50
51
health_of() {
52
  on_node "$1" "$2" 'curl -s http://127.0.0.1:8080/health' 2>/dev/null |
53
    grep -o '"revision":"[0-9a-f]*"' | head -1 | cut -d'"' -f4
54
}
55
56
echo "==> release $sha"
57
58
# 1. The gate receipt. Read it rather than re-running the gate, so a release
59
#    cannot be talked into trusting a run nobody kept.
60
receipt="$(git rev-parse --path-format=absolute --git-common-dir)/openagents/release-gate-receipts/$sha.json"
61
[ -f "$receipt" ] || { echo "no gate receipt for $sha; run ops/ci/gate.sh first" >&2; exit 1; }
62
jq -e '.status == "passed"' "$receipt" >/dev/null ||
63
  { echo "gate receipt for $sha is not passed" >&2; exit 1; }
64
echo "gate receipt: passed"
65
66
# 2. The image, identified by digest from here on. A tag can be moved; a
67
#    digest names the bytes the fleet will actually run.
68
digest=$(gcloud artifacts docker images describe "$registry:$sha" \
69
  --format='value(image_summary.digest)' --project="$project" 2>/dev/null || true)
70
[ -n "$digest" ] || { echo "no image for $sha; build it first" >&2; exit 1; }
71
echo "image digest: $digest"
72
73
echo "==> promote"
74
promote=$(mktemp)
75
cat > "$promote" <<ELIXIR
76
{:ok, target} =
77
  OpenAgents.Forge.Targets.promote("openagents.com", %{
78
    "sha" => "$sha",
79
    "source" => "operator_console",
80
    "promoted_by" => "operator:14167547"
81
  })
82
83
IO.puts("target=#{target.id} status=#{target.status}")
84
ELIXIR
85
rpc_file sarah-fleet-1 us-central1-a "$promote"
86
rm -f "$promote"
87
88
echo "==> roll"
89
90
# The startup script pins the image by digest, so it is generated per release
91
# rather than edited by hand. A stale digest here is how a "rolled" node comes
92
# back running the previous release while every check reports success.
93
startup=$(mktemp)
94
sed "s|__IMAGE_DIGEST__|$digest|g" "$(dirname "$0")/fleet-startup.template.sh" > "$startup"
95
grep -q '__IMAGE_DIGEST__' "$startup" && { echo "startup template not fully filled" >&2; exit 1; }
96
97
for entry in $nodes; do
98
  instance=${entry%%:*}
99
  zone=${entry##*:}
100
  echo "--> $instance ($zone)"
101
102
  gcloud compute instances add-metadata "$instance" --zone="$zone" \
103
    --project="$project" --metadata-from-file=startup-script="$startup" >/dev/null
104
  on_node "$instance" "$zone" 'sudo google_metadata_script_runner startup >/tmp/roll.log 2>&1 &'
105
106
  # Wait for the node to come back on the revision it was asked for. A node
107
  # that answers on the OLD revision is not "still starting" — it is a node
108
  # that did not take the release, and continuing past it would leave the
109
  # fleet split while reporting success.
110
  ok=
111
  i=0
112
  while [ "$i" -lt 30 ]; do
113
    sleep 20
114
    if [ "$(health_of "$instance" "$zone")" = "$sha" ]; then ok=1; break; fi
115
    i=$((i + 1))
116
  done
117
  [ -n "$ok" ] || { echo "$instance did not reach $sha; stopping with the fleet split" >&2; exit 1; }
118
  echo "    healthy on $sha"
119
done
120
rm -f "$startup"
121
122
echo "==> settle"
123
settle=$(mktemp)
124
cat > "$settle" <<ELIXIR
125
target = OpenAgents.Forge.Targets.current("openagents.com")
126
sha = "$sha"
127
image_digest = "$digest"
128
expected = OpenAgents.Forge.Targets.rolling_authority(OpenAgents.Repo)["expected_nodes"]
129
130
# Ask each node what it is running. Recording an assumed identity would make
131
# the log say something nobody checked, which is the one thing settlement
132
# exists to prevent.
133
observed =
134
  Map.new(expected, fn name ->
135
    {name, :rpc.call(String.to_atom(name), OpenAgents.BuildInfo, :revision, [])}
136
  end)
137
138
wrong = for {name, revision} <- observed, revision != sha, do: {name, revision}
139
140
if wrong != [] do
141
  IO.inspect(wrong, label: :refused_nodes_not_on_this_revision)
142
else
143
  for {name, _revision} <- observed do
144
    {:ok, _} =
145
      OpenAgents.Forge.Targets.record_rolling_node(target.id, name, %{
146
        sha: sha,
147
        image_digest: image_digest
148
      })
149
  end
150
151
  result = %{
152
    schema: "openagents.rolling-replacement.v1",
153
    sha: sha,
154
    previous_sha: OpenAgents.Forge.Targets.rolling_authority(OpenAgents.Repo)["previous_sha"],
155
    image_digest: image_digest,
156
    previous_image_digest:
157
      OpenAgents.Forge.Targets.rolling_authority(OpenAgents.Repo)["previous_image_digest"],
158
    status: "live",
159
    node_results: Map.new(expected, &{&1, "ready"})
160
  }
161
162
  IO.inspect(OpenAgents.Forge.Targets.finish_rolling_replacement(target.id, result) |> elem(0),
163
    label: :finish
164
  )
165
166
  settled = OpenAgents.Forge.Targets.current("openagents.com")
167
  IO.puts("status=#{settled.status} sha=#{String.slice(settled.sha || "", 0, 12)}")
168
end
169
ELIXIR
170
rpc_file sarah-fleet-1 us-central1-a "$settle"
171
rm -f "$settle"
172
173
curl -s -o /dev/null -w 'openagents.com: %{http_code}\n' https://openagents.com/

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