# Agentic Loop Build Matrix

The agentic loop cycles through a **build matrix** — a set of build
configurations that each define a preset, a sanitizer, a build directory,
and an optional test command. This ensures the loop doesn't only test one
build type but regularly exercises ASAN, TSan, profile, and release builds
across both Windows (Stevedore) and Linux (Rancher Desktop).

## Configuration

The build matrix is defined in the project's `AgenticLoop.config.json` under
the `buildMatrix` key. Each platform (`windows`, `linux`) has an array of
entries:

```json
{
  "buildMatrix": {
    "windows": [
      {
        "name": "clangcl-debug",
        "sanitizer": "asan",
        "buildDir": "build-clangcl-debug",
        "buildType": "Debug",
        "testCommand": "ctest --test-dir build-clangcl-debug --output-on-failure -C Debug"
      },
      {
        "name": "clangcl-profile",
        "sanitizer": "none",
        "buildDir": "build-clangcl-profile",
        "buildType": "RelWithDebInfo",
        "testCommand": "ctest --test-dir build-clangcl-profile --output-on-failure -C RelWithDebInfo"
      },
      {
        "name": "clangcl-release",
        "sanitizer": "none",
        "buildDir": "build-clangcl-release",
        "buildType": "Release",
        "testCommand": null
      }
    ],
    "linux": [
      {
        "name": "linux-debug-asan-clang",
        "sanitizer": "asan",
        "buildDir": "build-asan-clang",
        "buildType": "Debug",
        "testCommand": "ctest --test-dir build-asan-clang --output-on-failure -C Debug"
      },
      {
        "name": "linux-debug-tsan-clang",
        "sanitizer": "tsan",
        "buildDir": "build-tsan-clang",
        "buildType": "Debug",
        "testCommand": "ctest --test-dir build-tsan-clang --output-on-failure -C Debug"
      },
      {
        "name": "linux-profile-clang",
        "sanitizer": "none",
        "buildDir": "build-profile-clang",
        "buildType": "RelWithDebInfo",
        "testCommand": "ctest --test-dir build-profile-clang --output-on-failure -C RelWithDebInfo"
      },
      {
        "name": "linux-release-clang",
        "sanitizer": "none",
        "buildDir": "build-release-clang",
        "buildType": "Release",
        "testCommand": null
      }
    ]
  }
}
```

### Entry Fields

| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Handed to the build script: `-Configurations <name>` on Windows, `--preset <name>` on Linux (see [Cross-Platform Support](#cross-platform-support)) |
| `sanitizer` | `string` | `asan`, `tsan`, or `none` — controls env vars set before tests |
| `buildDir` | `string` | Linux `--build-dir` (default `build`). The Windows build command does not carry it; a `ctest --test-dir` names it inside `testCommand` |
| `buildType` | `string` | CMake build type (`Debug`, `RelWithDebInfo`, `Release`). Recorded only: neither loop passes it on, so `ctest -C` goes inside `testCommand` |
| `testCommand` | `string\|null` | Shell command for tests. `null` falls back to `build.windowsTestCommand` / `build.linuxTestCommand`; tests are skipped only when that is empty too |

### Backward Compatibility

The module also accepts the legacy `buildConfigurations` format (arrays of
strings). Strings are normalized to entries with `sanitizer: "none"` and no
per-config test command. This means existing configs keep working without
changes.

## Sanitizer-Aware Test Execution

When a matrix entry has `sanitizer: "asan"` or `sanitizer: "tsan"`, the
module automatically sets the appropriate environment variables before
running the test command, then restores the original environment:

| Sanitizer | Env Var | Value |
|-----------|---------|-------|
| `asan` | `ASAN_OPTIONS` | `detect_leaks=1:halt_on_error=1:abort_on_error=1:allocator_may_return_null=1` |
| `tsan` | `TSAN_OPTIONS` | `halt_on_error=1:abort_on_error=1:second_deadlock_stack=1` |
| `none` | — | No env vars set |

This ensures sanitizer-instrumented tests actually catch memory errors and
data races, rather than silently passing because the sanitizer was not
configured to halt on error.

The variables are set in the loop's own process. A `testCommand` that runs its
tests inside a container sees them only if it forwards them (`-e ASAN_OPTIONS`).

### Windows ASAN Note

On Windows, the ASAN debug binary needs `clang_rt.asan_dynamic-x86_64.dll`
next to the executable. Staging it is the consumer's job, not the loop's, and
it must be Microsoft's runtime:
[`windows-clang-cl-sanitizers.md`](windows-clang-cl-sanitizers.md). The
`ASAN_OPTIONS` env var must use a **relative** `log_path` — an absolute
`C:\...` path breaks ASAN option parsing at the drive-letter colon. The
module's default `ASAN_OPTIONS` does not set `log_path`, avoiding this
issue.

## Full Matrix Sweep

In addition to cycling one config per build trigger, the loop supports a
**full matrix sweep** — a build that falls in every Nth iteration runs ALL
configs in sequence instead of just the next one. This ensures all configs are
exercised regularly, not just the one that happens to be next in the cycle.
Executor-only mode never sweeps: its iteration counter stays at 0.

Enable it in the config:

```json
{
  "intervals": {
    "fullMatrixEveryNIterations": 5
  }
}
```

- `0` = disabled (cycle one config per build trigger, the default)
- `5` = every 5th iteration, run all configs in sequence

## Build Matrix Cycling

Without a full sweep, the loop cycles through the matrix entries one at a
time. After every `buildEveryNTasks` completed tasks, the next entry in the
matrix is used:

| Build # | Windows | Linux |
|---------|---------|-------|
| 1 | `clangcl-debug` (ASAN) | `linux-debug-asan-clang` (ASAN) |
| 2 | `clangcl-profile` | `linux-debug-tsan-clang` (TSan) |
| 3 | `clangcl-release` | `linux-profile-clang` |
| 4 | `clangcl-debug` (cycles back) | `linux-release-clang` |
| 5 | `clangcl-profile` | `linux-debug-asan-clang` (cycles back) |

This ensures that over multiple build triggers, every config is exercised.

## Cross-Platform Support

The same config file works on both Windows and Linux. The PowerShell module
picks the `windows` or `linux` matrix from the host it runs on; the Bash loop
takes the platform as `run_agentic_loop`'s third argument (the template passes
`linux`). Each build is one call to a script the consumer owns:

- **Windows**: `pwsh -File <build.windowsScript> -Configurations <name> -SkipTests`,
  default script `scripts/windows/Build-Windows-Container.ps1` (a Stevedore
  container build over the hub's `WindowsContainerBuild.Reuse`). What `<name>`
  means is that script's business: a `Build-Windows.config.psd1` entry in
  BeschleunigerBallett, a `Build-Windows.ps1` preset alias in OmniAccelerANT.
- **Linux**: `bash <build.linuxScript> --preset <name> --build-dir <buildDir>`.
  The PowerShell module defaults the script to
  `scripts/linux/cmake-configure-build.sh`; the Bash loop has no default, so
  set the key. For a CMake project that script can be a thin wrapper over the
  hub's `linux/scripts/lib/cmake-build.sh`, whose `--build-dir` overrides the
  preset's `binaryDir` so each config gets its own build directory.

## Module API

### PowerShell (`WindowsAgenticLoop.Common.psm1`)

The PowerShell functions implementing this behaviour
(`Resolve-BuildMatrixEntry`, `Get-SanitizerEnvVars`,
`Invoke-SanitizerTestCommand`, and the high-level `Invoke-AgenticLoop`)
are documented in the module API reference,
[`windows-agentic-loop.md`](windows-agentic-loop.md).

### Bash (`agentic-loop.sh`)

| Function | Purpose |
|----------|---------|
| `get_sanitizer_env_vars <sanitizer>` | Echo env var assignment string for the sanitizer |
| `invoke_sanitizer_tests <cmd> <sanitizer>` | Set env vars, run tests, restore env |
| `resolve_build_matrix_entry <config_json> <index> <platform>` | Parse a matrix entry to globals `MATRIX_*` |
| `count_build_matrix <config_json> <platform>` | Count matrix entries for a platform |
| `get_matrix_entry_name <config_json> <index> <platform>` | Get entry name by index (backward-compatible) |
| `run_agentic_loop <config_json> <repo_root> <platform>` | Full loop with build matrix, sanitizer tests, quality gates |
| `_agentic_planner_phase <repo_root> <force_planner> <planner_ran_nameref>` | Phase 1 of an iteration: skip while actionable tasks pend, starvation guard, refactor-cycle prompt; reports whether it ran |

### The two bash files

`agentic-loop.sh` is the only file a consumer sources; it sources
`agentic-engines.sh` from its own directory on load. The split follows the one
seam the file had: **which agent to run and how to talk to it** versus **what to
run it on**.

| File | Owns |
|------|------|
| `lib/agentic-engines.sh` | `_AGENTIC_JQ_PRELUDE`, `load_engine_config`, the role-prompt composition (`resolve_role_prompt_file`, `write_opencode_agent_file`, `_agentic_repo_path`), `agent_timeout_for_role`, `agent_stream_passthrough`, `claude_stream_render`, `opencode_major_version`, `opencode_run_args`, `invoke_opencode`, `invoke_claude`, `usage_limit_wait_seconds`, `invoke_agent` |
| `lib/agentic-loop.sh` | `LOG_FILE` and `log`/`section`, `init_agentic_loop`/`complete_agentic_loop`, the BACKLOG helpers, the build/test/quality phases, `_agentic_planner_phase`, the matrix readers, the `_AL` loop state and `run_agentic_loop` |

The dependency points one way only. The engine half calls `log` and appends to
`LOG_FILE`, both defined by the loop half before it sources the engines — so
`agentic-engines.sh` is a half of one library, not a standalone one. It is
therefore skipped by `test-lib-modules.sh` for the same reason
`agentic-loop.sh` always was: neither is a `lib/` module that must reach
`01-core/logging.sh` and define `info`/`warn`/`err`. `test-lib-smoke.sh` still
covers both (parses, sources clean under `set -euo pipefail`, defines
functions), and `test-agentic-loop.sh` drives the seam from the loop side: it
sources `agentic-loop.sh` only, and its config-precedence and `invoke_agent`
cases land in the engine half through it.

Two engines are supported: `opencode` invokes
`opencode run --agent <role> --model <model> --standalone` (plus `--auto` for
the executor; opencode v2 only, v1 is refused:
[opencode v2](windows-agentic-loop.md#opencode-v2)); `claude` invokes
`claude -p --model <model>` with the role system prompt appended from the
configured prompt file.

### Role-prompt composition

`resolve_role_prompt_file` in `lib/agentic-engines.sh` is the Bash twin of the
PowerShell module's `New-AgenticComposedPrompt` / `Write-AgenticOpenCodeAgentFile`.
`load_engine_config` calls it for both roles on **every** engine, which closes two
gaps that shared one root cause — the composition only ever existed on the
`claude` command line:

1. The Bash half read `.engines.claude.<role>PromptFile` and nothing else, so a
   consumer that had migrated to the preferred **overlay** shape got no role
   prompt at all on Linux — and got it silently, because `invoke_claude` only
   warns about a prompt file it was given and could not read.
2. `opencode` takes no system-prompt file on its command line; it reads an
   agent's role prompt from `.opencode/agents/<role>.md` in the repo. Nothing
   wrote that file, so every `opencode` consumer hand-maintained its own copy of
   the shared role prompt, and one of them ended up holding two full copies that
   had drifted 271 lines apart (see `New-AgenticComposedPrompt` in
   `WindowsAgenticLoop.Common.psm1`).

Hence three branches, one invariant: `.opencode/agents/<role>.md` is written on
every one of them, so opencode is never left without a role prompt. The two
engines get the same text whenever a prompt is configured; with none, `claude`
gets no role prompt and opencode the shared one.

| Config shape | `claude` is given | `.opencode/agents/<role>.md` is given |
|--------------|-------------------|---------------------------------------|
| `.promptOverlays.<role>PromptOverlayFile` (preferred) | shared role prompt + overlay, composed into `$TMPDIR` | the same composed text |
| `.engines.claude.<role>PromptFile` (full override) | that file | that file, labelled "migrate it to an overlay" |
| neither configured | nothing | `shared/agentic-loop/system-prompts/<role>.md` |

The generated file carries a DO-NOT-EDIT header, is rewritten only when its
content changed, and is written under `DRY_RUN` too — it is a derived artefact,
and a dry run whose point is checking the prompt wiring has to produce it. If the
consumer's `.gitignore` does not exclude `.opencode/agents/`, the run warns:
a committed copy is how the prompts forked in the first place.

Prompt and overlay paths are stored repo-relative in the config file;
`_agentic_repo_path` anchors a relative one at `<repo_root>` before any of the
above runs, and leaves an absolute one alone.

### Environment overrides

All optional; each one beats the value in the config JSON.

| Variable | Effect |
|----------|--------|
| `AGENTIC_ENGINE` | `opencode` \| `claude` — overrides `.engine` |
| `AGENTIC_PLANNER_MODEL` | overrides the planner model id |
| `AGENTIC_EXECUTOR_MODEL` | overrides the executor model id |
| `DRY_RUN` | `true` = print actions without invoking anything |
| `SKIP_BUILD` / `SKIP_TESTS` / `SKIP_QUALITY` | skip that phase |
| `PLANNER_ONLY` / `EXECUTOR_ONLY` | single-phase mode |
| `MAX_ITERATIONS_OVERRIDE` | overrides `.intervals.maxIterations` |

Typical use from a project's `Run-AgenticLoop.sh` (the full copy-and-edit
wrapper is `shared/agentic-loop/templates/Run-AgenticLoop.sh`):

```bash
source "${REPO_ROOT}/third_party/ANTfrastructure/linux/scripts/lib/agentic-loop.sh"
init_agentic_loop "MyProject" "$REPO_ROOT"
run_agentic_loop "$CONFIG_PATH" "$REPO_ROOT" linux
```
