Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ jobs:
- name: Clippy
run: cargo clippy --workspace --all-targets --all-features --locked --no-deps -- -D warnings

- name: Clippy (controller, default features)
run: cargo clippy -p crucible-controller -p crux --all-targets --locked --no-deps -- -D warnings
- name: Clippy (default features)
run: cargo clippy -p crucible -p crucible-controller -p crux --all-targets --locked --no-deps -- -D warnings

# The controller's `#[sqlx::test]` suite needs a live Postgres at DATABASE_URL; each test
# creates (and drops) its own database on it. An ephemeral host port so concurrent jobs on
Expand Down Expand Up @@ -194,8 +194,8 @@ jobs:
- name: Test
run: cargo nextest run --workspace --all-features --locked

- name: Test (controller, default features)
run: cargo nextest run -p crucible-controller -p crux --locked
- name: Test (default features)
run: cargo nextest run -p crucible -p crucible-controller -p crux --locked

# Nextest deliberately excludes ignored tests; this one proves an idle controller tick
# opens no span while an ingest still traces.
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ jobs:
# the loop pod runs) + forge (engine-side build/deploy bins).
- name: Build release binaries
run: |
cargo build --release --locked -p crucible -p crucible-broker -p forge --bins
cargo build --release --locked -p crucible -p crucible-broker -p forge --bins \
--features crucible/autoresearch
mkdir -p prebuilt/bin
find target/release -maxdepth 1 -type f -executable -exec cp {} prebuilt/bin/ \;
echo "staged binaries:" && ls -la prebuilt/bin/
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jobs:
env:
CRUCIBLE_GIT_SHA: ${{ github.sha }}
SQLX_OFFLINE: "true"
run: cargo build --release --locked -p crucible -p forge -p crucible-controller -p crux --bins --features crucible-controller/autoresearch
run: cargo build --release --locked -p crucible -p forge -p crucible-controller -p crux --bins --features crucible/autoresearch,crucible-controller/autoresearch

# Collect every top-level executable from target/release (same pattern the Containerfile
# uses to stage /art/bin) into one tarball + a no-tooling sha256 integrity file.
Expand Down
1 change: 1 addition & 0 deletions Containerfile.runtime-selfcontained
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ WORKDIR /src
COPY . .
# The same bins, flags and staging docker.yml uses, so the packaged output matches CI's.
RUN cargo build --release --locked -p crucible -p crucible-broker -p forge --bins \
--features crucible/autoresearch \
&& mkdir -p /prebuilt/bin \
&& find target/release -maxdepth 1 -type f -executable -exec cp {} /prebuilt/bin/ \; \
&& ls -la /prebuilt/bin/
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,15 @@ Build and install from source:
```bash
git clone https://github.com/neuralmagic/crucible.git
cd crucible
cargo build --release -p crucible
cargo build --release -p crucible --features autoresearch
install -m 755 target/release/crucible ~/.local/bin/crucible
```

Without `--features autoresearch` the binary is the playbook workflow engine alone: `plan`,
`check`, `deploy`, `build`, `flow` and `fetch` work, and the optimization loop, `scope`,
`rank-grounded`, `watch-pr` and `loop-states` are not built. Release binaries and images carry
the feature.

Place the destination directory on `PATH`. Published binaries, when available for a
platform, are listed on the [GitHub releases page](https://github.com/neuralmagic/crucible/releases).

Expand All @@ -121,7 +126,7 @@ and Git history without a model or cluster. Its `command` backend increments the
From a source checkout:

```bash
cargo run -p crucible -- \
cargo run -p crucible --features autoresearch -- \
--manifest examples/counter/crucible.toml \
--iterations 6
```
Expand Down Expand Up @@ -336,7 +341,8 @@ intended for deterministic tests and integrations that provide their own propose
## CLI reference

Running `crucible` without a subcommand starts an optimization loop and requires
`--manifest`. The principal subcommands are:
`--manifest`. The loop, `scope`, `watch-pr`, `rank-grounded` and `loop-states` need a build with
`--features autoresearch`. The principal subcommands are:

| Command | Function |
| --- | --- |
Expand Down
3 changes: 3 additions & 0 deletions crucible/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ default = ["publish"]
# manifests, plans, flow) needs none of it, and the SDK carries its own hyper/rustls generation,
# so a linked consumer turns this off.
publish = ["dep:aws-config", "dep:aws-sdk-s3"]
# The scored optimization loop (keep/discard over a judge), scoping, grounded ranking and PR
# watching. Off by default: without it the binary is the playbook workflow engine.
autoresearch = ["publish"]

[dependencies]
anyhow.workspace = true
Expand Down
3 changes: 3 additions & 0 deletions crucible/src/agent/agent_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use nix::fcntl::{Flock, FlockArg};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[cfg(feature = "autoresearch")]
use crate::report::session::SessionAction;

const LEDGER_FILE: &str = "agent-sessions.json";
Expand Down Expand Up @@ -42,6 +43,7 @@ impl SessionTurn {
self.completed_turns > 0
}

#[cfg(feature = "autoresearch")]
pub(crate) fn action(&self) -> SessionAction {
if self.is_resume() {
SessionAction::Resumed
Expand Down Expand Up @@ -155,6 +157,7 @@ pub(crate) fn commit_if_ok(
.map(|e| format!("committing agent session failed: {e:#}"))
}

#[cfg(feature = "autoresearch")]
/// A resumed turn gets the follow-up prompt when the caller has one.
pub(crate) fn effective_prompt<'a>(
prepared: Option<&SessionTurn>,
Expand Down
19 changes: 19 additions & 0 deletions crucible/src/agent/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ pub(crate) fn handle() -> Result<&'static Handle> {
)
}

#[cfg(feature = "autoresearch")]
/// Best-effort span flush, for the run paths that end in `std::process::exit` (which skips
/// [`EngineCtx`]'s `Drop`). Bounded on a scratch thread so a hung collector cannot wedge exit;
/// a no-op when no exporter is installed.
Expand All @@ -109,6 +110,7 @@ pub(crate) fn flush() {
let _ = rx.recv_timeout(Duration::from_secs(3));
}

#[cfg(feature = "autoresearch")]
/// Why the run is being torn down, and the exit code that reports it. `128 + signo`, the shell
/// convention, so a killed loop is distinguishable from one that chose its own exit code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -119,6 +121,7 @@ pub(crate) enum Termination {
Interrupted,
}

#[cfg(feature = "autoresearch")]
impl Termination {
fn exit_code(self) -> i32 {
match self {
Expand All @@ -135,6 +138,7 @@ impl Termination {
}
}

#[cfg(feature = "autoresearch")]
/// Resolves on SIGTERM or ctrl-c, naming which arrived. Mirrors `crucible-broker`'s
/// `telemetry::shutdown_signal`; the broker feeds it to axum's graceful shutdown, the engine has no
/// server to drain so it acts on it directly in [`abort_on_signal`].
Expand All @@ -161,6 +165,7 @@ async fn termination_signal() -> Termination {
}
}

#[cfg(feature = "autoresearch")]
/// End `span` as aborted: mark it, then close the OTel side explicitly.
///
/// Dropping the `tracing::Span` is not enough here. The signal handler holds a CLONE while the run
Expand All @@ -183,6 +188,7 @@ fn end_span_aborted(span: &tracing::Span, reason: &str) {
otel.end();
}

#[cfg(feature = "autoresearch")]
/// Close the run out on SIGTERM/ctrl-c instead of dying mid-batch.
///
/// The engine has no graceful-shutdown path of its own: `run` ends in `std::process::exit`, so a
Expand Down Expand Up @@ -215,12 +221,15 @@ pub(crate) fn abort_on_signal(run_span: Option<tracing::Span>) {
});
}

#[cfg(feature = "autoresearch")]
/// The W3C env vars the controller's dispatches (loop run, scope turn, rank turn) inject; the
/// engine adopts them as its trace parent. Distinct from the `OTEL_*` exporter config, so they
/// never collide with it.
const TRACEPARENT_ENV: &str = "TRACEPARENT";
#[cfg(feature = "autoresearch")]
const TRACESTATE_ENV: &str = "TRACESTATE";

#[cfg(feature = "autoresearch")]
/// The controller-injected W3C parent from the process env, or `None` when `TRACEPARENT` is absent
/// (the normal local/uninstrumented invocation). A present-but-unparseable value is a
/// warn-and-ignore (never fail the work); the adopting span just roots itself.
Expand All @@ -237,6 +246,7 @@ fn dispatch_parent() -> Option<opentelemetry::Context> {
parent
}

#[cfg(feature = "autoresearch")]
/// The long-lived `run` root span for a loop. When the controller dispatched this pod the span is
/// parented to that dispatch, so the backend shows one tree (controller → run → turn → RPCs); a
/// standalone run (no controller, so no `TRACEPARENT`) gets the same span self-rooted. Returns
Expand Down Expand Up @@ -278,6 +288,7 @@ pub(crate) fn run_span(workspace: &str, run_id: &str) -> Option<tracing::Span> {
Some(span)
}

#[cfg(feature = "autoresearch")]
/// Which controller-dispatched agent turn is adopting the dispatch as its trace parent.
pub(crate) enum TurnSpanKind {
/// `crucible scope --propose`, pairs with the controller's `dispatch_scope` PRODUCER.
Expand All @@ -286,6 +297,7 @@ pub(crate) enum TurnSpanKind {
RankGrounded,
}

#[cfg(feature = "autoresearch")]
/// The CONSUMER root span for one controller-dispatched agent turn (scope-propose / grounded-rank),
/// parented to the controller's PRODUCER dispatch span exactly like [`run_span`], so the turn's
/// `openshell_turn` span nests under the dispatch instead of floating as an orphaned trace. Same
Expand Down Expand Up @@ -593,6 +605,7 @@ fn trace_env_from_context(cx: &opentelemetry::Context) -> Option<(String, Option
Some((traceparent, tracestate))
}

#[cfg(feature = "autoresearch")]
/// Extract the controller's remote parent context from the W3C carrier values, or `None` when
/// `traceparent` is absent/blank or serializes to an invalid (all-zeros) span context. A local
/// `TraceContextPropagator` mirrors the controller's injection side.
Expand Down Expand Up @@ -823,6 +836,7 @@ mod tests {
assert_eq!(resolve_logs_endpoint(Some(" ".into()), None), None);
}

#[cfg(feature = "autoresearch")]
#[test]
fn extract_parent_round_trips_the_controller_traceparent() {
use opentelemetry::trace::TraceContextExt as _;
Expand All @@ -843,6 +857,7 @@ mod tests {
);
}

#[cfg(feature = "autoresearch")]
#[test]
fn extract_parent_ignores_absent_or_garbage_traceparent() {
// Absent: the normal local run (turn spans root themselves).
Expand All @@ -861,6 +876,7 @@ mod tests {
);
}

#[cfg(feature = "autoresearch")]
#[test]
fn trace_env_formats_and_round_trips_through_extract() {
use opentelemetry::trace::{
Expand Down Expand Up @@ -1060,6 +1076,7 @@ mod tests {
);
}

#[cfg(feature = "autoresearch")]
#[test]
fn termination_reports_the_shell_signal_codes() {
// 128 + signo, so a killed loop is distinguishable from a chosen exit code. 143 is the one
Expand All @@ -1070,12 +1087,14 @@ mod tests {
assert_eq!(Termination::Interrupted.reason(), "SIGINT");
}

#[cfg(feature = "autoresearch")]
#[test]
fn abort_on_signal_without_a_span_is_a_noop() {
// Telemetry off means no run span, and installing a handler must not require a runtime.
abort_on_signal(None);
}

#[cfg(feature = "autoresearch")]
#[test]
fn end_span_aborted_ignores_an_unsampled_span() {
// No exporter installed in the test process, so the span carries an invalid SpanContext and
Expand Down
2 changes: 2 additions & 0 deletions crucible/src/agent/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ fn openai_prices(m: &str) -> Option<(f64, f64)> {
}
}

#[cfg(feature = "autoresearch")]
/// The turn's provisional cost from one mid-turn token sample: the OTEL number when
/// telemetry stamped one, otherwise the pricing-table estimate. Reconciled by the
/// authoritative turn-end cost, so streaming this keeps the budget line moving
Expand Down Expand Up @@ -173,6 +174,7 @@ mod tests {
assert_eq!(openai_prices("gpt-5.2-codex"), Some((1.75, 14.0)));
}

#[cfg(feature = "autoresearch")]
#[test]
fn provisional_prefers_the_authoritative_sample_cost() {
let mut t = sample();
Expand Down
5 changes: 5 additions & 0 deletions crucible/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! - [`AgentSource::Command`]: run a deterministic shell command in the workspace for
//! examples/tests; native `AgentEvent` JSON lines are decoded, everything else is raw.

#[cfg(feature = "autoresearch")]
pub(crate) mod activity;
pub(crate) mod agent_session;
pub(crate) mod engine;
Expand All @@ -30,6 +31,7 @@ pub(crate) mod turn;
use crate::agent::event::{AgentEvent, RawStream, Tokens, estimate_cost};
use crate::agent::harness::{HarnessRuntime, StreamDecoder};
use crate::args::{Args, Paths};
#[cfg(feature = "autoresearch")]
use crate::manifest::AgentBackend;
use crucible_harness::OtelCollector;
use std::io::{BufRead, BufReader, Read};
Expand Down Expand Up @@ -59,10 +61,12 @@ pub enum AgentSource {
Command(String),
}

#[cfg(feature = "autoresearch")]
pub(crate) fn supports_persistent_sessions(args: &Args) -> bool {
backend_supports_persistent_sessions(args.agent_backend, args.harness())
}

#[cfg(feature = "autoresearch")]
/// Capability predicate used by runtime admission and scope preview.
pub(crate) fn backend_supports_persistent_sessions(
backend: AgentBackend,
Expand Down Expand Up @@ -216,6 +220,7 @@ fn spawn_local(
})
}

#[cfg(feature = "autoresearch")]
/// Run one agent turn against the source resolved from `args`. `sink(raw_line, stream,
/// event)` is called per output line; returns the turn's [`TurnOutcome`]: the highest cost the
/// agent reported (0 if none) plus the transport failure that stopped it, if any.
Expand Down
15 changes: 15 additions & 0 deletions crucible/src/args.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
//! The shared vocabulary of a run: its CLI options ([`Args`]), the paths everything anchors
//! off ([`Paths`]), and the inputs resolved once before the loop starts ([`Prepared`]).

#[cfg(feature = "autoresearch")]
use crate::duration::parse_duration;
#[cfg(feature = "autoresearch")]
use crate::identity;
use crate::manifest;
use crate::openshell;
use std::path::{Path, PathBuf};
#[cfg(feature = "autoresearch")]
use std::time::Duration;

/// Which front-end to drive the loop with.
Expand Down Expand Up @@ -166,16 +169,20 @@ pub(crate) struct Args {
/// Publish-on-keep (composite only): per-component `(name, owner/repo)` fork map, populated from the
/// composite manifest's `[[component]].pr_repo`, not a CLI flag. Each touched component opens one
/// cross-linked draft PR against its fork.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
#[arg(skip)]
pub component_pr_repos: Vec<(String, String)>,
/// Declared pipeline artifacts (from `[[workspace.artifact]]`), for the publish layer:
/// each `embed` match lands in the PR body and the S3 run record. No CLI flag.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
#[arg(skip)]
pub artifacts: Vec<manifest::Artifact>,
/// Wide-round search config (from `[search]`). No CLI flag, set by `run_from_manifest`.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
#[arg(skip)]
pub search: Option<manifest::SearchCfg>,
/// Manifest-only authored workflow.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
#[arg(skip)]
pub workflow: Option<crate::plan::workflow::WorkflowCfg>,
/// Manifest injects restored in each task workspace.
Expand Down Expand Up @@ -203,11 +210,13 @@ impl Args {
<Flagless as clap::Parser>::try_parse_from(["crucible"]).map(|f| f.run)
}

#[cfg(feature = "autoresearch")]
/// Parse `--max-time` (e.g. `30m`) into a duration; None when unset/invalid.
pub(crate) fn max_time(&self) -> Option<Duration> {
parse_duration(&self.max_time)
}

#[cfg(feature = "autoresearch")]
/// Parse `--max-park` into a duration; None = wait on an approval indefinitely.
pub(crate) fn max_park(&self) -> Option<Duration> {
parse_duration(&self.max_park)
Expand Down Expand Up @@ -238,21 +247,26 @@ pub(crate) struct Paths {
/// Toolbox source dir (`[agent].toolbox_dir`, manifest-relative); its subdirs are copied
/// into `<workspace>/.claude/skills` each run. `None` when the manifest sets no toolbox.
pub skills: Option<PathBuf>,
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
pub steer: PathBuf,
/// Cross-process state dir (gitignored): the session log + control file live here.
pub state: PathBuf,
/// Append-only NDJSON event log the headless loop emits for external tailers.
pub session_log: PathBuf,
/// Cross-process stop signal written by the `stop` tool.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
pub control: PathBuf,
/// Escalation marker the agent's `escalate` tool writes in its workspace; the loop detects it
/// after a turn, restores the world, and halts for human review.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
pub escalation: PathBuf,
/// Pending-provisioning marker the agent writes when it has an open approval to wait on; the loop
/// detects it after a turn and parks or continues per its `mode`.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
pub provisioning: PathBuf,
/// Append-only NDJSON record of every external input, authoritative over the session
/// log for what an operator asked for; a resume replays it.
#[cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
pub admissions: PathBuf,
}

Expand Down Expand Up @@ -298,6 +312,7 @@ impl Paths {
}

/// Inputs resolved once before the loop (and before any UI takes the screen).
#[cfg(feature = "autoresearch")]
#[derive(Clone)]
pub(crate) struct Prepared {
pub goal: String,
Expand Down
Loading
Loading