feat(stage-1d): gateway, dispatcher service, director persona, render updates

This commit is contained in:
John Smith committed 2026-10-14 17:04:05 -04:00
1 parent f71f89cc5a
commit f01279777f
16 files changed
+580 -35

No files matched your search

+2
View File
@@ -3,6 +3,8 @@ docs/prompts/v1/
docs/audit/
docs/evidence/
docker-compose.yml
docker-compose.ports.yml
gateway/Caddyfile
experts/
workspace/
state/
+2 -2
View File
@@ -75,7 +75,7 @@ engine-live:
bash scripts/accept/1c/engine-live.inc both
e2e:
@echo "not built until stage 4c: e2e" >&2; exit 1
bash scripts/e2e.sh
gc:
@echo "not built until stage 3b: gc" >&2; exit 1
bash scripts/gc.sh
+6 -5
View File
@@ -1,7 +1,8 @@
# The Agency dispatcher image (Stage 1c). FROM the pinned base image, plus
# Bun sources for driveway engine runs. Runs as `agent`, attaches to the
# `fleet` network. Mounts `/work`, `/artifacts`, the registry, and `/data`
# at run time. It has no server entrypoint yet: Stage 1d adds one.
# The Agency dispatcher image (Stage 1d). FROM the pinned base image, plus
# Bun sources for the service. Runs as `agent`, attaches to the `fleet`
# network. Mounts `/work`, `/artifacts`, and `/data` (state, registry,
# audit) at run time. The server is dispatcher/src/main.ts: REST, SSE,
# and the fleet MCP endpoint on port 8080.
ARG OPENCODE_VERSION=2.0.26
ARG AGENT_UID=1000
ARG AGENT_GID=1000
@@ -28,4 +29,4 @@ USER agent
WORKDIR /app
# The base image's OpenCode entrypoint does not apply here.
ENTRYPOINT ["tini", "--"]
CMD ["bun", "--version"]
CMD ["bun", "dispatcher/src/main.ts"]
+30
View File
@@ -0,0 +1,30 @@
# Fallback port publishing for expert debug access (Stage 1d, Step 4).
# Not applied by default: `make up` publishes only the gateway.
# Apply explicitly when direct OpenCode UI access is needed:
# docker compose -f docker-compose.yml -f docker-compose.ports.yml up -d
# Every mapping stays on the loopback interface.
services:
director:
ports: ["127.0.0.1:4111:4096"]
scout:
ports: ["127.0.0.1:4112:4096"]
architect:
ports: ["127.0.0.1:4113:4096"]
product:
ports: ["127.0.0.1:4114:4096"]
frontend:
ports: ["127.0.0.1:4115:4096"]
backend:
ports: ["127.0.0.1:4116:4096"]
dba:
ports: ["127.0.0.1:4117:4096"]
platform:
ports: ["127.0.0.1:4118:4096"]
security:
ports: ["127.0.0.1:4119:4096"]
qa:
ports: ["127.0.0.1:4120:4096"]
reviewer:
ports: ["127.0.0.1:4121:4096"]
docs:
ports: ["127.0.0.1:4122:4096"]
+113
View File
@@ -745,6 +745,117 @@ services:
volumes:
- ./egress/allowlist.txt:/etc/squid/allowlist.txt:ro,z
- ./egress/squid.conf:/etc/squid/squid.conf:ro,z
dispatcher:
container_name: agency-dispatcher
build:
context: .
dockerfile: dispatcher/Dockerfile
args:
OPENCODE_VERSION: ${OPENCODE_VERSION}
AGENT_UID: ${AGENT_UID}
AGENT_GID: ${AGENT_GID}
image: agency-dispatcher:1.0.0
restart: unless-stopped
working_dir: /app
networks:
- fleet
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 512
cpus: 2
mem_limit: 4g
depends_on:
director:
condition: service_healthy
scout:
condition: service_healthy
architect:
condition: service_healthy
product:
condition: service_healthy
frontend:
condition: service_healthy
backend:
condition: service_healthy
dba:
condition: service_healthy
platform:
condition: service_healthy
security:
condition: service_healthy
qa:
condition: service_healthy
reviewer:
condition: service_healthy
docs:
condition: service_healthy
verifier:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
environment:
AGENCY_MODEL: ${AGENCY_MODEL}
FLEET_MCP_TOKEN: ${FLEET_MCP_TOKEN}
VERIFIER_TOKEN: ${VERIFIER_TOKEN}
DISPATCHER_STATE_DIR: /data
DISPATCHER_REGISTRY_DIR: /data/registry
DISPATCHER_WORKSPACE: /work
DISPATCHER_ARTIFACTS: /artifacts
DISPATCHER_ALLOWLIST: /data/registry/allowlist.yaml
PW_DIRECTOR: ${PW_DIRECTOR}
PW_SCOUT: ${PW_SCOUT}
PW_ARCHITECT: ${PW_ARCHITECT}
PW_PRODUCT: ${PW_PRODUCT}
PW_FRONTEND: ${PW_FRONTEND}
PW_BACKEND: ${PW_BACKEND}
PW_DBA: ${PW_DBA}
PW_PLATFORM: ${PW_PLATFORM}
PW_SECURITY: ${PW_SECURITY}
PW_QA: ${PW_QA}
PW_REVIEWER: ${PW_REVIEWER}
PW_DOCS: ${PW_DOCS}
volumes:
- ${AGENCY_WORKSPACE:-./workspace}:/work:rw,z
- artifacts:/artifacts
- ./state:/data:rw,z
- dispatcher-cache:/home/agent/.cache
gateway:
container_name: agency-gateway
image: caddy@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
restart: unless-stopped
networks:
- fleet
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
cpus: 1
mem_limit: 512m
depends_on:
dispatcher:
condition: service_healthy
healthcheck:
test:
- CMD-SHELL
- curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
ports:
- 127.0.0.1:${AGENCY_HTTP_PORT}:8080
volumes:
- ./gateway/Caddyfile:/etc/caddy/Caddyfile:ro,z
- gateway-data:/data
networks:
fleet:
internal: true
@@ -789,3 +900,5 @@ volumes:
docs-state: {}
docs-cache: {}
verifier-cache: {}
dispatcher-cache: {}
gateway-data: {}
+85 -22
View File
@@ -3,28 +3,91 @@ description: Mission orchestrator. Picks a playbook, writes the plan and task br
mode: primary
---
You are the Director, the mission orchestrator of the expert fleet. You own
decomposition, briefs, sequencing, rework decisions, and the final mission
report. You never edit files and you cannot skip a playbook gate; landing is
done by the Dispatcher, not by you.
# Director
What you hand off, and to whom: everything except planning and judgement.
Reconnaissance goes to scout, design to architect, requirements to product,
implementation to frontend or backend, data work to dba, delivery
definitions to platform, test authoring to qa, review to reviewer, and
documentation to docs. Name the expert and state the request; never do
their job yourself.
## Identity
How you work: read the mission objective and the repository's control
files first, pick the playbook that fits, then slice the work by owner and
write path so each task has one expert and a small set of files. Write each
brief with an objective, context, constraints, and numbered acceptance
criteria. When results return, check every criterion against the evidence
quoted in the report before you accept; if evidence is missing, send the
task back with one precise question rather than guessing.
You lead missions. You do not write code: the workspace is mounted
read-only for you, and your shell is limited to `git log`, `git status`,
`git diff`, and `git show`. Your tools are reading the repository and
the `fleet_*` tools on the Dispatcher's MCP server. Landing, merging,
verification, and scheduling belong to the Dispatcher; you cannot skip a
playbook gate, and you never push, merge, or switch branches. Your
reports go under `/artifacts/<taskId>/`. What you hand off, and to whom:
everything except planning and judgement. Reconnaissance goes to scout,
design to architect, requirements to product, implementation to frontend
or backend, data work to dba, delivery definitions to platform, test
authoring to qa, review to reviewer, and documentation to docs. Name the
expert and state the request; never do their job yourself.
You are read-only: the workspace is mounted read-only for you, your shell
is limited to `git log`, `git status`, `git diff`, and `git show`, and your
reports go under `/artifacts/<taskId>/`. You end your final message with
the `RESULT` block from your fleet protocol, with the plan or the accept
and rework decisions recorded in it.
## Start
Call `fleet_roster` to see who is available, healthy, and loaded, then
`fleet_mission_create` with the mission objective, which returns the
mission id and the repository's ownership map summary. Read enough of
the repository to plan (recent history, the areas the objective
touches). If the objective is ambiguous, ask the person one concise
question and wait; otherwise proceed without further questions.
## Planning
Each task has one owner, paths inside that owner's grant, and checkable
acceptance criteria. Slice by owner and write path so no two tasks claim
the same files; order by dependency (interfaces before implementations,
builders before the test author). Tell the person the plan in a few
lines — what each expert will do and what it waits on — then call
`fleet_plan_submit`. When the mission parks as `waiting-human`, stop and
wait for the human's approval; never resubmit a smaller plan to dodge
the threshold.
## Briefs
A brief stands alone; the expert sees nothing of this conversation. Each
brief carries an objective (what changes and why), the context it needs
(file pointers, the decision being implemented, prior results it builds
on), constraints (granted paths, what is out of scope, artifacts under
`/artifacts/<taskId>/`), and acceptance criteria as a numbered list,
each checkable by running something (`bun test tests/x.test.ts`, not
"tests pass"). If AGENTS.md exists at the worktree root, the brief tells
the expert to read it first.
## Waiting
After submitting, stop. You will be woken by a `[fleet]` message when a
task reaches a terminal state or the mission needs a decision. On
waking, call `fleet_mission_get` and act on what changed: read results,
decide, or replan. If you wake without a session to return to — after a
compaction or restart — recover with `fleet_mission_get` first, then
continue. When there is nothing to decide yet, call `fleet_wait` and
stop again; do not poll, do not chat, do not invent progress.
## Deciding
Read `fleet_task_result` first and `fleet_task_diff` only when the
report does not settle the question. Match every acceptance criterion
against quoted evidence (commands, exit codes, observed output) before
you accept. Do not accept a task with verification failures, blocked
status, or scope failures. Decide with `fleet_decide`: `accept` for
verified work inside scope, `rework` with one precise question rather
than a guess, `abandon` when the slice was wrong. Rework at most twice,
then report to the person instead of trying a third time.
## Handoffs
Turn each handoff line in a result (`{"expert": ..., "request": ...}`)
into a new task in the plan: same brief discipline, the named expert as
owner, paths inside that expert's grant, and a dependency on the task
that asked for it. Never do the handoff work yourself and never hand
work to an expert outside its grant.
## Report
When tasks have landed, call `fleet_mission_close` with the summary and
report to the person what was done, where it is (the integration branch
and merge SHAs), what was verified (the checks that ran green on the
integration branch), and what remains open (assumptions, risks,
follow-ups). The Dispatcher refuses the close unless every code change
carries a landed SHA and the last integration verify is green; write
only from landed evidence. End your final message with the `RESULT`
block from your fleet protocol, with the plan and the accept and rework
decisions recorded in it.
@@ -24,3 +24,10 @@ knowledge produces hallway results.
The expert can start without asking anything; each criterion names its
check (`bun test tests/x.test.ts`, not "tests pass").
## Fleet tools
A brief is the `objective`, `context`, `constraints`, `acceptance`, and
`paths` of one `fleet_plan_submit` entry. Read the repository first
(`fleet_roster` for who does what, `git log` and `git diff` for the
ground truth); the expert sees only the brief, never this conversation.
@@ -22,3 +22,11 @@ end.
A human reading only the report knows what shipped, what did not, and
what to watch.
## Fleet tools
Recover with `fleet_mission_get` (objective, plan, every task status and
reason, decisions) after any compaction or restart. Close with
`fleet_mission_close`, which the Dispatcher refuses unless every code
change carries a landed SHA and the last integration verify is green;
write the summary only from landed evidence.
@@ -23,3 +23,11 @@ objective changes shape.
One playbook named, its gates listed in the plan, and no gate skipped or
weakened to fit the schedule.
## Fleet tools
Start with `fleet_roster` (who is available and healthy) and
`fleet_mission_create` (which returns the repository's ownership summary).
The plan you submit must keep every task's paths inside its owner's
grant; `fleet_plan_submit` refuses anything outside, and that refusal is
a planning error, not a negotiation.
@@ -24,3 +24,11 @@ evidence you have not checked.
Each decision cites the evidence, the attempt number, and what changes
next; nothing is accepted on trust.
## Fleet tools
Read `fleet_task_result` first and `fleet_task_diff` only when the report
does not settle the question. Decide with `fleet_decide`: `accept` only
for verified work inside scope, `rework` with one precise question (at
most twice), `abandon` when the slice was wrong. Turn each handoff line
into a new task rather than doing it yourself.
@@ -25,3 +25,11 @@ single-file fixes that one expert can own outright.
Every task names its expert, its files, and what it waits on; no two tasks
claim the same files; the plan fits the playbook's task ceiling.
## Fleet tools
Submit the sliced plan with `fleet_plan_submit`: each entry carries `key`,
`expert`, `title`, `objective`, `context`, `constraints`, `acceptance[]`,
`paths[]`, and `deps[]`. When the mission parks as `waiting-human`, tell
the person the plan in a few lines and wait; never resubmit to dodge
approval.
+83
View File
@@ -0,0 +1,83 @@
# GENERATED, do not edit. Rendered by scripts/render.ts from fleet.yaml.
{
admin off
http_port 8080
}
http://agency.localhost {
reverse_proxy dispatcher:8080 {
flush_interval -1
}
}
http://director.agency.localhost {
reverse_proxy director:4096 {
flush_interval -1
}
}
http://scout.agency.localhost {
reverse_proxy scout:4096 {
flush_interval -1
}
}
http://architect.agency.localhost {
reverse_proxy architect:4096 {
flush_interval -1
}
}
http://product.agency.localhost {
reverse_proxy product:4096 {
flush_interval -1
}
}
http://frontend.agency.localhost {
reverse_proxy frontend:4096 {
flush_interval -1
}
}
http://backend.agency.localhost {
reverse_proxy backend:4096 {
flush_interval -1
}
}
http://dba.agency.localhost {
reverse_proxy dba:4096 {
flush_interval -1
}
}
http://platform.agency.localhost {
reverse_proxy platform:4096 {
flush_interval -1
}
}
http://security.agency.localhost {
reverse_proxy security:4096 {
flush_interval -1
}
}
http://qa.agency.localhost {
reverse_proxy qa:4096 {
flush_interval -1
}
}
http://reviewer.agency.localhost {
reverse_proxy reviewer:4096 {
flush_interval -1
}
}
http://docs.agency.localhost {
reverse_proxy docs:4096 {
flush_interval -1
}
}
+59 -2
View File
@@ -1,14 +1,20 @@
#!/usr/bin/env bash
# init-env for The Agency (Stage 1b, Step 7).
# init-env for The Agency (Stage 1b, Step 7; allowlist in Stage 1d).
# From the salvaged studio init: creates .env (host uid/gid, gateway port,
# a random value for every PW_<NAME>, PROXY_PW_<NAME>, and token) and a
# placeholder .env.provider, both mode 0600, never overwriting an existing
# file. Never prints a secret: values are generated silently.
# Stage 1d addition: seeds state/registry/allowlist.yaml (admin, approver,
# viewer with argon2-hashed passwords) and the operator copy
# secrets/allowlist.env with the plaintext passwords, both mode 0600 and
# never overwritten.
set -u
ROOT="$(git rev-parse --show-toplevel)"
ENVFILE="$ROOT/.env"
PROVIDERFILE="$ROOT/.env.provider"
ALLOWLIST="$ROOT/state/registry/allowlist.yaml"
OPERATOR="$ROOT/secrets/allowlist.env"
if [ -f "$ENVFILE" ]; then
echo "init-env: $ENVFILE exists; refusing to overwrite (delete it to re-init)" >&2
@@ -18,11 +24,23 @@ if [ -f "$PROVIDERFILE" ]; then
echo "init-env: $PROVIDERFILE exists; refusing to overwrite (delete it to re-init)" >&2
exit 1
fi
if [ -f "$ALLOWLIST" ]; then
echo "init-env: $ALLOWLIST exists; refusing to overwrite (delete it to re-init)" >&2
exit 1
fi
if [ -f "$OPERATOR" ]; then
echo "init-env: $OPERATOR exists; refusing to overwrite (delete it to re-init)" >&2
exit 1
fi
command -v python3 >/dev/null 2>&1 || {
echo "init-env: missing required command python3" >&2
exit 1
}
command -v bun >/dev/null 2>&1 || {
echo "init-env: missing required command bun (needed to hash allowlist passwords)" >&2
exit 1
}
gen_secret() {
python3 -c "import secrets; print(secrets.token_urlsafe(24))" || {
@@ -67,4 +85,43 @@ chmod 600 "$ENVFILE"
} >"$PROVIDERFILE"
chmod 600 "$PROVIDERFILE"
echo "init-env: wrote $ENVFILE and $PROVIDERFILE (0600). Set AGENCY_MODEL in .env and OPENCODE_API_KEY in .env.provider."
ADMIN_PW="$(gen_secret)" || exit 1
APPROVER_PW="$(gen_secret)" || exit 1
VIEWER_PW="$(gen_secret)" || exit 1
ADMIN_HASH="$(bun -e 'console.log(await Bun.password.hash(process.argv[2], { algorithm: "argon2id" }))' "$ADMIN_PW")" || {
echo "init-env: hashing the admin password failed" >&2
exit 1
}
APPROVER_HASH="$(bun -e 'console.log(await Bun.password.hash(process.argv[2], { algorithm: "argon2id" }))' "$APPROVER_PW")" || {
echo "init-env: hashing the approver password failed" >&2
exit 1
}
VIEWER_HASH="$(bun -e 'console.log(await Bun.password.hash(process.argv[2], { algorithm: "argon2id" }))' "$VIEWER_PW")" || {
echo "init-env: hashing the viewer password failed" >&2
exit 1
}
mkdir -p "$ROOT/state/registry" "$ROOT/secrets"
{
echo "# Generated by scripts/init-env.sh. Do not commit."
echo "version: 1"
echo "users:"
echo " - username: admin"
echo " role: admin"
echo " passwordHash: '$ADMIN_HASH'"
echo " - username: approver"
echo " role: approver"
echo " passwordHash: '$APPROVER_HASH'"
echo " - username: viewer"
echo " role: viewer"
echo " passwordHash: '$VIEWER_HASH'"
} >"$ALLOWLIST"
chmod 600 "$ALLOWLIST"
{
echo "# Generated by scripts/init-env.sh. Operator copy of dashboard passwords; never commit."
echo "AGENCY_ADMIN_PASSWORD=$ADMIN_PW"
echo "AGENCY_APPROVER_PASSWORD=$APPROVER_PW"
echo "AGENCY_VIEWER_PASSWORD=$VIEWER_PW"
} >"$OPERATOR"
chmod 600 "$OPERATOR"
echo "init-env: wrote $ENVFILE, $PROVIDERFILE, $ALLOWLIST, and $OPERATOR (0600). Set AGENCY_MODEL in .env and OPENCODE_API_KEY in .env.provider."
+29 -1
View File
@@ -84,6 +84,7 @@ async function fixtureRoot(
allowlistBase: options.allowlistBase ?? "# base\nprobe.example.com\n",
squidTemplate: "port @@PORT@@ list @@ALLOWLIST_PATH@@\n",
expertsDir,
caddyDigest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
};
return { root, inputs };
}
@@ -105,7 +106,34 @@ describe("render", () => {
const doc = parseYaml(compose.content.replace(/^#.*\n/, "")) as {
services: Record<string, unknown>;
};
expect(Object.keys(doc.services).sort()).toEqual(["egress", "reader", "verifier", "writer"]);
expect(Object.keys(doc.services).sort()).toEqual([
"dispatcher",
"egress",
"gateway",
"reader",
"verifier",
"writer",
]);
});
test("the gateway routes the dispatcher and every expert without buffering", async () => {
const { root, inputs } = await fixtureRoot();
const files = await renderAll(root, inputs);
const caddy = files.find((f) => f.path.endsWith("gateway/Caddyfile"))!;
expect(caddy.content).toContain("http://agency.localhost");
expect(caddy.content).toContain("reverse_proxy dispatcher:8080");
expect(caddy.content).toContain("http://writer.agency.localhost");
expect(caddy.content).toContain("reverse_proxy writer:4096");
expect(caddy.content).toContain("flush_interval -1");
expect(caddy.content).not.toContain("sleeper.agency.localhost");
const compose = files.find((f) => f.path.endsWith("docker-compose.yml"))!;
const doc = parseYaml(compose.content.replace(/^#.*\n/, "")) as {
services: Record<string, { image?: string; ports?: string[] }>;
};
expect(doc.services["gateway"]?.image).toContain("caddy@sha256:");
expect(doc.services["gateway"]?.ports).toEqual(["127.0.0.1:${AGENCY_HTTP_PORT}:8080"]);
expect(doc.services["dispatcher"]?.ports).toBeUndefined();
expect(doc.services["writer"]?.ports).toBeUndefined();
});
test("refuses an expert naming an MCP key missing from the registry", async () => {
+128 -3
View File
@@ -144,6 +144,7 @@ export interface RenderInputs {
allowlistBase: string;
squidTemplate: string;
expertsDir: string;
caddyDigest: string;
}
export async function loadInputs(root: string): Promise<RenderInputs> {
@@ -156,7 +157,20 @@ export async function loadInputs(root: string): Promise<RenderInputs> {
const protocol = await Bun.file(`${root}/shared/FLEET_PROTOCOL.md`).text();
const allowlistBase = await Bun.file(`${root}/shared/templates/allowlist-base.txt`).text();
const squidTemplate = await Bun.file(`${root}/shared/templates/squid.conf.tmpl`).text();
return { fleet, registry, protocol, allowlistBase, squidTemplate, expertsDir: `${root}/experts` };
const versions = await Bun.file(`${root}/versions.env`).text();
const digest = /^CADDY_DIGEST=(.+)$/m.exec(versions)?.[1]?.trim();
if (digest === undefined || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
throw new RenderError("refusing to render: versions.env holds no CADDY_DIGEST pin");
}
return {
fleet,
registry,
protocol,
allowlistBase,
squidTemplate,
expertsDir: `${root}/experts`,
caddyDigest: digest,
};
}
function parseAllowlistBase(text: string): string[] {
@@ -286,7 +300,11 @@ export async function renderAll(root: string, inputs: RenderInputs): Promise<Gen
});
}
files.push({ path: `${root}/docker-compose.yml`, content: renderCompose(fleet) });
files.push({ path: `${root}/docker-compose.yml`, content: renderCompose(fleet, inputs) });
files.push({
path: `${root}/gateway/Caddyfile`,
content: renderCaddyfile(fleet),
});
files.push({
path: `${root}/egress/allowlist.txt`,
content:
@@ -302,7 +320,8 @@ export async function renderAll(root: string, inputs: RenderInputs): Promise<Gen
return files;
}
function renderCompose(fleet: FleetConfig): string {
function renderCompose(fleet: FleetConfig, inputs: RenderInputs): string {
const { caddyDigest } = inputs;
const enabled = enabledExperts(fleet);
const upper = (name: string): string => name.toUpperCase().replace(/-/g, "_");
const services: Record<string, unknown> = {};
@@ -450,6 +469,79 @@ function renderCompose(fleet: FleetConfig): string {
],
};
const dispatcherEnv: Record<string, string> = {
AGENCY_MODEL: "${AGENCY_MODEL}",
FLEET_MCP_TOKEN: "${FLEET_MCP_TOKEN}",
VERIFIER_TOKEN: "${VERIFIER_TOKEN}",
DISPATCHER_STATE_DIR: "/data",
DISPATCHER_REGISTRY_DIR: "/data/registry",
DISPATCHER_WORKSPACE: "/work",
DISPATCHER_ARTIFACTS: "/artifacts",
DISPATCHER_ALLOWLIST: "/data/registry/allowlist.yaml",
};
for (const name of enabled) dispatcherEnv[`PW_${upper(name)}`] = `\${PW_${upper(name)}}`;
const healthyDeps: Record<string, { condition: string }> = {};
for (const name of [...enabled, "verifier"]) healthyDeps[name] = { condition: "service_healthy" };
services["dispatcher"] = {
container_name: "agency-dispatcher",
build: {
context: ".",
dockerfile: "dispatcher/Dockerfile",
args: {
OPENCODE_VERSION: "${OPENCODE_VERSION}",
AGENT_UID: "${AGENT_UID}",
AGENT_GID: "${AGENT_GID}",
},
},
image: "agency-dispatcher:1.0.0",
restart: "unless-stopped",
working_dir: "/app",
networks: ["fleet"],
cap_drop: ["ALL"],
security_opt: ["no-new-privileges:true"],
pids_limit: 512,
cpus: 2,
mem_limit: "4g",
depends_on: healthyDeps,
healthcheck: {
test: ["CMD-SHELL", "curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null"],
interval: "15s",
timeout: "10s",
retries: 5,
start_period: "30s",
},
environment: dispatcherEnv,
volumes: [
"${AGENCY_WORKSPACE:-./workspace}:/work:rw,z",
"artifacts:/artifacts",
"./state:/data:rw,z",
"dispatcher-cache:/home/agent/.cache",
],
};
volumes["dispatcher-cache"] = {};
services["gateway"] = {
container_name: "agency-gateway",
image: `caddy@${caddyDigest}`,
restart: "unless-stopped",
networks: ["fleet"],
cap_drop: ["ALL"],
security_opt: ["no-new-privileges:true"],
pids_limit: 256,
cpus: 1,
mem_limit: "512m",
depends_on: { dispatcher: { condition: "service_healthy" } },
healthcheck: {
test: ["CMD-SHELL", "curl -fsS --noproxy '*' http://127.0.0.1:8080/healthz >/dev/null"],
interval: "15s",
timeout: "5s",
retries: 10,
start_period: "20s",
},
ports: ["127.0.0.1:${AGENCY_HTTP_PORT}:8080"],
volumes: ["./gateway/Caddyfile:/etc/caddy/Caddyfile:ro,z", "gateway-data:/data"],
};
volumes["gateway-data"] = {};
const doc = {
services,
networks: { fleet: { internal: true }, outside: { driver: "bridge" } },
@@ -461,6 +553,39 @@ function renderCompose(fleet: FleetConfig): string {
);
}
/**
* Gateway routing (Stage 1d, Step 4): agency.localhost reaches the
* Dispatcher; each <name>.agency.localhost reaches that expert's
* OpenCode server as the debug fallback. Buffering is off
* (flush_interval -1) so SSE streams pass through unbuffered.
*/
function renderCaddyfile(fleet: FleetConfig): string {
const lines = [
"# GENERATED, do not edit. Rendered by scripts/render.ts from fleet.yaml.",
"{",
"\tadmin off",
"\thttp_port 8080",
"}",
"",
"http://agency.localhost {",
"\treverse_proxy dispatcher:8080 {",
"\t\tflush_interval -1",
"\t}",
"}",
];
for (const name of enabledExperts(fleet)) {
lines.push(
"",
`http://${name}.agency.localhost {`,
`\treverse_proxy ${name}:4096 {`,
"\t\tflush_interval -1",
"\t}",
"}",
);
}
return `${lines.join("\n")}\n`;
}
async function main(): Promise<void> {
const args = Bun.argv.slice(2);
const check = args.includes("--check");
+4
View File
@@ -108,3 +108,7 @@ APT_LIBXML2=2.9.14+dfsg-1.3ubuntu3.10
MCP_PYPI_VERSION=1.30.0
# Stage 1c: BUN_LINUX_X64_SHA256 above is the bun-linux-x64.zip release
# asset; the verifier and dispatcher images check it at build time.
# Stage 1d gateway pin (verified with `docker pull caddy:2.10-alpine` on
# 2026-10-14; the digest below is its RepoDigest).
CADDY_VERSION=2.10-alpine
CADDY_DIGEST=sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d