Skip to content
Open
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
17 changes: 17 additions & 0 deletions .fdignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Paths hidden from `fd`, and therefore from the estate-wide `mdformat-all`
# formatter, which discovers Markdown with `fd` and pipes it through
# `mdtablefix --wrap`.
#
# `mdtablefix` has no exclusion flag, so a file it must not touch has to be
# withheld at discovery. `markdownlint-cli2` already skips the paths below via
# `.markdownlint-cli2.jsonc`; this file closes the other half of `make fmt`.
#
# This is deliberately `.fdignore` rather than `.ignore`: ripgrep does not read
# it, so these paths stay searchable.

# Expected-output fixtures for the html_to_markdown converter tests. Their
# content is a byte-for-byte record of converter output, which does not wrap
# prose. Reflowing them to 80 columns makes
# `convert_test_pages_to_markdown` fail against a fixture that no longer
# describes any real conversion.
tests/test-pages/
2 changes: 1 addition & 1 deletion .github/workflows/audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ permissions:

jobs:
audit:
runs-on: ubicloud-standard-8
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/claude-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
review:
name: Claude Code Review
if: contains(github.event.pull_request.labels.*.name, 'staging-promotion')
runs-on: ubicloud-standard-8
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-label-classify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:

jobs:
classify:
runs-on: ubicloud-standard-8
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-label-scope.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:

jobs:
scope:
runs-on: ubicloud-standard-8
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v7
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/regression-test-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
jobs:
regression-test:
name: Regression test enforcement
runs-on: ubicloud-standard-8
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ test-matrix-cargo:
$(CARGO) test --features postgres,libsql-test-helpers,html-to-markdown -- --nocapture
$(CARGO) test --manifest-path $(GITHUB_TOOL_MANIFEST) -- --nocapture

# Validate the mutation-testing caller workflow contract.
# Validate the GitHub Actions workflow contracts: reusable-workflow callers,
# the isolated CodeScene coverage job, and the per-job runner policy.
test-workflow-contracts:
uv run --with 'pytest>=8' --with 'pyyaml>=6' pytest tests/workflow_contracts -q

Expand Down
77 changes: 77 additions & 0 deletions docs/adr-013-split-ci-runners-by-compile-cost.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ADR-013 — Split CI runners by compile cost

**Status:** Accepted **Date:** 2026-08-05 **Deciders:** `@leynos`

## Context

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare second-level headings across all ADRs.
fd -t f 'adr-[0-9]{3}-.*\.md' docs --exec sh -c 'echo "== $1"; rg -n "^## " "$1"' _ {}

Repository: leynos/axinite

Length of output: 4415


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- docs/adr-013-split-ci-runners-by-compile-cost.md ---'
sed -n '1,30p' docs/adr-013-split-ci-runners-by-compile-cost.md
printf '%s\n' '--- ADR filename and required-heading checks ---'
python3 - <<'PY'
from pathlib import Path
import re

for path in sorted(Path("docs").glob("adr-[0-9][0-9][0-9]-*.md")):
    text = path.read_text(encoding="utf-8")
    headings = re.findall(r"^## (.+)$", text, re.MULTILINE)
    required = {
        "Status": bool(re.search(r"^## Status$", text, re.MULTILINE)),
        "Date": bool(re.search(r"^## Date$", text, re.MULTILINE)),
        "Context and Problem Statement": bool(
            re.search(r"^## Context and Problem Statement$", text, re.MULTILINE)
        ),
        "Context and problem statement": bool(
            re.search(r"^## Context and problem statement$", text, re.MULTILINE)
        ),
    }
    print(f"{path}: {required}; first headings={headings[:4]}")
PY

Repository: leynos/axinite

Length of output: 4969


Add the required ADR sections.

Use ## Status, ## Date, and ## Context and Problem Statement. The ADR currently combines Status and Date in one metadata line and uses ## Context.

Triage: [type:docstyle]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/adr-013-split-ci-runners-by-compile-cost.md` at line 5, Update the ADR
headings and metadata by adding separate ## Status and ## Date sections, and
rename ## Context to ## Context and Problem Statement. Remove the combined
Status/Date metadata line while preserving its information under the
corresponding sections.

Source: Coding guidelines


Every Linux job in the repository ran on `ubicloud-standard-8`, an 8-vCPU paid
runner chosen because a full workspace build is the dominant CI cost. That
choice was applied uniformly rather than per job, so it also covered work that
never compiles anything: the two pull-request labelling workflows, the
regression-test check, the Claude Code review, and the weekly dependency
audit. Those jobs are single-threaded shell scripts, an action call, or an
API-bound agent run; the extra vCPUs sit idle.

A July 2026 Ubicloud usage audit attributed roughly 1,600 billed premium-8
minutes per month to those five workflows. Axinite is a public repository, so
GitHub-hosted `ubuntu-latest` runners execute the same work at no cost, and
the standard 2-vCPU hosted runner is not the bottleneck for any of them.

## Decision

Select the runner per job, from the job's compile cost:

- A job that compiles the workspace — `cargo build`, `cargo test`,
`cargo nextest`, `cargo clippy`, `cargo llvm-cov`, `cargo component`, or the
`make` targets that wrap them — runs on `ubicloud-standard-8`.
- A job that does not compile runs on GitHub-hosted `ubuntu-latest`.

The five non-compiling workflows moved accordingly. Windows jobs keep
`windows-latest` and the release workflow keeps its pinned `ubuntu-22.04`
images, both for reproducibility rather than cost.

The policy is enforced by `tests/workflow_contracts/runner_policy_test.py`,
which records the runner for every job in the repository. Adding a job, or
moving one between pools, fails until the recorded policy is updated
deliberately. The same suite asserts that no job on the free pool runs a
compile command or installs a Rust build cache.

## Rationale

Runner selection is a per-job property, not a per-repository one. Paying for
vCPUs a job cannot use is waste with no compensating benefit, and the
alternative — leaving everything on the paid pool because it is simpler —
costs roughly 1,600 billed minutes a month for jobs whose wall-clock time is
dominated by network round trips and process startup.

Encoding the rule as a contract test rather than a comment matters because the
failure mode is silent: a new job copied from an existing workflow inherits
whichever runner the template used, and nothing surfaces the mistake until the
next billing audit. A test that enumerates every job turns that into a
review-time question.

`release-plz.yml` is deliberately untouched. Its jobs are gated to the `nearai`
repository owner and never execute here, so moving them would change nothing
observable while diverging from upstream.

## Consequences

- Non-compile pull-request feedback moves to the free pool, so it competes for
GitHub's shared hosted-runner concurrency rather than Ubicloud's. These jobs
run on every pull-request event, so any queueing regression is visible
immediately.
- Adding a workflow or a job now requires an edit to `RUNNER_POLICY` in
`tests/workflow_contracts/runner_policy_test.py`. That is the intended
friction: the runner choice becomes an explicit review decision.
- A free-runner job that later grows a build step fails its contract test
rather than silently running a compile on a 2-vCPU machine.

## Alternatives considered

- **Leave everything on Ubicloud.** Simplest, and wrong: it keeps paying for
capacity that five workflows demonstrably cannot use.
- **Move every Linux job to `ubuntu-latest`.** Free, but the workspace build
is the reason the paid pool exists; hosted runners lack both the vCPUs and
the disk headroom the coverage and end-to-end jobs need.
- **Document the split without a test.** Rejected for the silent-inheritance
failure mode described above.
4 changes: 4 additions & 0 deletions docs/contents.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,7 @@
- [ADR 012: Monotonic clock seam for build duration measurement](adr-012-monotonic-clock-seam-for-build-duration.md)
records why `BuildSoftwareTool` uses an `Instant`-backed duration seam
instead of `mockable::Clock` for elapsed-time assertions.
- [ADR 013: Split CI runners by compile cost](adr-013-split-ci-runners-by-compile-cost.md)
records why compile-bound jobs stay on the paid Ubicloud pool while
non-compiling jobs run on GitHub-hosted runners, and how the split is
enforced.
30 changes: 30 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ that assert on printed startup or boot-screen content — for example, the
`print_startup_info_matches_snapshot` test in `src/startup/boot.rs`. The crate
is compiled only when running tests and has no effect on the production binary.

### Runner selection

Runners are chosen per job, from the job's compile cost:

- A job that compiles the workspace runs on `ubicloud-standard-8`. That covers
`test.yml`, `code_style.yml`, `coverage.yml`, `codescene-coverage.yml`,
`e2e.yml`, and `staging-ci.yml` — anything invoking `cargo build`,
`cargo test`, `cargo nextest`, `cargo clippy`, `cargo llvm-cov`,
`cargo component`, or the `make` targets that wrap them.
- A job that does not compile runs on GitHub-hosted `ubuntu-latest`, which is
free for this public repository. That covers `pr-label-classify.yml`,
`pr-label-scope.yml`, `regression-test-check.yml`, `claude-review.yml`, and
`audit.yml`. These are single-threaded shell scripts, action calls, or
API-bound agent runs; the scheduled audit installs `cargo-audit` as a
prebuilt binary and only reads the lockfile.

Windows jobs use `windows-latest` and `release.yml` pins `ubuntu-22.04`, both
for reproducibility rather than cost. `release-plz.yml` is left on Ubicloud
because its jobs are gated to the `nearai` repository owner and never execute
here.
Comment on lines +95 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the release.yml runner claim.

The text states that release.yml pins ubuntu-22.04. RUNNER_POLICY records build-local-artifacts as "${{ matrix.runner }}", so that job takes its runner from a matrix rather than a pin. The sentence contradicts the recorded policy it is meant to describe.

Also consider naming job scope for the audit. test.yml declares its own audit job that stays on Ubicloud, so a reader may infer that all audit work moved to the free pool.

♻️ Proposed fix
-Windows jobs use `windows-latest` and `release.yml` pins `ubuntu-22.04`, both
-for reproducibility rather than cost. `release-plz.yml` is left on Ubicloud
-because its jobs are gated to the `nearai` repository owner and never execute
-here.
+Windows jobs use `windows-latest`. `release.yml` pins `ubuntu-22.04` for every
+job except `build-local-artifacts`, which selects its runner from the release
+matrix. Both choices serve reproducibility rather than cost. `release-plz.yml`
+is left on Ubicloud because its jobs are gated to the `nearai` repository owner
+and never execute here.

Triage: [type:docstyle]

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Windows jobs use `windows-latest` and `release.yml` pins `ubuntu-22.04`, both
for reproducibility rather than cost. `release-plz.yml` is left on Ubicloud
because its jobs are gated to the `nearai` repository owner and never execute
here.
Windows jobs use `windows-latest`. `release.yml` pins `ubuntu-22.04` for every
job except `build-local-artifacts`, which selects its runner from the release
matrix. Both choices serve reproducibility rather than cost. `release-plz.yml`
is left on Ubicloud because its jobs are gated to the `nearai` repository owner
and never execute here.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/developers-guide.md` around lines 95 - 98, Update the runner-policy
explanation in the developer guide to remove the incorrect claim that
release.yml pins ubuntu-22.04, and accurately describe build-local-artifacts as
using its matrix-selected runner. Clarify the relevant job scope, distinguishing
release.yml’s audit behavior from test.yml’s separate audit job that remains on
Ubicloud.


`tests/workflow_contracts/runner_policy_test.py` records the runner for every
job in the repository and fails when the mapping drifts, so adding a job or
moving one between pools requires a deliberate edit to `RUNNER_POLICY`. The
same suite asserts that no job on the free pool runs a compile command or
installs a Rust build cache. Run it with `make test-workflow-contracts`.

The rationale is recorded in
[ADR 013](adr-013-split-ci-runners-by-compile-cost.md).

### Workflow pins and Dependabot

Dependabot owns the upgrade of GitHub Actions and reusable workflows, including
Expand Down
16 changes: 12 additions & 4 deletions src/agent/dispatcher/tests/auth.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Auth detection tests.

use anyhow::Context as _;

use super::super::{check_auth_required, parse_auth_result};
use super::*;

Expand All @@ -13,21 +15,25 @@ fn check_auth_json(tool_name: &str, json: serde_json::Value) -> Option<(String,

/// Assert that an auth-awaiting detection result is `Some`, and that the
/// returned name and instructions match the expected values.
///
/// Returns an error when detection did not fire, leaving the calling test to
/// decide how that failure surfaces.
fn assert_auth_detected(
detected: Option<(String, String)>,
expected_name: &str,
expected_instructions_fragment: &str,
) {
) -> anyhow::Result<()> {
assert!(detected.is_some(), "expected auth detection to fire");
let (name, instructions) =
detected.expect("expected auth detection to fire and return (name, instructions)");
detected.context("expected auth detection to fire and return (name, instructions)")?;
assert_eq!(name, expected_name);
assert!(
instructions.contains(expected_instructions_fragment),
"instructions did not contain {:?}: {:?}",
expected_instructions_fragment,
instructions,
);
Ok(())
Comment on lines +18 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return precondition failures through the helper result.

Remove the assertion that precedes error conversion in each helper. Each assertion panics before the later Context or map_err call can return the documented error.

  • src/agent/dispatcher/tests/auth.rs#L18-L36: remove the detected.is_some() assertion and let detected.context(...)? return the missing-detection error.
  • src/agent/dispatcher/tests/loop_guard.rs#L191-L215: remove the timeout assertion and let result.map_err(...)? return the timeout error.
📍 Affects 2 files
  • src/agent/dispatcher/tests/auth.rs#L18-L36 (this comment)
  • src/agent/dispatcher/tests/loop_guard.rs#L191-L215
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/dispatcher/tests/auth.rs` around lines 18 - 36, Remove the
detected.is_some() assertion in assert_auth_detected and let
detected.context(...)? return the documented missing-detection error. Also
update the timeout helper in src/agent/dispatcher/tests/loop_guard.rs at lines
191-215 by removing its timeout assertion and allowing result.map_err(...)? to
return the timeout error; both sites require direct changes.

}

#[test]
Expand All @@ -45,7 +51,8 @@ fn test_detect_auth_awaiting_positive() {
),
"telegram",
"Telegram Bot API",
);
)
.expect("expected auth detection to fire");
Comment on lines +54 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Propagate helper errors from all affected agent tests.

Replace the newly added .expect() calls with ?. Return anyhow::Result<()> from the affected test or scenario functions.

  • src/agent/dispatcher/tests/auth.rs#L54-L55: propagate assert_auth_detected.
  • src/agent/dispatcher/tests/image_sentinel.rs#L245-L247: propagate run_image_generate_and_count_statuses.
  • src/agent/dispatcher/tests/loop_guard.rs#L243-L244: propagate assert_agentic_loop_text_response.
  • src/agent/dispatcher/tests/skill_bundle_context_bdd.rs#L222-L223: propagate assert_rendered_snapshot; apply the same change at Lines 233-234.
  • src/agent/dispatcher/tests/skills.rs#L92-L93: propagate skill-construction failures; apply the same change to the other changed .expect() sites in this file.

As per path instructions, src/agent/**/*.rs requires: “Never call .unwrap() or .expect(); use ? with proper error mapping.”

📍 Affects 5 files
  • src/agent/dispatcher/tests/auth.rs#L54-L55 (this comment)
  • src/agent/dispatcher/tests/image_sentinel.rs#L245-L247
  • src/agent/dispatcher/tests/loop_guard.rs#L243-L244
  • src/agent/dispatcher/tests/skill_bundle_context_bdd.rs#L222-L223
  • src/agent/dispatcher/tests/skills.rs#L92-L93
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/dispatcher/tests/auth.rs` around lines 54 - 55, Replace the
affected helper-call .expect() sites with ? and update each enclosing test or
scenario function to return anyhow::Result<()>, preserving normal success
behavior. Apply this in src/agent/dispatcher/tests/auth.rs lines 54-55 for
assert_auth_detected; src/agent/dispatcher/tests/image_sentinel.rs lines 245-247
for run_image_generate_and_count_statuses;
src/agent/dispatcher/tests/loop_guard.rs lines 243-244 for
assert_agentic_loop_text_response; both assert_rendered_snapshot sites in
src/agent/dispatcher/tests/skill_bundle_context_bdd.rs lines 222-223 and
233-234; and every changed skill-construction .expect() site in
src/agent/dispatcher/tests/skills.rs, using proper error propagation throughout
src/agent/**/*.rs.

Source: Path instructions

}

#[test]
Expand Down Expand Up @@ -129,7 +136,8 @@ fn test_detect_auth_awaiting_tool_activate() {
),
"slack",
"Slack Bot",
);
)
.expect("expected auth detection to fire");
}

#[test]
Expand Down
20 changes: 16 additions & 4 deletions src/agent/dispatcher/tests/image_sentinel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,14 @@ fn sentinel_json(data: Option<&str>, path: Option<&str>) -> String {
serde_json::Value::Object(sentinel).to_string()
}

async fn run_image_generate_and_count_statuses(data_url: Option<&str>) -> (bool, usize) {
/// Run `image_generate` through the sentinel path and report whether a
/// sentinel was detected along with the number of statuses broadcast.
///
/// Returns an error if the captured-status lock is poisoned; a poisoned lock
/// means the stub channel panicked, so the recorded statuses cannot be trusted.
async fn run_image_generate_and_count_statuses(
data_url: Option<&str>,
) -> anyhow::Result<(bool, usize)> {
let (channels, statuses) = new_stubbed_channels("test-chan").await;
let agent = build_agent_with_stub_channel(channels);
let session = Arc::new(Mutex::new(Session::new("test-user")));
Expand All @@ -158,8 +165,11 @@ async fn run_image_generate_and_count_statuses(data_url: Option<&str>) -> (bool,
let result = delegate
.maybe_emit_image_sentinel("image_generate", &output)
.await;
let count = statuses.lock().expect("statuses lock poisoned").len();
(result, count)
let count = statuses
.lock()
.map_err(|_| anyhow::anyhow!("statuses lock poisoned"))?
.len();
Ok((result, count))
}

struct ImageSentinelHarness {
Expand Down Expand Up @@ -232,7 +242,9 @@ async fn delegate_skips_broadcast_for_invalid_data_urls(
#[case] data_url: Option<&str>,
#[case] expected_message: &str,
) {
let (result, count) = run_image_generate_and_count_statuses(data_url).await;
let (result, count) = run_image_generate_and_count_statuses(data_url)
.await
.expect("image_generate run should complete without a poisoned lock");

assert!(
result,
Expand Down
17 changes: 13 additions & 4 deletions src/agent/dispatcher/tests/loop_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,23 +188,31 @@ fn build_test_agent_config(max_tool_iterations: usize) -> AgentConfig {
}

/// Assert that the timeout-wrapped agentic loop result is a text response.
///
/// Returns an error if the loop timed out or the dispatcher returned `Err`,
/// leaving the calling test to decide how the failure surfaces.
fn assert_agentic_loop_text_response<E: std::fmt::Debug>(
result: Result<Result<super::super::AgenticLoopResult, E>, tokio::time::error::Elapsed>,
expected_text: &str,
) {
) -> anyhow::Result<()> {
assert!(
result.is_ok(),
"Dispatcher timed out -- max_iterations guard failed to terminate the loop"
);
let inner = result.expect("test timed out or dispatcher context lost");
match inner.expect("Expected Ok(AgenticLoopResult) but dispatcher returned Err") {
let inner =
result.map_err(|e| anyhow::anyhow!("test timed out or dispatcher context lost: {e}"))?;
let outcome = inner.map_err(|e| {
anyhow::anyhow!("Expected Ok(AgenticLoopResult) but dispatcher returned Err: {e:?}")
})?;
match outcome {
super::super::AgenticLoopResult::Response(text) => {
assert_eq!(text, expected_text);
}
super::super::AgenticLoopResult::NeedApproval { .. } => {
panic!("Expected text response, got NeedApproval");
}
}
Ok(())
}

/// Verify that the max_iterations guard terminates the loop even when the
Expand Down Expand Up @@ -232,5 +240,6 @@ async fn test_dispatcher_terminates_with_max_iterations() {
)
.await;

assert_agentic_loop_text_response(result, "forced text response");
assert_agentic_loop_text_response(result, "forced text response")
.expect("dispatcher should return a text response");
}
Loading
Loading