Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions .github/scripts/linux_ci_cargo_test_partition.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
TESTABLE_KINDS = frozenset({"lib", "bin", "test", "bench", "example"})
SKIPPED_ONLY_KINDS = frozenset({"custom-build"})
TARGET_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
NON_LIB_SHARDS = ("merge", "other")
MERGE_TEST_TARGETS = frozenset({"merge_cli", "pr_issue_cli"})


@dataclass(frozen=True)
Expand Down Expand Up @@ -117,7 +119,11 @@ def load_root_package(manifest_dir: Path) -> Mapping[str, object]:
raise SystemExit(f"no metadata package for {manifest_path}")


def partition_argv(targets: Sequence[TestableTarget], partition: str) -> list[str]:
def partition_argv(
targets: Sequence[TestableTarget], partition: str, shard: str | None = None
) -> list[str]:
if shard is not None and (partition != "non-lib" or shard not in NON_LIB_SHARDS):
raise ValueError(f"invalid {partition} shard: {shard}")
if partition == "lib":
lib_targets = [target for target in targets if target.kind == "lib"]
if len(lib_targets) != 1:
Expand All @@ -128,6 +134,9 @@ def partition_argv(targets: Sequence[TestableTarget], partition: str) -> list[st
for target in targets:
if target.kind == "lib":
continue
owner = "merge" if target.kind == "test" and target.name in MERGE_TEST_TARGETS else "other"
if shard is not None and owner != shard:
continue
argv.extend(target.argv_tokens())
return argv
raise ValueError(f"unknown partition: {partition}")
Expand Down Expand Up @@ -183,6 +192,12 @@ def verify_partition(targets: Sequence[TestableTarget]) -> None:
"partition argv does not cover metadata targets "
f"(missing={missing!r}, extra={extra!r})"
)
sharded_keys = [
key for shard in NON_LIB_SHARDS
for key in argv_to_keys(partition_argv(targets, "non-lib", shard))
]
if len(sharded_keys) != len(set(sharded_keys)) or set(sharded_keys) != non_lib_set:
raise ValueError("non-lib shards do not form a disjoint complete partition")


def emit_argv(argv: Sequence[str]) -> None:
Expand All @@ -203,6 +218,10 @@ def build_parser() -> argparse.ArgumentParser:
choices=("lib", "non-lib"),
help="required for argv",
)
parser.add_argument(
"--shard", choices=NON_LIB_SHARDS,
help="optional non-lib target shard; omission retains the complete non-lib partition",
)
parser.add_argument(
"--manifest-dir",
type=Path,
Expand All @@ -225,7 +244,7 @@ def main(argv: Sequence[str] | None = None) -> int:

if args.partition is None:
parser.error("argv requires a partition")
partition_argv_tokens = partition_argv(targets, args.partition)
partition_argv_tokens = partition_argv(targets, args.partition, args.shard)
if args.partition == "non-lib" and not partition_argv_tokens:
sys.stderr.write("non-lib partition is empty\n")
return 1
Expand Down
26 changes: 25 additions & 1 deletion .github/scripts/linux_ci_library_shards.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
from pathlib import Path
from typing import Sequence

SHARDS = ("autopilot", "supervise-core", "supervise-fixtures", "rest")
SHARDS = (
"autopilot", "supervise-core", "supervise-bound", "supervise-decomposition", "supervise-parent",
"supervise-nested", "supervise-fixtures", "external-agent", "worktree", "rest",
)
REST_SAFETY_PREFLIGHTS = (
"process_runner::tests::gemini_online_profile_projects_only_the_trusted_resolver_file",
"runtime_adapter::cursor_managed_parent::tests::offline_check_failure_diagnostic_keeps_untrusted_output_private",
Expand Down Expand Up @@ -95,6 +98,10 @@ def partition_inventory(names: Sequence[str]) -> dict[str, list[str]]:
if not names or len(names) != len(set(names)):
raise ValueError("library inventory must be nonempty and unique")
partitions: dict[str, list[str]] = {shard: [] for shard in SHARDS}
standalone = {
*SAFETY_PREFLIGHTS["supervise-core"],
*SUPERVISE_DIAGNOSTICS, *SUPERVISE_CAUSAL_DIAGNOSTICS,
}
for name in sorted(names):
# Match the root module, not an arbitrary substring in a test name.
if name.startswith("autopilot::"):
Expand All @@ -103,6 +110,23 @@ def partition_inventory(names: Sequence[str]) -> dict[str, list[str]]:
owner = "supervise-fixtures"
elif name.startswith("supervise::"):
owner = "supervise-core"
# Keep the existing standalone preflights/diagnostics with their
# original owners; split the remaining slow assignment bodies.
if name not in standalone:
if name.startswith("supervise::assignment_execution::bound_parent_turn"):
owner = "supervise-bound"
elif name.startswith("supervise::assignment_execution::decomposition_tests::"):
owner = "supervise-decomposition"
elif name.startswith("supervise::assignment_execution::managed_parent_controller_tests::"):
owner = "supervise-parent"
elif name.startswith("supervise::assignment_execution::") and not name.startswith(
"supervise::assignment_execution::attached_parent_capture_tests::"
):
owner = "supervise-nested"
elif name.startswith("external_agent::"):
owner = "external-agent"
elif name.startswith("worktree::"):
owner = "worktree"
else:
owner = "rest"
partitions[owner].append(name)
Expand Down
12 changes: 9 additions & 3 deletions .github/scripts/run-linux-ci-cargo-test-partition.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
set -euo pipefail

if [[ "$#" -lt 1 || "$#" -gt 2 ]]; then
printf '%s\n' "usage: $0 lib <autopilot|supervise-core|supervise-fixtures|rest> | non-lib" >&2
printf '%s\n' "usage: $0 lib <shard> | non-lib [merge|other]" >&2
exit 1
fi

partition="$1"
case "${partition}:${2:-}" in
lib:autopilot | lib:supervise-core | lib:supervise-fixtures | lib:rest | non-lib:) ;;
lib:autopilot | lib:supervise-core | lib:supervise-bound | lib:supervise-decomposition | lib:supervise-parent | \
lib:supervise-nested | lib:supervise-fixtures | lib:external-agent | lib:worktree | lib:rest | \
non-lib: | non-lib:merge | non-lib:other) ;;
*)
printf '%s\n' "invalid partition: ${partition}" >&2
exit 1
Expand All @@ -25,8 +27,12 @@ python3 "${partition_py}" verify --manifest-dir "${repo_root}"

partition_argv_output=""
argv_status=0
shard_args=()
if [[ "${partition}" == non-lib && -n "${2:-}" ]]; then
shard_args=(--shard "$2")
fi
partition_argv_output="$(
python3 "${partition_py}" argv "${partition}" --manifest-dir "${repo_root}"
python3 "${partition_py}" argv "${partition}" "${shard_args[@]}" --manifest-dir "${repo_root}"
)" || argv_status=$?
if [[ "${argv_status}" -ne 0 ]]; then
exit "${argv_status}"
Expand Down
40 changes: 37 additions & 3 deletions .github/scripts/test_linux_ci_library_shards.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ class LinuxCiLibraryShardTests(unittest.TestCase):
def setUpClass(cls) -> None:
cls.shards = load_library_shards()

def test_four_shard_names(self) -> None:
def test_shard_names(self) -> None:
self.assertEqual(
self.shards.SHARDS,
("autopilot", "supervise-core", "supervise-fixtures", "rest"),
("autopilot", "supervise-core", "supervise-bound", "supervise-decomposition", "supervise-parent",
"supervise-nested", "supervise-fixtures", "external-agent", "worktree", "rest"),
)

def test_mixed_inventory_disjoint_complete_union(self) -> None:
Expand All @@ -38,14 +39,47 @@ def test_mixed_inventory_disjoint_complete_union(self) -> None:
parts = self.shards.partition_inventory(inventory)
self.assertEqual(parts["autopilot"], ["autopilot::alpha"])
self.assertEqual(
parts["supervise-core"], ["supervise::assignment_execution::beta"]
parts["supervise-nested"], ["supervise::assignment_execution::beta"]
)
self.assertEqual(parts["supervise-fixtures"], ["supervise::tests::gamma"])
self.assertEqual(parts["rest"], ["runtime_adapter::delta"])
flattened = [name for group in parts.values() for name in group]
self.assertEqual(sorted(flattened), sorted(inventory))
self.assertEqual(len(flattened), len(set(flattened)))

def test_new_shards_refine_the_old_partition_without_loss_or_overlap(self) -> None:
inventory = [
*self.shards.SUPERVISE_DIAGNOSTICS,
*self.shards.SUPERVISE_CAUSAL_DIAGNOSTICS,
*self.shards.SAFETY_PREFLIGHTS["supervise-core"],
*self.shards.REST_SAFETY_PREFLIGHTS,
"autopilot::new", "supervise::tests::new", "supervise::future::new",
"supervise::assignment_execution::decomposition_tests::new",
"supervise::assignment_execution::managed_parent_controller_tests::new",
"supervise::assignment_execution::nested_worker::new",
"supervise::assignment_execution::attached_parent_capture_tests::new",
"supervise::assignment_execution::bound_parent_turn_tests::new",
"external_agent::new", "worktree::new", "future_module::new",
"future::external_agent::lookalike", "worktree_extra::lookalike",
]
inventory = sorted(set(inventory))
parts = self.shards.partition_inventory(inventory)
self.assertEqual(parts, self.shards.partition_inventory(list(reversed(inventory))))
old_core = sorted(n for n in inventory if n.startswith("supervise::")
and not n.startswith("supervise::tests::"))
old_rest = sorted(n for n in inventory if not n.startswith(("autopilot::", "supervise::")))
self.assertEqual(sorted(n for key in ("supervise-core", "supervise-bound", "supervise-decomposition",
"supervise-parent", "supervise-nested")
for n in parts[key]), old_core)
self.assertEqual(sorted(n for key in ("external-agent", "worktree", "rest")
for n in parts[key]), old_rest)
flattened = [n for group in parts.values() for n in group]
self.assertEqual(sorted(flattened), inventory)
self.assertEqual(len(flattened), len(set(flattened)))
for shard, names in self.shards.SAFETY_PREFLIGHTS.items():
self.assertTrue(set(names) <= set(parts[shard]))
self.shards.validate_mandatory_supervise_ownership(inventory, parts)

def test_lookalike_substrings_go_to_rest(self) -> None:
inventory = [
"host::autopilot::lookalike",
Expand Down
42 changes: 33 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,12 @@ jobs:
linux-library:
name: Linux library tests (${{ matrix.shard }})
needs: nix-package
# Ubuntu 26.04 shards exceeded the 45-minute job timeout mid-test; keep
# 24.04 (last within budget) until 26.04 test runtime fits the timeout.
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [autopilot, supervise-core, supervise-fixtures, rest]
shard: [autopilot, supervise-core, supervise-bound, supervise-decomposition, supervise-parent, supervise-nested, supervise-fixtures, external-agent, worktree, rest]
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -258,6 +256,18 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev
# Package installation can replace systemd and its sandbox executor.
# Re-exec the existing user manager before starting confined tests.
if systemctl is-active --quiet "user@$(id -u).service"; then
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
systemctl --version
echo 'systemd user manager before re-exec:'
systemctl --user show --property=Version
systemctl --user daemon-reexec
echo 'systemd user manager after re-exec:'
systemctl --user show --property=Version
fi

- name: Verify delegated Linux process startup
run: bash .github/scripts/run-linux-all-tests-in-delegated-user-scope.sh /usr/bin/true
Expand Down Expand Up @@ -319,12 +329,14 @@ jobs:
run: bash .github/scripts/run-linux-ci-cargo-test-partition.sh lib "${{ matrix.shard }}"

linux-integration:
name: Linux integration tests
name: Linux integration tests (${{ matrix.shard }})
needs: nix-package
# Ubuntu 26.04 hits the candidate-validation readiness timeout; keep the
# last verified image until its sandbox startup behavior is qualified.
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [merge, other]
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -505,6 +517,18 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev
# Package installation can replace systemd and its sandbox executor.
# Re-exec the existing user manager before starting confined tests.
if systemctl is-active --quiet "user@$(id -u).service"; then
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"
systemctl --version
echo 'systemd user manager before re-exec:'
systemctl --user show --property=Version
systemctl --user daemon-reexec
echo 'systemd user manager after re-exec:'
systemctl --user show --property=Version
fi

- name: Verify delegated Linux process startup
run: bash .github/scripts/run-linux-all-tests-in-delegated-user-scope.sh /usr/bin/true
Expand All @@ -520,7 +544,7 @@ jobs:
run: bash .github/scripts/install-github-hosted-ci-codex.sh

- name: Run integration and binary tests
run: bash .github/scripts/run-linux-ci-cargo-test-partition.sh non-lib
run: bash .github/scripts/run-linux-ci-cargo-test-partition.sh non-lib "${{ matrix.shard }}"

linux-gate:
name: Linux fmt, check, clippy, and test
Expand Down
36 changes: 36 additions & 0 deletions scripts/tests/test_linux_ci_cargo_test_partition.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,42 @@ def test_custom_build_target_is_excluded(self) -> None:
targets = partition.collect_testable_targets(package)
self.assertEqual([target.kind for target in targets], ["lib"])

def test_non_lib_shards_cover_original_targets_once_including_future_targets(self) -> None:
package = sample_package()
package["targets"].extend([
{"kind": ["test"], "name": "pr_issue_cli"},
{"kind": ["test"], "name": "future_integration"},
{"kind": ["example"], "name": "merge_cli"},
])
targets = partition.collect_testable_targets(package)
partition.verify_partition(targets)
old = partition.argv_to_keys(partition.partition_argv(targets, "non-lib"))
groups = {
shard: partition.argv_to_keys(partition.partition_argv(targets, "non-lib", shard))
for shard in partition.NON_LIB_SHARDS
}
self.assertEqual(groups["merge"], [("test", "merge_cli"), ("test", "pr_issue_cli")])
self.assertIn(("example", "merge_cli"), groups["other"])
self.assertIn(("test", "future_integration"), groups["other"])
flattened = [key for group in groups.values() for key in group]
self.assertCountEqual(flattened, old)
self.assertEqual(len(flattened), len(set(flattened)))

def test_invalid_non_lib_shard_refused(self) -> None:
targets = partition.collect_testable_targets(sample_package())
for group, shard in (("non-lib", "unknown"), ("lib", "merge")):
with self.subTest(group=group, shard=shard), self.assertRaises(ValueError):
partition.partition_argv(targets, group, shard)

def test_integration_matrix_requires_every_target_shard(self) -> None:
source = WORKFLOW.read_text(encoding="utf-8")
job = source.split(" linux-integration:", 1)[1].split(" linux-gate:", 1)[0]
self.assertIn("shard: [merge, other]", job)
self.assertIn("fail-fast: false", job)
self.assertIn("timeout-minutes: 45", job)
self.assertNotIn("continue-on-error", job)
self.assertIn('non-lib "${{ matrix.shard }}"', job)

def test_unsupported_target_kind_fails_closed(self) -> None:
package = {
"targets": [
Expand Down
Loading
Loading