fix(showcase/harness): split --isolate compose into infra-up + target-build (A21b)

A21 (a9114a831) regression: positional slug after 'up' restricted which services start (only target+depends_on came up; infra profile services never started). With concurrent sibling stack on same host ports, health checks crossed to foreign pocketbase → cell results misrouted → 0.0s red. Fix: split into 2 compose calls — (1) compose infra up -d, then (2) compose --profile slug up -d --build slug. Preserves A21's target-only rebuild + restores full infra stack.

(cherry picked from commit d71ff46edb4a7b0a89f89d8126fd5fe3d5c7d24e)
This commit is contained in:
Jordan Ritter
2026-06-16 21:27:31 -07:00
parent a9114a8317
commit d178e67300
3 changed files with 135 additions and 59 deletions
+34 -9
View File
@@ -96,18 +96,43 @@ cmd_up() {
fi
info "Launching fleet topology: control-plane + ${pool_count} worker(s) + PocketBase + demos + aimock (HARNESS_POOL_COUNT=${pool_count})"
# Scope `--build` to the targeted slug(s) when provided. Without a positional
# service filter, `up -d --build` rebuilds EVERY service in every active
# profile (infra + slug profiles), which serializes BuildKit and stalls
# concurrent `--isolate` runs (issue #5495, A21). When no slugs are given
# (infra-only bring-up), the blanket `--build` is preserved so first-time
# bootstrap still builds whatever infra services are missing.
# Two-call strategy to preserve A21's BuildKit-contention fix (target-only
# rebuild) WITHOUT regressing infra startup that A21 inadvertently dropped
# (A21b, issue #5495).
#
# docker compose semantics: positional service names after `up` restrict
# WHICH services start to the named ones + their `depends_on` chain — not
# just which ones get `--build`-rebuilt. So a single
# `up -d --build <slug>` only brings up the slug + its depends_on (aimock),
# leaving the rest of the infra profile (pocketbase/dashboard/harness/
# harness-pool-worker) down. Under `--isolate` with a sibling stack on the
# same host ports, health checks then cross onto foreign containers and
# cells silently misroute → 0.0s red.
#
# Fix: when slugs are present, emit TWO compose calls:
# 1. <profiles> up -d (no --build, no positional services) — start all
# services in the active profiles using cached images.
# 2. <profiles> up -d --build <slug...> — force-rebuild ONLY the named
# services and ensure they're up. Other services from call (1) are
# no-ops.
# When no slugs are present (infra-only bring-up), keep the single blanket
# `--build` call so first-time bootstrap still builds missing images.
#
# bash 3.2 (macOS default) errors on `${arr[@]}` for an EMPTY array under
# `set -u`; the `${arr[@]+...}` guard expands to nothing when unset/empty.
$compose_cmd --profile infra ${profile_args[@]+"${profile_args[@]}"} up -d --build \
--scale harness-pool-worker="$pool_count" ${passthrough_args[@]+"${passthrough_args[@]}"} \
${build_targets[@]+"${build_targets[@]}"}
if [[ ${#build_targets[@]} -gt 0 ]]; then
# Call 1: bring up ALL services in active profiles, no build.
$compose_cmd --profile infra ${profile_args[@]+"${profile_args[@]}"} up -d \
--scale harness-pool-worker="$pool_count" ${passthrough_args[@]+"${passthrough_args[@]}"}
# Call 2: rebuild ONLY the targeted slug(s) and ensure they're up.
$compose_cmd --profile infra ${profile_args[@]+"${profile_args[@]}"} up -d --build \
--scale harness-pool-worker="$pool_count" ${passthrough_args[@]+"${passthrough_args[@]}"} \
"${build_targets[@]}"
else
# Infra-only: single call with blanket --build for first-time bootstrap.
$compose_cmd --profile infra up -d --build \
--scale harness-pool-worker="$pool_count" ${passthrough_args[@]+"${passthrough_args[@]}"}
fi
}
cmd_down() {
+62 -28
View File
@@ -101,7 +101,7 @@ describe("rebuild() — targeted slugs", () => {
});
});
describe("up() — scoped --build (A21)", () => {
describe("up() — two-call infra-start + scoped --build (A21b)", () => {
// Stub healthCheck transitively by making fetch unreachable but compose succeed.
// up() throws on unhealthy services, so we mock the health-check path via
// network fetch failing — but the IMPORTANT assertion is the compose argv
@@ -118,13 +118,20 @@ describe("up() — scoped --build (A21)", () => {
});
/**
* The compose argv shape we want:
* [--profile infra --profile <slug>... up -d --build <slug>...]
* The two-call shape we want when slugs are provided:
* Call 1: [--profile infra --profile <slug>... up -d]
* (no --build, no positional services — brings up ALL services in
* active profiles using cached images)
* Call 2: [--profile infra --profile <slug>... up -d --build <slug>...]
* (rebuilds ONLY the named services; others are no-ops)
*
* Without slug positionals AFTER `--build`, compose rebuilds EVERY service
* in every active profile (infra + slug profiles). With slug positionals,
* compose rebuilds ONLY the named services and uses cached images for
* everything else — that is the A21 fix.
* Background: docker compose positional service names after `up` restrict
* WHICH services start (not just which get rebuilt). A21's single-call
* `up -d --build <slug>` therefore prevented infra services without an
* explicit `depends_on` from the slug from starting at all — health checks
* crossed onto sibling stack containers and cells silently misrouted (0.0s
* red). A21b splits into two calls to keep target-only rebuild while
* restoring the full infra startup.
*
* `--profile <name>` arguments do NOT scope the build target on their own;
* docker compose only treats positional args after `up` as the service
@@ -151,39 +158,66 @@ describe("up() — scoped --build (A21)", () => {
return out;
}
it("scopes --build to the targeted slug (does NOT rebuild infra services)", async () => {
/** Pull the `--profile <name>` set from a compose argv. */
function profilesIn(argv: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < argv.length - 1; i++) {
if (argv[i] === "--profile") out.push(argv[i + 1]);
}
return out.sort();
}
it("emits TWO compose calls when slugs provided: infra-up then target-build", async () => {
await up(["langgraph-python"]);
const upCall = composeCalls().find(
(argv) => argv.includes("up") && argv.includes("--build"),
);
expect(upCall).toBeDefined();
// The slug must appear as a positional AFTER `up` so compose treats it as
// the build target filter — not merely as a `--profile <slug>` argument
// (profiles don't scope --build).
expect(positionalsAfterUp(upCall!)).toEqual(["langgraph-python"]);
const upCalls = composeCalls().filter((argv) => argv.includes("up"));
expect(upCalls.length).toBe(2);
// Call 1: no --build, no positional services.
const [call1, call2] = upCalls;
expect(call1).not.toContain("--build");
expect(positionalsAfterUp(call1)).toEqual([]);
// Call 2: --build present, slug as positional after `up`.
expect(call2).toContain("--build");
expect(positionalsAfterUp(call2)).toEqual(["langgraph-python"]);
});
it("with no slugs (infra-only), --build remains blanket (first-time bootstrap)", async () => {
it("with no slugs (infra-only), emits a SINGLE blanket --build call", async () => {
await up([]);
const upCall = composeCalls().find(
(argv) => argv.includes("up") && argv.includes("--build"),
);
expect(upCall).toBeDefined();
const upCalls = composeCalls().filter((argv) => argv.includes("up"));
expect(upCalls.length).toBe(1);
const [call] = upCalls;
expect(call).toContain("--build");
// No slug positional after `up`: compose builds whatever is missing in the
// infra profile (cached images skipped, fresh ones built). This preserves
// first-time bootstrap behaviour when no infra images exist yet.
expect(positionalsAfterUp(upCall!)).toEqual([]);
expect(positionalsAfterUp(call)).toEqual([]);
});
it("multi-slug: scopes --build to ALL targeted slugs", async () => {
it("multi-slug: scopes --build to ALL targeted slugs (call 2)", async () => {
await up(["a", "b"]);
const upCall = composeCalls().find(
(argv) => argv.includes("up") && argv.includes("--build"),
);
expect(upCall).toBeDefined();
expect(positionalsAfterUp(upCall!).sort()).toEqual(["a", "b"]);
const upCalls = composeCalls().filter((argv) => argv.includes("up"));
expect(upCalls.length).toBe(2);
const [, call2] = upCalls;
expect(call2).toContain("--build");
expect(positionalsAfterUp(call2).sort()).toEqual(["a", "b"]);
});
it("both calls use IDENTICAL profile flags (no profile drift)", async () => {
await up(["langgraph-python"]);
const upCalls = composeCalls().filter((argv) => argv.includes("up"));
expect(upCalls.length).toBe(2);
const [call1, call2] = upCalls;
// Same --profile set in both calls so services across calls agree on
// which profiles are "active" — drift here would mean call 1 brings up a
// service that call 2 doesn't know about (or vice versa).
expect(profilesIn(call1)).toEqual(profilesIn(call2));
// And the slug's profile is actually present.
expect(profilesIn(call1)).toContain("infra");
expect(profilesIn(call1)).toContain("langgraph-python");
});
});
+39 -22
View File
@@ -265,30 +265,47 @@ export async function up(
}
const verboseFlag = opts?.verbose ? ["--progress", "plain"] : [];
// Scope `--build` to the targeted slug(s) when provided. Without a
// positional service filter, `up -d --build` rebuilds EVERY service in
// every active profile (infra + slug profiles), which serializes BuildKit
// and stalls concurrent `--isolate` runs — slot N's full-stack rebuild
// contends with slot M's rebuild on the same daemon (issue #5495, A21).
// Two-call strategy to preserve A21's BuildKit-contention fix (target-only
// rebuild) WITHOUT regressing the infra startup that A21 inadvertently
// dropped (A21b, issue #5495).
//
// With positional slugs after `up`, compose only rebuilds the named
// services; everything else uses cached images and falls through to plain
// start. First-time bootstrap (no infra images) still works because
// compose builds missing images automatically — `--build` only FORCES a
// rebuild of services that already have an image. For a forced full
// rebuild, callers should use `rebuild()` (the `--rebuild` flag path).
const buildTargets = slugs.length > 0 ? slugs : [];
const args = [
...profileArgs,
"up",
"-d",
"--build",
...verboseFlag,
...buildTargets,
];
// docker compose semantics: positional service names after `up` restrict
// WHICH services start to the named ones + their `depends_on` chain — not
// just which ones get `--build`-rebuilt. A21 passed the target slug as a
// positional after `up -d --build`, which (correctly) scoped the rebuild
// to the slug but (incorrectly) prevented infra services without an
// explicit `depends_on` from the target (pocketbase, dashboard, harness,
// harness-pool-worker) from coming up. Under `--isolate` with a sibling
// stack holding the same host ports, health checks then crossed onto the
// foreign containers and the cell silently misrouted → 0.0s red.
//
// Fix: split into two calls when slugs is non-empty.
// 1. compose <profiles> up -d — start ALL services in the
// active profiles using cached images. No `--build`, no positional
// services. Brings up the full infra profile + the slug's profile.
// 2. compose <profiles> up -d --build <slug...>
// Force-rebuild ONLY the named services and ensure they're up. Other
// services already running from call (1) are no-ops.
//
// When slugs is empty (infra-only bring-up), keep the single blanket call
// so first-time bootstrap still builds whatever infra images are missing.
log.info("starting services", { slugs: slugs.length ? slugs : ["infra"] });
compose(...args);
if (slugs.length > 0) {
// Call 1: bring up all services (no build, cached images).
compose(...profileArgs, "up", "-d", ...verboseFlag);
// Call 2: rebuild target slug(s) and ensure they're up.
compose(
...profileArgs,
"up",
"-d",
"--build",
...verboseFlag,
...slugs,
);
} else {
// Infra-only: single call with blanket --build for first-time bootstrap.
compose(...profileArgs, "up", "-d", "--build", ...verboseFlag);
}
// Determine which services to health-check
const servicesToCheck =