Files
OpenShell/examples/multi-agent-notepad/runner.sh
Alexander Watson 142a3a3d31 fix(examples): harden multi-agent notepad 409 retry and improve docs (#1166)
* fix(examples): harden multi-agent notepad 409 retry and improve docs

Add decorrelated jitter to the GitHub Contents API retry loop so racing
workers don't retry in lockstep, and widen the retry set to 5xx for
transient server errors. Without jitter, N concurrent workers trip the
same backoff schedule and re-collide on each beat.

Also:
- Extract the inline 220-line sandbox runner from demo.sh into a
  separate runner.sh. demo.sh stays as host orchestration; runner.sh
  is the per-sandbox program. demo.sh shrinks 460 to 240 lines.
- Rewrite README to lead with "Why GitHub as a shared notepad?" and
  add a "Memory architecture variants" section covering pile,
  append-journal, and indexed-memory patterns.
- Switch Quick Start to use \`gh auth token\` as the primary path.
- Pass the worker slice arg explicitly instead of reading \$9 from the
  outer dispatcher.
- Add .gitignore for personal run_demo.sh helpers.

* docs updates
2026-05-05 08:36:12 -07:00

236 lines
7.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# This script runs INSIDE each OpenShell sandbox.
#
# It is uploaded to /sandbox/payload/demo-runner.sh by demo.sh and invoked as
# the sandbox entrypoint. It receives positional arguments from `run_sandbox()`
# in demo.sh and reads DEMO_GITHUB_TOKEN from the environment, where the
# OpenShell proxy resolves the provider placeholder at the network boundary.
#
# Modes:
# worker — render the worker prompt for a slice, run codex, PUT the note.
# synthesis — read every worker note, run the synthesis prompt, PUT summary.
set -euo pipefail
MODE="$1"
OWNER="$2"
REPO="$3"
BRANCH="$4"
RUN_ID="$5"
AGENT_INDEX="${6:-0}"
AGENT_COUNT="${7:-0}"
TOPIC="${8:-}"
api_url() {
printf 'https://api.github.com%s' "$1"
}
github_request() {
local method="$1"
local path="$2"
local output="$3"
shift 3
curl -sS \
-X "$method" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${DEMO_GITHUB_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$@" \
-o "$output" \
-w "%{http_code}" \
"$(api_url "$path")"
}
render_template() {
local template="$1"
local slice="${2:-}"
node - "$template" "$AGENT_INDEX" "$AGENT_COUNT" "$TOPIC" "$slice" <<'NODE'
const fs = require("fs");
const [templatePath, agentIndex, agentCount, topic, slice] = process.argv.slice(2);
let text = fs.readFileSync(templatePath, "utf8");
text = text.replace(/^(?:<!-- [^\n]* -->\n)+\n?/, "");
const replacements = {
"{{AGENT_INDEX}}": agentIndex,
"{{AGENT_COUNT}}": agentCount,
"{{TOPIC}}": topic,
"{{SLICE}}": slice,
};
for (const [needle, value] of Object.entries(replacements)) {
text = text.split(needle).join(value);
}
process.stdout.write(text);
NODE
}
bootstrap_codex_oauth() {
mkdir -p "$HOME/.codex"
node - <<'NODE'
const fs = require("fs");
const path = `${process.env.HOME}/.codex/auth.json`;
const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const fakeIdToken = [
b64u({ alg: "none", typ: "JWT" }),
b64u({
iss: "https://auth.openai.com",
aud: "codex",
sub: "openshell-placeholder",
email: "placeholder@example.com",
iat: now,
exp: now + 3600,
}),
"placeholder",
].join(".");
fs.writeFileSync(path, JSON.stringify({
auth_mode: "chatgpt",
OPENAI_API_KEY: null,
tokens: {
id_token: fakeIdToken,
access_token: process.env.CODEX_AUTH_ACCESS_TOKEN,
refresh_token: process.env.CODEX_AUTH_REFRESH_TOKEN,
account_id: process.env.CODEX_AUTH_ACCOUNT_ID,
},
last_refresh: new Date().toISOString(),
}, null, 2));
NODE
chmod 600 "$HOME/.codex/auth.json"
}
put_contents() {
local repo_path="$1"
local source_file="$2"
local message="$3"
local get_body put_body put_response status sha
local attempt=0
local max_attempts=6
local sleep_secs=1
get_body="$(mktemp)"
put_body="$(mktemp)"
put_response="$(mktemp)"
while (( attempt < max_attempts )); do
attempt=$((attempt + 1))
status="$(github_request GET "/repos/${OWNER}/${REPO}/contents/${repo_path}?ref=${BRANCH}" "$get_body")"
if [[ "$status" == "200" ]]; then
sha="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.stdout.write(p.sha || "");' "$get_body")"
elif [[ "$status" == "404" ]]; then
sha=""
else
echo "GitHub GET ${repo_path} failed with HTTP ${status}" >&2
cat "$get_body" >&2
return 1
fi
node - "$source_file" "$message" "$BRANCH" "$sha" > "$put_body" <<'NODE'
const fs = require("fs");
const [file, message, branch, sha] = process.argv.slice(2);
const body = {
message,
branch,
content: fs.readFileSync(file).toString("base64"),
};
if (sha) body.sha = sha;
process.stdout.write(JSON.stringify(body));
NODE
status="$(github_request PUT "/repos/${OWNER}/${REPO}/contents/${repo_path}" "$put_response" --data-binary "@${put_body}")"
if [[ "$status" == "200" || "$status" == "201" ]]; then
return 0
fi
# Retry on 409 (concurrent writer) and 5xx (transient server error)
# with decorrelated jitter so racing workers don't retry in lockstep.
if [[ "$status" =~ ^(409|5[0-9][0-9])$ && $attempt -lt $max_attempts ]]; then
sleep "$(( sleep_secs + RANDOM % sleep_secs ))"
sleep_secs=$((sleep_secs * 2))
continue
fi
echo "GitHub PUT ${repo_path} failed with HTTP ${status} (attempt ${attempt}/${max_attempts})" >&2
cat "$put_response" >&2
return 1
done
}
get_contents_file() {
local repo_path="$1"
local destination="$2"
local body status
body="$(mktemp)"
status="$(github_request GET "/repos/${OWNER}/${REPO}/contents/${repo_path}?ref=${BRANCH}" "$body")"
if [[ "$status" != "200" ]]; then
echo "GitHub GET ${repo_path} failed with HTTP ${status}" >&2
cat "$body" >&2
return 1
fi
node - "$body" "$destination" <<'NODE'
const fs = require("fs");
const [bodyPath, destination] = process.argv.slice(2);
const body = JSON.parse(fs.readFileSync(bodyPath, "utf8"));
fs.writeFileSync(destination, Buffer.from((body.content || "").replace(/\s/g, ""), "base64"));
NODE
}
run_codex_to_file() {
local prompt_file="$1"
local output_file="$2"
codex exec \
--skip-git-repo-check \
--sandbox read-only \
--ephemeral \
--output-last-message "$output_file" \
"$(cat "$prompt_file")"
}
worker() {
local slice="$1"
local prompt_file output_file repo_path
bootstrap_codex_oauth
prompt_file="$(mktemp)"
output_file="$(mktemp)"
repo_path="runs/${RUN_ID}/notes/agent-${AGENT_INDEX}.md"
render_template /sandbox/payload/prompts/worker.md "$slice" > "$prompt_file"
run_codex_to_file "$prompt_file" "$output_file"
put_contents "$repo_path" "$output_file" "Add agent ${AGENT_INDEX} note for ${RUN_ID}"
printf 'wrote %s\n' "$repo_path"
}
synthesis() {
local notes_dir prompt_file output_file repo_path
bootstrap_codex_oauth
notes_dir="$(mktemp -d)"
prompt_file="$(mktemp)"
output_file="$(mktemp)"
repo_path="runs/${RUN_ID}/summary.md"
for i in $(seq 1 "$AGENT_COUNT"); do
get_contents_file "runs/${RUN_ID}/notes/agent-${i}.md" "${notes_dir}/agent-${i}.md"
done
render_template /sandbox/payload/prompts/synthesis.md "" > "$prompt_file"
{
printf '\n\n## Worker Notes\n\n'
for i in $(seq 1 "$AGENT_COUNT"); do
printf '\n\n---\n\n'
cat "${notes_dir}/agent-${i}.md"
done
} >> "$prompt_file"
run_codex_to_file "$prompt_file" "$output_file"
put_contents "$repo_path" "$output_file" "Add multi-agent summary for ${RUN_ID}"
printf 'wrote %s\n' "$repo_path"
}
case "$MODE" in
worker) worker "${9:-}" ;;
synthesis) synthesis ;;
*) echo "unknown mode: $MODE" >&2; exit 2 ;;
esac