From 59ca866b71a09ed7fd702853cb3dc675dfa4da50 Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Mon, 3 Aug 2026 00:45:08 -0700 Subject: [PATCH 1/4] Harden cross-platform agent recovery --- .codex | 0 .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 151 ++++++++ CHANGELOG.md | 9 + Cargo.lock | 96 ++++- Cargo.toml | 13 +- README.md | 57 ++- docs/spec.md | 13 + src/capture.rs | 658 ++++++++++++++++++++++++++++++++ src/compatibility.rs | 186 ++++++++++ src/config.rs | 54 ++- src/discovery.rs | 680 ++++++++++++++++++++++++++++++++++ src/exec.rs | 5 +- src/jobs.rs | 142 ++++++- src/lib.rs | 4 + src/main.rs | 81 +++- src/platform.rs | 333 +++++++++++++++++ src/process.rs | 154 +++++++- tests/agent_jobs_cli.rs | 76 +++- tests/compatibility_cli.rs | 122 ++++++ tests/config_cli.rs | 20 +- tests/discovery_cli.rs | 261 +++++++++++++ tests/error_capture_cli.rs | 140 +++++++ tests/exec_cli.rs | 69 ++-- 24 files changed, 3232 insertions(+), 98 deletions(-) create mode 100644 .codex create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 src/capture.rs create mode 100644 src/compatibility.rs create mode 100644 src/discovery.rs create mode 100644 src/platform.rs create mode 100644 tests/compatibility_cli.rs create mode 100644 tests/discovery_cli.rs create mode 100644 tests/error_capture_cli.rs diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8d0504..699d103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,11 @@ on: jobs: verify: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8c2f80d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,151 @@ +name: Binary release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Semantic version to release. Must match Cargo.toml." + required: false + type: string + prerelease: + description: "Mark the GitHub release as prerelease" + required: true + default: true + type: boolean + +permissions: + contents: write + +jobs: + metadata: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + prerelease: ${{ steps.meta.outputs.prerelease }} + steps: + - uses: actions/checkout@v4 + - id: meta + shell: bash + run: | + set -euo pipefail + cargo_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)" + requested_version="${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }}" + version="${requested_version:-$cargo_version}" + test "$version" = "$cargo_version" + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + test "${GITHUB_REF_NAME}" = "v${version}" + fi + prerelease="true" + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + prerelease="${{ inputs.prerelease }}" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT" + + build: + needs: metadata + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-x86_64 + binary_ext: "" + - os: ubuntu-24.04-arm + platform: linux-aarch64 + binary_ext: "" + - os: macos-15-intel + platform: macos-x86_64 + binary_ext: "" + - os: macos-14 + platform: macos-aarch64 + binary_ext: "" + - os: windows-latest + platform: windows-x86_64 + binary_ext: ".exe" + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build and smoke + shell: bash + run: | + cargo build --locked --release + "target/release/agentctl${{ matrix.binary_ext }}" --version + - name: Package + shell: bash + run: | + set -euo pipefail + version="${{ needs.metadata.outputs.version }}" + platform="${{ matrix.platform }}" + binary="agentctl${{ matrix.binary_ext }}" + root="dist/agentctl-${version}" + staging="${root}/${platform}" + archive="dist/agentctl-${version}-${platform}.tar.gz" + mkdir -p "$staging" + cp "target/release/${binary}" "$staging/${binary}" + chmod +x "$staging/${binary}" || true + cp README.md LICENSE "$root/" + cat > "$root/RELEASE-METADATA.json" <=0.1.13, <0.2.0" + } + JSON + tar -C dist -czf "$archive" "agentctl-${version}" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$archive" > "${archive}.sha256" + else + shasum -a 256 "$archive" > "${archive}.sha256" + fi + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }} + path: | + dist/agentctl-${{ needs.metadata.outputs.version }}-${{ matrix.platform }}.tar.gz + dist/agentctl-${{ needs.metadata.outputs.version }}-${{ matrix.platform }}.tar.gz.sha256 + if-no-files-found: error + + publish: + needs: [metadata, build] + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: dist + - name: Verify and publish complete matrix + shell: bash + run: | + set -euo pipefail + version="${{ needs.metadata.outputs.version }}" + for platform in linux-x86_64 linux-aarch64 macos-x86_64 macos-aarch64 windows-x86_64; do + archive="dist/agentctl-${version}-${platform}.tar.gz" + test -s "$archive" + test -s "${archive}.sha256" + sha256sum -c "${archive}.sha256" + done + tag="v${version}" + prerelease_flag="" + if [[ "${{ needs.metadata.outputs.prerelease }}" == "true" ]]; then + prerelease_flag="--prerelease" + fi + if ! gh release view "$tag" >/dev/null 2>&1; then + gh release create "$tag" --title "agentctl v${version}" \ + --notes "Paired launcher for LDGR Core 0.1.13 first-class error recovery." \ + $prerelease_flag + fi + gh release upload "$tag" dist/*.tar.gz dist/*.tar.gz.sha256 --clobber diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3126f78 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.1.2 - 2026-07-30 + +- Persist supervisor-owned execution intents and first-class recovery envelopes before worker startup. +- Detect native Windows homes, executables, process state, and process trees without Unix shims. +- Add `agentctl discover` with LDGR harness and Core compatibility diagnostics. +- Negotiate `ldgr.launcher-compatibility.v1` before an LDGR loop worker starts; incompatible or older Core binaries now produce an actionable durable `agentctl.compatibility/core-incompatible` error. +- Publish checksum-covered binaries for Linux, macOS, and Windows for paired packaging with LDGR Core 0.1.13. diff --git a/Cargo.lock b/Cargo.lock index 705fe39..9c97a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,17 +4,21 @@ version = 4 [[package]] name = "agentctl" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "assert_cmd", "clap", + "getrandom 0.2.17", "libc", + "semver", "serde", "serde_json", + "sha2", "signal-hook", "tempfile", "toml", + "windows-sys", ] [[package]] @@ -94,6 +98,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -157,12 +170,41 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -191,6 +233,27 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -435,6 +498,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -479,7 +553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys", @@ -532,6 +606,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -550,6 +630,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -559,6 +645,12 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" diff --git a/Cargo.toml b/Cargo.toml index bd424f0..73f6b2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentctl" -version = "0.1.1" +version = "0.1.2" edition = "2024" license = "MIT" description = "Compact, bounded command and agent reports for LLM tool loops" @@ -13,14 +13,25 @@ exclude = [".ldgr/", "docs/"] [dependencies] anyhow = "1.0.98" clap = { version = "4.5.40", features = ["derive"] } +getrandom = "0.2.16" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" +semver = "1.0.26" +sha2 = "0.10.9" toml = "0.8.23" [target.'cfg(unix)'.dependencies] libc = "0.2.186" signal-hook = "0.3.18" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } + [dev-dependencies] assert_cmd = "2.0.17" tempfile = "3.20.0" diff --git a/README.md b/README.md index 36b7cee..f898289 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,43 @@ agentctl config check `~/.agentctl/config.toml`. Raw command logs and detached job records live under `~/.agentctl/jobs/`. +Inspect the current platform, home-directory resolution, LDGR harness +selection, configured agents, and commands actually available on `PATH`: + +```sh +agentctl discover +agentctl discover --json +``` + +For an `ldgr-loop` or `ldgr-summary` profile, agentctl negotiates +`ldgr.launcher-compatibility.v1` before reading worker output or starting the +agent. Agentctl 0.1.2 requires LDGR Core `>=0.1.13, <0.2.0`. An older or mixed +Core is rejected with a durable `agentctl.compatibility/core-incompatible` +recovery record and exact upgrade/rollback commands. Install both executables +from the same LDGR Core release bundle. + +Agentctl works with `HOME` on Unix, macOS, WSL, and Windows environments that +provide it. Native Windows also falls back to `USERPROFILE`, then +`HOMEDRIVE` + `HOMEPATH`; `AGENTCTL_HOME` is an explicit final configuration +override for unusual service or test environments. A synthesized home is +passed to child harnesses as `HOME`. + +`ldgr install` records its ordered harness selection in +`~/.ldgr/config.toml`. Agentctl also reads the older +`~/.ldgr/config.json` during migration. Resolution order is: + +1. an explicit `agentctl run ` profile; +2. a runnable generic `ldgr-loop` profile; +3. the first runnable profile among LDGR's default and selected harnesses; +4. for an agent omitted on the command line, another runnable configured + profile. + +An unavailable explicit non-LDGR profile never silently changes providers. +When the generic LDGR profile is stale, Agentctl can use the next selected +harness and prints the decision to stderr. Pass `--no-fallback` to disable that +behavior. Failures list the detected platform, config source, selected +harnesses, runnable alternatives, and repair commands. + ## Wrap a noisy command Use `exec` when an agent wants to run a command but should receive only the @@ -57,7 +94,10 @@ retained in memory. Agent commands run in an isolated process group. Interruptin agentctl forwards the signal to the complete command tree, waits for it, and terminates descendants left behind by a command that exits early. On Linux, agentctl also adopts and reaps orphaned descendants so repeated tool loops do not -accumulate zombie or live background agent processes. +accumulate zombie or live background agent processes. Native Windows commands +are assigned to kill-on-close Job Objects, detached supervisors use a detached +process group, and job status uses native process handles rather than Unix +utilities. ## Run a detached agent task @@ -73,6 +113,10 @@ max_preview_lines = 12 command = ["codex", "exec", "--sandbox", "workspace-write"] prompt_stdin = true +[agents.pi] +command = ["pi", "-p"] +prompt_stdin = false + [agents.claude-code] command = ["claude", "-p"] prompt_stdin = false @@ -95,6 +139,14 @@ prompt_stdin = true [agents.ollama] command = ["ollama", "run", "llama3"] prompt_stdin = true + +[agents.openclaw] +command = ["openclaw", "run"] +prompt_stdin = false + +[agents.opencode] +command = ["opencode", "run"] +prompt_stdin = false ``` Start a long task without keeping the caller attached: @@ -106,7 +158,8 @@ agentctl run codex \ --detach \ --json -# codex is the default agent, and stdin is accepted as the prompt: +# With no explicit profile, Agentctl uses the valid LDGR selection when +# available and otherwise prefers codex. Stdin is accepted as the prompt: printf 'Run cargo test and summarize the result.' | agentctl run --json ``` diff --git a/docs/spec.md b/docs/spec.md index 2c603e2..abd75f1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -58,6 +58,19 @@ agentctl config [generate|check|show] `~/.agentctl/config.toml`, overridable per-project by `./agentctl.toml` (project file wins). Generated by `agentctl config generate`. +The LDGR integration additionally reads the ordered harness selection from +`~/.ldgr/config.toml`, with `~/.ldgr/config.json` retained as a legacy fallback. +Agentctl discovers commands from platform-correct `PATH`/`PATHEXT` semantics, +uses `USERPROFILE` when native Windows does not provide `HOME`, and exposes +`agentctl discover [--json]` for recovery diagnostics. Explicit agent profiles +win; only the generic LDGR profile may automatically fall through to another +LDGR-selected runnable harness. + +LDGR-owned profiles negotiate `ldgr.launcher-compatibility.v1` before worker +startup. Agentctl 0.1.2 requires Core `>=0.1.13, <0.2.0` and recovery schema 1. +Failure is an infrastructure error with code `core-incompatible`; it is written +through Core when supported or atomically spooled for import after Core upgrade. + ```toml [summary] max_output_bytes = 16384 # cap on captured output considered diff --git a/src/capture.rs b/src/capture.rs new file mode 100644 index 0000000..dcf0bbd --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,658 @@ +use std::env; +use std::ffi::OsStr; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::platform; + +static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); + +const NO_SINK_DIAGNOSTIC: &str = + "FATAL: no durable LDGR recovery sink is writable; worker execution was not started"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionAttempt { + pub operation_id: String, + pub attempt_id: String, + observed_at: String, + project_root: PathBuf, + intent_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub enum FailureKind { + Configuration, + Environment, + CoreIncompatible { + detected_core_version: Option, + }, + Spawn, + ExitCode(i32), + Signal(i32), + UnexpectedDisappearance, +} + +#[derive(Debug, Clone, Serialize)] +struct ProjectRef { + project_id: Option, + locator: String, + database_identity: Option, +} + +#[derive(Debug, Serialize)] +struct IntentEnvelope<'a> { + format: &'static str, + schema_version: u32, + project: &'a ProjectRef, + producer: &'static str, + operation_id: &'a str, + attempt_id: &'a str, + boundary: &'static str, + subject: &'a str, + environment: Value, + accepted_at: String, + process_id: u32, +} + +#[derive(Debug, Serialize)] +struct Fingerprint { + version: &'static str, + value: String, + inputs: FingerprintInputs, +} + +#[derive(Debug, Serialize)] +struct FingerprintInputs { + class: &'static str, + domain: &'static str, + code: &'static str, + boundary: &'static str, + component: &'static str, + subject: &'static str, +} + +#[derive(Debug, Serialize)] +struct ErrorBody { + class: &'static str, + domain: &'static str, + code: &'static str, + severity: &'static str, + retryability: &'static str, + source: &'static str, + summary: &'static str, + details: Value, + environment: Value, +} + +#[derive(Debug, Serialize)] +struct ErrorEnvelope<'a> { + format: &'static str, + schema_version: u32, + project: &'a ProjectRef, + producer: &'static str, + idempotency_key: String, + operation_id: &'a str, + attempt_id: &'a str, + occurrence_id: String, + fingerprint: Fingerprint, + error: ErrorBody, + observed_at: String, +} + +impl ExecutionAttempt { + /// Persist acceptance before configuration discovery or process creation. + pub fn begin(project_hint: &Path, subject: &str) -> Result { + let operation_id = uuid_v7()?; + let attempt_id = uuid_v7()?; + let project_root = discover_project_root(project_hint); + let project = project_ref(&project_root); + let environment = allowlisted_environment(); + let accepted_at = observed_at(); + let envelope = IntentEnvelope { + format: "ldgr-execution-intent", + schema_version: 1, + project: &project, + producer: "agentctl", + operation_id: &operation_id, + attempt_id: &attempt_id, + boundary: "agent-launch", + subject, + environment, + accepted_at: accepted_at.clone(), + process_id: std::process::id(), + }; + let bytes = serde_json::to_vec_pretty(&envelope)?; + let filename = format!("{attempt_id}.intent.json"); + let intent_path = write_to_first_sink(&project_root, &filename, &bytes) + .map_err(|error| anyhow!("{NO_SINK_DIAGNOSTIC}: {error:#}"))?; + Ok(Self { + operation_id, + attempt_id, + observed_at: accepted_at, + project_root, + intent_path, + }) + } + + pub fn record_error(&self, failure: FailureKind) -> Result<()> { + if !self.intent_path.is_file() { + return Ok(()); + } + let classification = classification(&failure); + let occurrence_id = deterministic_occurrence_id(&self.attempt_id, classification.code); + let inputs = FingerprintInputs { + class: classification.class, + domain: classification.domain, + code: classification.code, + boundary: classification.boundary, + component: "agentctl", + subject: "agent-worker", + }; + let fingerprint = fingerprint(&inputs)?; + let project = project_ref(&self.project_root); + let environment = allowlisted_environment(); + let details = match &failure { + FailureKind::CoreIncompatible { + detected_core_version, + } => json!({ + "agentctl_version": env!("CARGO_PKG_VERSION"), + "detected_core_version": detected_core_version, + "required_core": crate::compatibility::REQUIRED_CORE, + "compatibility_schema": crate::compatibility::COMPATIBILITY_SCHEMA, + }), + FailureKind::ExitCode(code) => json!({ "exit_code": code }), + FailureKind::Signal(signal) => json!({ "signal": signal }), + _ => json!({}), + }; + let observed_at = self.observed_at.clone(); + let idempotency_key = format!("{}:{}", self.attempt_id, classification.code); + let envelope = ErrorEnvelope { + format: "ldgr-error-recovery", + schema_version: 1, + project: &project, + producer: "agentctl", + idempotency_key: idempotency_key.clone(), + operation_id: &self.operation_id, + attempt_id: &self.attempt_id, + occurrence_id: occurrence_id.clone(), + fingerprint: Fingerprint { + version: "structured-v1", + value: fingerprint.clone(), + inputs, + }, + error: ErrorBody { + class: classification.class, + domain: classification.domain, + code: classification.code, + severity: "error", + retryability: classification.retryability, + source: classification.source, + summary: classification.summary, + details: details.clone(), + environment: environment.clone(), + }, + observed_at: observed_at.clone(), + }; + + if record_with_core( + &self.project_root, + CoreOccurrence { + occurrence_id: &occurrence_id, + idempotency_key: &idempotency_key, + operation_id: &self.operation_id, + attempt_id: &self.attempt_id, + class: classification.class, + domain: classification.domain, + code: classification.code, + boundary: classification.boundary, + retryability: classification.retryability, + source: classification.source, + summary: classification.summary, + details: &details, + environment: &environment, + observed_at: &observed_at, + }, + ) { + self.remove_intent(); + return Ok(()); + } + + let filename = format!("{}-{}.json", self.attempt_id, classification.code); + let bytes = serde_json::to_vec_pretty(&envelope)?; + write_to_first_sink(&self.project_root, &filename, &bytes).map_err(|error| { + anyhow!( + "FATAL: accepted worker failure could not be written to any durable LDGR sink: {error:#}" + ) + })?; + self.remove_intent(); + Ok(()) + } + + pub fn complete(&self) { + self.remove_intent(); + } + + fn remove_intent(&self) { + let _ = fs::remove_file(&self.intent_path); + } +} + +struct Classification { + class: &'static str, + domain: &'static str, + code: &'static str, + boundary: &'static str, + retryability: &'static str, + source: &'static str, + summary: &'static str, +} + +fn classification(failure: &FailureKind) -> Classification { + match failure { + FailureKind::Configuration => Classification { + class: "infrastructure-error", + domain: "agentctl.bootstrap", + code: "configuration-invalid", + boundary: "config-discovery", + retryability: "after-change", + source: "agentctl:config-discovery", + summary: "Agent configuration could not be loaded before worker startup.", + }, + FailureKind::Environment => Classification { + class: "infrastructure-error", + domain: "agentctl.bootstrap", + code: "environment-invalid", + boundary: "environment", + retryability: "after-change", + source: "agentctl:environment", + summary: "The supervisor environment was invalid before worker startup.", + }, + FailureKind::CoreIncompatible { .. } => Classification { + class: "infrastructure-error", + domain: "agentctl.compatibility", + code: "core-incompatible", + boundary: "core-negotiation", + retryability: "after-change", + source: "agentctl:core-negotiation", + summary: "The installed LDGR Core is incompatible with this agentctl release.", + }, + FailureKind::Spawn => Classification { + class: "infrastructure-error", + domain: "agentctl.spawn", + code: "worker-spawn-failed", + boundary: "worker-spawn", + retryability: "after-change", + source: "agentctl:worker-spawn", + summary: "The supervisor could not start the configured worker.", + }, + FailureKind::ExitCode(_) => Classification { + class: "task-failure", + domain: "agentctl.worker", + code: "nonzero-exit", + boundary: "worker-exit", + retryability: "unknown", + source: "agentctl:worker-exit", + summary: "The worker returned a nonzero exit code.", + }, + FailureKind::Signal(_) => Classification { + class: "interruption", + domain: "agentctl.worker", + code: "signal", + boundary: "worker-exit", + retryability: "unknown", + source: "agentctl:worker-exit", + summary: "The worker was terminated by a signal.", + }, + FailureKind::UnexpectedDisappearance => Classification { + class: "interruption", + domain: "agentctl.supervisor", + code: "unexpected-disappearance", + boundary: "supervisor-reconciliation", + retryability: "after-change", + source: "agentctl:status", + summary: "A supervised process disappeared without a terminal record.", + }, + } +} + +struct CoreOccurrence<'a> { + occurrence_id: &'a str, + idempotency_key: &'a str, + operation_id: &'a str, + attempt_id: &'a str, + class: &'a str, + domain: &'a str, + code: &'a str, + boundary: &'a str, + retryability: &'a str, + source: &'a str, + summary: &'a str, + details: &'a Value, + environment: &'a Value, + observed_at: &'a str, +} + +fn record_with_core(project_root: &Path, occurrence: CoreOccurrence<'_>) -> bool { + let db = project_root.join(".ldgr/ldgr.db"); + if !db.is_file() || platform::command_path("ldgr").is_none() { + return false; + } + let details = match serde_json::to_string(occurrence.details) { + Ok(value) => value, + Err(_) => return false, + }; + let environment = match serde_json::to_string(occurrence.environment) { + Ok(value) => value, + Err(_) => return false, + }; + Command::new("ldgr") + .arg("--db") + .arg(db) + .args([ + "error", + "record", + "--occurrence-id", + occurrence.occurrence_id, + "--producer", + "agentctl", + "--idempotency-key", + occurrence.idempotency_key, + "--operation-id", + occurrence.operation_id, + "--attempt-id", + occurrence.attempt_id, + "--class", + occurrence.class, + "--domain", + occurrence.domain, + "--code", + occurrence.code, + "--boundary", + occurrence.boundary, + "--component", + "agentctl", + "--subject", + "agent-worker", + "--severity", + "error", + "--retryability", + occurrence.retryability, + "--source", + occurrence.source, + "--summary", + occurrence.summary, + "--details", + &details, + "--environment", + &environment, + "--observed-at", + occurrence.observed_at, + "--recovery-origin", + "database", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn write_to_first_sink(project_root: &Path, filename: &str, bytes: &[u8]) -> Result { + let mut failures = Vec::new(); + for directory in recovery_directories(project_root) { + match atomic_write(&directory, filename, bytes) { + Ok(path) => return Ok(path), + Err(error) => failures.push(format!("{}: {error:#}", directory.display())), + } + } + bail!("{}", failures.join("; ")) +} + +fn recovery_directories(project_root: &Path) -> Vec { + let mut directories = vec![project_root.join(".ldgr/recovery/inbox")]; + if let Some(user) = user_recovery_directory() + && !directories.contains(&user) + { + directories.push(user); + } + directories +} + +fn user_recovery_directory() -> Option { + #[cfg(windows)] + { + nonempty_var("LOCALAPPDATA").map(|root| PathBuf::from(root).join("ldgr/recovery/inbox")) + } + #[cfg(not(windows))] + { + nonempty_var("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| nonempty_var("HOME").map(|home| PathBuf::from(home).join(".local/state"))) + .map(|root| root.join("ldgr/recovery/inbox")) + } +} + +fn atomic_write(directory: &Path, filename: &str, bytes: &[u8]) -> Result { + fs::create_dir_all(directory) + .with_context(|| format!("creating recovery directory {}", directory.display()))?; + let destination = directory.join(filename); + if fs::read(&destination).is_ok_and(|existing| existing == bytes) { + return Ok(destination); + } + let temporary = directory.join(format!( + ".{filename}.{}.{}.tmp", + std::process::id(), + UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .with_context(|| format!("creating {}", temporary.display()))?; + file.write_all(bytes) + .with_context(|| format!("writing {}", temporary.display()))?; + file.sync_all() + .with_context(|| format!("syncing {}", temporary.display()))?; + drop(file); + if let Err(error) = fs::rename(&temporary, &destination) { + if fs::read(&destination).is_ok_and(|existing| existing == bytes) { + let _ = fs::remove_file(&temporary); + return Ok(destination); + } + let _ = fs::remove_file(&temporary); + return Err(error).with_context(|| { + format!( + "atomically publishing {} as {}", + temporary.display(), + destination.display() + ) + }); + } + #[cfg(unix)] + { + fs::File::open(directory) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("syncing recovery directory {}", directory.display()))?; + } + Ok(destination) +} + +fn discover_project_root(hint: &Path) -> PathBuf { + if hint.is_absolute() { + hint.to_path_buf() + } else { + env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(hint) + } +} + +fn project_ref(project_root: &Path) -> ProjectRef { + ProjectRef { + project_id: fs::read_to_string(project_root.join(".ldgr/project-id")) + .ok() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), + locator: redacted_locator(project_root), + database_identity: None, + } +} + +fn redacted_locator(project_root: &Path) -> String { + for name in ["HOME", "USERPROFILE"] { + let Some(home) = nonempty_var(name).map(PathBuf::from) else { + continue; + }; + let Ok(relative) = project_root.strip_prefix(home) else { + continue; + }; + if relative.as_os_str().is_empty() { + return "$HOME".to_owned(); + } + return format!("$HOME/{}", relative.to_string_lossy().replace('\\', "/")); + } + project_root.to_string_lossy().replace('\\', "/") +} + +fn allowlisted_environment() -> Value { + json!({ + "os": env::consts::OS, + "arch": env::consts::ARCH, + "family": env::consts::FAMILY, + }) +} + +fn fingerprint(inputs: &FingerprintInputs) -> Result { + let canonical = serde_json::to_vec(&serde_json::to_value(inputs)?)?; + Ok(format!("sha256:{:x}", Sha256::digest(canonical))) +} + +fn uuid_v7() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_millis() as u64; + let mut bytes = [0_u8; 16]; + getrandom::getrandom(&mut bytes) + .map_err(|error| anyhow!("generating execution identity: {error}"))?; + bytes[..6].copy_from_slice(&millis.to_be_bytes()[2..]); + bytes[6] = (bytes[6] & 0x0f) | 0x70; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Ok(format!( + "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", + u32::from_be_bytes(bytes[0..4].try_into().expect("four bytes")), + u16::from_be_bytes(bytes[4..6].try_into().expect("two bytes")), + u16::from_be_bytes(bytes[6..8].try_into().expect("two bytes")), + u16::from_be_bytes(bytes[8..10].try_into().expect("two bytes")), + u64::from_be_bytes([ + 0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], + ]) + )) +} + +fn deterministic_occurrence_id(attempt_id: &str, code: &str) -> String { + let digest = Sha256::digest(format!("agentctl:{attempt_id}:{code}")); + let timestamp = attempt_id + .chars() + .filter(|character| *character != '-') + .take(12) + .collect::(); + let random = format!("{:x}", digest); + let variant = match &random[3..4] { + "0" | "1" | "2" | "3" => "8", + "4" | "5" | "6" | "7" => "9", + "8" | "9" | "a" | "b" => "a", + _ => "b", + }; + format!( + "{}-{}-7{}-{}{}-{}", + ×tamp[0..8], + ×tamp[8..12], + &random[0..3], + variant, + &random[4..7], + &random[7..19], + ) +} + +fn observed_at() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + let days = seconds.div_euclid(86_400); + let second_of_day = seconds.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + let hour = second_of_day / 3_600; + let minute = (second_of_day % 3_600) / 60; + let second = second_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +fn nonempty_var(name: &str) -> Option { + env::var_os(OsStr::new(name)).filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intent_and_error_payloads_exclude_prompt_text() -> Result<()> { + let project = tempfile::tempdir()?; + let attempt = ExecutionAttempt::begin(project.path(), "test-agent")?; + let intent = fs::read_to_string(&attempt.intent_path)?; + assert!(intent.contains("ldgr-execution-intent")); + assert!(!intent.contains("secret prompt")); + + attempt.record_error(FailureKind::Spawn)?; + let inbox = project.path().join(".ldgr/recovery/inbox"); + let recovery = fs::read_dir(inbox)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .find(|path| path.extension() == Some(OsStr::new("json"))) + .context("recovery record")?; + let payload = fs::read_to_string(recovery)?; + assert!(payload.contains("worker-spawn-failed")); + assert!(!payload.contains("secret prompt")); + Ok(()) + } + + #[test] + fn generated_identity_has_uuid_v7_shape() -> Result<()> { + let id = uuid_v7()?; + assert_eq!(id.len(), 36); + assert_eq!(&id[14..15], "7"); + assert!(matches!(&id[19..20], "8" | "9" | "a" | "b")); + Ok(()) + } +} diff --git a/src/compatibility.rs b/src/compatibility.rs new file mode 100644 index 0000000..5dd96d7 --- /dev/null +++ b/src/compatibility.rs @@ -0,0 +1,186 @@ +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; + +use crate::platform; + +pub const COMPATIBILITY_SCHEMA: &str = "ldgr.launcher-compatibility.v1"; +pub const REQUIRED_CORE: &str = ">=0.1.13, <0.2.0"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoreCompatibility { + pub schema: String, + pub compatible: bool, + pub core_version: String, + pub core_executable: PathBuf, + pub agentctl_version: String, + pub agentctl_requirement: String, + pub error_recovery_schema: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CoreCompatibilityStatus { + pub executable: Option, + pub core_version: Option, + pub agentctl_version: String, + pub required_core: String, + pub compatible: bool, + pub diagnostic: Option, +} + +#[derive(Debug, Clone)] +pub struct CompatibilityFailure { + pub detected_core_version: Option, + message: String, +} + +impl fmt::Display for CompatibilityFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for CompatibilityFailure {} + +pub fn negotiate(project_root: &Path) -> Result { + let executable = platform::command_path("ldgr"); + let Some(executable) = executable else { + return Err(failure(None, None, "LDGR Core is not available on PATH")); + }; + + let output = Command::new(&executable) + .current_dir(project_root) + .args([ + "compatibility", + "--agentctl-version", + env!("CARGO_PKG_VERSION"), + "--json", + ]) + .stdin(Stdio::null()) + .output() + .map_err(|error| { + failure( + Some(&executable), + None, + &format!("could not start LDGR Core compatibility negotiation: {error}"), + ) + })?; + + if !output.status.success() { + let detected = probe_version(&executable, project_root).ok(); + return Err(failure( + Some(&executable), + detected.as_deref(), + "LDGR Core does not implement the required launcher compatibility protocol", + )); + } + + let report: CoreCompatibility = serde_json::from_slice(&output.stdout).map_err(|error| { + let detected = probe_version(&executable, project_root).ok(); + failure( + Some(&executable), + detected.as_deref(), + &format!("LDGR Core returned an invalid compatibility report: {error}"), + ) + })?; + let expected = VersionReq::parse(REQUIRED_CORE).expect("valid Core requirement"); + let core_version = Version::parse(&report.core_version).map_err(|error| { + failure( + Some(&executable), + Some(&report.core_version), + &format!("LDGR Core returned a non-semantic version: {error}"), + ) + })?; + if report.schema != COMPATIBILITY_SCHEMA + || !report.compatible + || !expected.matches(&core_version) + || report.error_recovery_schema != 1 + { + return Err(failure( + Some(&executable), + Some(&report.core_version), + "LDGR Core rejected this agentctl release or reported an unsupported recovery contract", + )); + } + Ok(report) +} + +pub fn status(project_root: &Path) -> CoreCompatibilityStatus { + match negotiate(project_root) { + Ok(report) => CoreCompatibilityStatus { + executable: Some(report.core_executable), + core_version: Some(report.core_version), + agentctl_version: env!("CARGO_PKG_VERSION").to_owned(), + required_core: REQUIRED_CORE.to_owned(), + compatible: true, + diagnostic: None, + }, + Err(error) => CoreCompatibilityStatus { + executable: platform::command_path("ldgr"), + core_version: error.detected_core_version.clone(), + agentctl_version: env!("CARGO_PKG_VERSION").to_owned(), + required_core: REQUIRED_CORE.to_owned(), + compatible: false, + diagnostic: Some(error.to_string()), + }, + } +} + +fn probe_version(executable: &Path, project_root: &Path) -> Result { + let output = Command::new(executable) + .current_dir(project_root) + .arg("--version") + .stdin(Stdio::null()) + .output() + .with_context(|| format!("starting {}", executable.display()))?; + if !output.status.success() { + bail!("{} --version failed", executable.display()); + } + let text = String::from_utf8(output.stdout).context("LDGR version output was not UTF-8")?; + text.split_whitespace() + .nth(1) + .map(str::to_owned) + .context("LDGR version output did not contain a version") +} + +fn failure( + executable: Option<&Path>, + detected_core_version: Option<&str>, + reason: &str, +) -> CompatibilityFailure { + let found = match (executable, detected_core_version) { + (Some(path), Some(version)) => format!("found ldgr {version} at {}", path.display()), + (Some(path), None) => format!("found an incompatible ldgr at {}", path.display()), + (None, _) => "no ldgr executable was found on PATH".to_owned(), + }; + CompatibilityFailure { + detected_core_version: detected_core_version.map(str::to_owned), + message: format!( + "{reason}; agentctl {} requires LDGR Core {REQUIRED_CORE} with compatibility schema {COMPATIBILITY_SCHEMA}, but {found}.\n\ + Upgrade both binaries from the same LDGR Core release bundle, then verify:\n\ + - `agentctl --version`\n\ + - `ldgr --version`\n\ + - `agentctl discover --json`\n\ + On Windows, rerun `irm https://ldgr.run/install.ps1 | iex`; the installer updates the currently resolved user binary directory. \ + To roll back, install a release bundle containing both binaries rather than mixing versions.", + env!("CARGO_PKG_VERSION") + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn required_core_range_is_valid_and_excludes_old_core() { + let requirement = VersionReq::parse(REQUIRED_CORE).expect("requirement"); + assert!(!requirement.matches(&Version::parse("0.1.12").expect("old"))); + assert!(requirement.matches(&Version::parse("0.1.13").expect("current"))); + assert!(!requirement.matches(&Version::parse("0.2.0").expect("future major"))); + } +} diff --git a/src/config.rs b/src/config.rs index 19a6a13..da870d5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,9 +2,11 @@ use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; +use crate::platform; + pub const PROJECT_CONFIG_FILE: &str = "agentctl.toml"; pub const CONFIG_DIR: &str = ".agentctl"; pub const CONFIG_FILE: &str = "config.toml"; @@ -43,6 +45,14 @@ pub struct LoadedConfig { impl Default for Config { fn default() -> Self { let mut agents = BTreeMap::new(); + agents.insert( + "pi".to_owned(), + AgentConfig { + command: vec!["pi".to_owned(), "-p".to_owned()], + prompt_stdin: false, + cwd: None, + }, + ); agents.insert( "codex".to_owned(), AgentConfig { @@ -88,6 +98,22 @@ impl Default for Config { cwd: None, }, ); + agents.insert( + "openclaw".to_owned(), + AgentConfig { + command: vec!["openclaw".to_owned(), "run".to_owned()], + prompt_stdin: false, + cwd: None, + }, + ); + agents.insert( + "opencode".to_owned(), + AgentConfig { + command: vec!["opencode".to_owned(), "run".to_owned()], + prompt_stdin: false, + cwd: None, + }, + ); agents.insert( "ollama".to_owned(), AgentConfig { @@ -121,22 +147,27 @@ pub fn load() -> Result { } pub fn load_from_dir(current_dir: &Path) -> Result { - let project_config = current_dir.join(PROJECT_CONFIG_FILE); - if project_config.exists() { - return load_path(&project_config); - } - - let home_config = home_config_path()?; - if home_config.exists() { - return load_path(&home_config); + let home = platform::home_dir()?; + let selected_path = preferred_config_path(current_dir, &home); + if selected_path.exists() { + return load_path(&selected_path); } Ok(LoadedConfig { - path: home_config, + path: selected_path, config: Config::default(), }) } +pub fn preferred_config_path(current_dir: &Path, home: &Path) -> PathBuf { + let project_config = current_dir.join(PROJECT_CONFIG_FILE); + if project_config.exists() { + return project_config; + } + + home.join(CONFIG_DIR).join(CONFIG_FILE) +} + pub fn generate() -> Result { generate_at(&home_config_path()?) } @@ -198,8 +229,7 @@ pub fn validate(config: &Config) -> Result<()> { } pub fn home_config_path() -> Result { - let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?; - Ok(PathBuf::from(home).join(CONFIG_DIR).join(CONFIG_FILE)) + Ok(platform::home_dir()?.join(CONFIG_DIR).join(CONFIG_FILE)) } fn load_path(path: &Path) -> Result { diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..af4a2cb --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,680 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use serde::{Deserialize, Serialize}; + +use crate::compatibility::{self, CoreCompatibilityStatus}; +use crate::config::{self, AgentConfig, Config, LoadedConfig}; +use crate::platform::{self, HomeResolution, PlatformInfo}; + +pub const DISCOVERY_SCHEMA: &str = "agentctl.discovery.v1"; +pub const LDGR_CONFIG_TOML: &str = ".ldgr/config.toml"; +pub const LDGR_CONFIG_JSON: &str = ".ldgr/config.json"; + +#[derive(Debug, Clone, Default, Deserialize)] +struct LdgrHarnessDocument { + #[serde(default)] + schema_version: Option, + #[serde(default)] + default_harness: Option, + #[serde(default)] + selected_harnesses: Vec, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct LdgrSelection { + pub path: Option, + pub format: Option, + pub schema_version: Option, + pub default_harness: Option, + pub selected_harnesses: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HarnessStatus { + pub name: String, + pub selected: bool, + pub default: bool, + pub command_candidates: Vec, + pub executable: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AgentStatus { + pub name: String, + pub command: Vec, + pub available: bool, + pub executable: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DiscoveryReport { + pub schema: String, + pub platform: PlatformInfo, + pub home: HomeResolution, + pub agentctl_config: PathBuf, + pub ldgr: LdgrSelection, + pub core_compatibility: CoreCompatibilityStatus, + pub harnesses: Vec, + pub agents: Vec, + pub recommended_agent: Option, + pub warnings: Vec, + pub options: Vec, +} + +#[derive(Debug, Clone)] +pub struct ResolvedAgent { + pub name: String, + pub agent: AgentConfig, + pub warnings: Vec, +} + +pub fn diagnose() -> Result { + let platform = platform::detect(); + let home = platform::home_resolution()?; + let current_dir = std::env::current_dir().context("determining current directory")?; + let mut warnings = Vec::new(); + let loaded = match config::check() { + Ok(loaded) => loaded, + Err(error) => { + warnings.push(format!( + "Agentctl config could not be loaded: {error:#}. Built-in profiles are shown for recovery only." + )); + LoadedConfig { + path: config::preferred_config_path(¤t_dir, &home.path), + config: Config::default(), + } + } + }; + let ldgr = load_ldgr_selection(&home.path); + let core_compatibility = compatibility::status(¤t_dir); + warnings.extend(ldgr.warnings.iter().cloned()); + let harnesses = harness_statuses(&ldgr); + let agents = agent_statuses(&loaded.config); + let recommended_agent = resolve_agent_with(&loaded, None, true, &ldgr, command_lookup) + .ok() + .map(|resolved| resolved.name); + let options = recovery_options(&agents, &harnesses); + + Ok(DiscoveryReport { + schema: DISCOVERY_SCHEMA.to_owned(), + platform, + home, + agentctl_config: loaded.path, + ldgr, + core_compatibility, + harnesses, + agents, + recommended_agent, + warnings, + options, + }) +} + +pub fn resolve_agent( + loaded: &LoadedConfig, + requested: Option<&str>, + allow_fallback: bool, +) -> Result { + let home = platform::home_dir()?; + let ldgr = load_ldgr_selection(&home); + resolve_agent_with(loaded, requested, allow_fallback, &ldgr, command_lookup) +} + +pub fn load_ldgr_selection(home: &Path) -> LdgrSelection { + let toml_path = home.join(LDGR_CONFIG_TOML); + let json_path = home.join(LDGR_CONFIG_JSON); + let mut warnings = Vec::new(); + + if toml_path.is_file() { + match read_ldgr_toml(&toml_path) { + Ok(document) => { + return selection_from_document(toml_path, "toml", document, warnings); + } + Err(error) => warnings.push(format!( + "Could not parse {}: {error:#}.", + toml_path.display() + )), + } + } + + if json_path.is_file() { + match read_ldgr_json(&json_path) { + Ok(document) => { + if toml_path.is_file() { + warnings.push(format!( + "Using legacy {} because the preferred TOML config is invalid.", + json_path.display() + )); + } else { + warnings.push(format!( + "Using legacy {}; rerun `ldgr install` to create {}.", + json_path.display(), + toml_path.display() + )); + } + return selection_from_document(json_path, "json", document, warnings); + } + Err(error) => warnings.push(format!( + "Could not parse {}: {error:#}.", + json_path.display() + )), + } + } + + if !toml_path.is_file() && !json_path.is_file() { + warnings.push(format!( + "No LDGR harness selection found. Run `ldgr install` to create {}.", + toml_path.display() + )); + } + LdgrSelection { + warnings, + ..LdgrSelection::default() + } +} + +pub fn print_human(report: &DiscoveryReport) { + println!("Platform"); + println!( + "- {} {} ({}) runtime={} ci={} container={}", + report.platform.os, + report.platform.arch, + report.platform.family, + report.platform.runtime, + report.platform.ci, + report.platform.container + ); + println!( + "- home: {} (source={})", + report.home.path.display(), + report.home.source + ); + println!("- agentctl config: {}", report.agentctl_config.display()); + match &report.ldgr.path { + Some(path) => println!( + "- LDGR selection: {} ({})", + path.display(), + report.ldgr.format.as_deref().unwrap_or("unknown") + ), + None => println!("- LDGR selection: "), + } + println!( + "- Core compatibility: {} (Core={}, agentctl={}, required={})", + if report.core_compatibility.compatible { + "compatible" + } else { + "incompatible" + }, + report + .core_compatibility + .core_version + .as_deref() + .unwrap_or(""), + report.core_compatibility.agentctl_version, + report.core_compatibility.required_core, + ); + + println!("Harnesses"); + for harness in &report.harnesses { + let marker = if harness.default { + "default" + } else if harness.selected { + "selected" + } else { + "not selected" + }; + let executable = harness + .executable + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_owned()); + println!("- {}: {} ({marker})", harness.name, executable); + } + + println!("Configured agents"); + for agent in &report.agents { + let executable = agent + .executable + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_owned()); + println!("- {}: {}", agent.name, executable); + } + println!( + "Recommended agent: {}", + report.recommended_agent.as_deref().unwrap_or("") + ); + + if !report.warnings.is_empty() { + println!("Warnings"); + for warning in &report.warnings { + println!("- {warning}"); + } + } + println!("Recovery options"); + for option in &report.options { + println!("- {option}"); + } +} + +fn read_ldgr_toml(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + toml::from_str(&text).with_context(|| format!("parsing {}", path.display())) +} + +fn read_ldgr_json(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) +} + +fn selection_from_document( + path: PathBuf, + format: &str, + document: LdgrHarnessDocument, + mut warnings: Vec, +) -> LdgrSelection { + let mut selected = Vec::new(); + for harness in document + .selected_harnesses + .into_iter() + .map(|value| normalize_harness(&value)) + { + if !harness.is_empty() && !selected.contains(&harness) { + selected.push(harness); + } + } + let default_harness = document + .default_harness + .map(|value| normalize_harness(&value)) + .filter(|value| !value.is_empty()); + if let Some(default) = &default_harness + && !selected.contains(default) + { + warnings.push(format!( + "LDGR default harness `{default}` is not present in selected_harnesses." + )); + selected.insert(0, default.clone()); + } + LdgrSelection { + path: Some(path), + format: Some(format.to_owned()), + schema_version: document.schema_version, + default_harness, + selected_harnesses: selected, + warnings, + } +} + +fn normalize_harness(value: &str) -> String { + match value.trim().to_ascii_lowercase().as_str() { + "claude-code" | "claude_code" => "claude".to_owned(), + "open-claw" | "open_claw" | "opencode" => "openclaw".to_owned(), + value => value.to_owned(), + } +} + +fn harness_specs() -> [(&'static str, &'static [&'static str]); 4] { + [ + ("pi", &["pi"]), + ("codex", &["codex"]), + ("claude", &["claude"]), + ("openclaw", &["openclaw", "opencode"]), + ] +} + +fn harness_statuses(selection: &LdgrSelection) -> Vec { + harness_specs() + .into_iter() + .map(|(name, commands)| HarnessStatus { + name: name.to_owned(), + selected: selection + .selected_harnesses + .iter() + .any(|value| value == name), + default: selection.default_harness.as_deref() == Some(name), + command_candidates: commands.iter().map(|value| (*value).to_owned()).collect(), + executable: commands + .iter() + .find_map(|command| platform::command_path(command)), + }) + .collect() +} + +fn agent_statuses(config: &Config) -> Vec { + config + .agents + .iter() + .map(|(name, agent)| { + let executable = agent + .command + .first() + .and_then(|command| platform::command_path(command)); + AgentStatus { + name: name.clone(), + command: agent.command.clone(), + available: executable.is_some(), + executable, + } + }) + .collect() +} + +fn resolve_agent_with( + loaded: &LoadedConfig, + requested: Option<&str>, + allow_fallback: bool, + selection: &LdgrSelection, + lookup: F, +) -> Result +where + F: Fn(&str) -> Option, +{ + let mut warnings = selection.warnings.clone(); + let requested = requested.map(str::trim).filter(|value| !value.is_empty()); + let preferred = requested.unwrap_or_else(|| { + if selection.path.is_some() { + "ldgr-loop" + } else { + "codex" + } + }); + + if let Some(agent) = loaded.config.agents.get(preferred) { + if agent_available(agent, &lookup) { + return Ok(ResolvedAgent { + name: preferred.to_owned(), + agent: agent.clone(), + warnings, + }); + } + warnings.push(unavailable_agent_warning(preferred, agent)); + } else if requested.is_some() && !is_ldgr_generic(preferred) { + return Err(resolution_error( + format!("unknown agent `{preferred}` in {}", loaded.path.display()), + loaded, + selection, + &lookup, + )); + } + + let may_fallback = allow_fallback && (requested.is_none() || is_ldgr_generic(preferred)); + if may_fallback { + for harness in ordered_harnesses(selection) { + for profile in profile_candidates(preferred, &harness) { + let Some(agent) = loaded.config.agents.get(&profile) else { + continue; + }; + if agent_available(agent, &lookup) { + if profile != preferred { + warnings.push(format!( + "Using available selected harness `{harness}` through agent profile `{profile}`." + )); + } + return Ok(ResolvedAgent { + name: profile, + agent: agent.clone(), + warnings, + }); + } + } + } + + if requested.is_none() { + for (name, agent) in &loaded.config.agents { + if agent_available(agent, &lookup) { + warnings.push(format!( + "No selected LDGR harness was runnable; using available configured agent `{name}`." + )); + return Ok(ResolvedAgent { + name: name.clone(), + agent: agent.clone(), + warnings, + }); + } + } + } + } + + Err(resolution_error( + format!("agent `{preferred}` is not runnable"), + loaded, + selection, + &lookup, + )) +} + +fn agent_available(agent: &AgentConfig, lookup: &F) -> bool +where + F: Fn(&str) -> Option, +{ + agent + .command + .first() + .and_then(|command| lookup(command)) + .is_some() +} + +fn unavailable_agent_warning(name: &str, agent: &AgentConfig) -> String { + let executable = agent + .command + .first() + .map(String::as_str) + .unwrap_or(""); + format!("Configured agent `{name}` requires `{executable}`, which was not found.") +} + +fn is_ldgr_generic(name: &str) -> bool { + matches!(name, "ldgr-loop" | "ldgr-summary") +} + +fn ordered_harnesses(selection: &LdgrSelection) -> Vec { + let mut harnesses = Vec::new(); + if let Some(default) = &selection.default_harness { + harnesses.push(default.clone()); + } + for harness in &selection.selected_harnesses { + if !harnesses.contains(harness) { + harnesses.push(harness.clone()); + } + } + harnesses +} + +fn profile_candidates(generic: &str, harness: &str) -> Vec { + let mut profiles = Vec::new(); + if is_ldgr_generic(generic) { + profiles.push(format!("ldgr-loop-{harness}")); + } + match harness { + "pi" => profiles.push("pi".to_owned()), + "codex" => profiles.push("codex".to_owned()), + "claude" => { + profiles.push("claude-code".to_owned()); + profiles.push("claude".to_owned()); + } + "openclaw" => { + profiles.push("openclaw".to_owned()); + profiles.push("opencode".to_owned()); + } + other => profiles.push(other.to_owned()), + } + profiles +} + +fn resolution_error( + headline: String, + loaded: &LoadedConfig, + selection: &LdgrSelection, + lookup: &F, +) -> anyhow::Error +where + F: Fn(&str) -> Option, +{ + let available = loaded + .config + .agents + .iter() + .filter(|(_, agent)| agent_available(agent, lookup)) + .map(|(name, _)| name.clone()) + .collect::>(); + let selected = if selection.selected_harnesses.is_empty() { + "".to_owned() + } else { + selection.selected_harnesses.join(", ") + }; + let available = if available.is_empty() { + "".to_owned() + } else { + available.join(", ") + }; + anyhow!( + "{headline}\n\ + detected platform: {} {} ({})\n\ + agentctl config: {}\n\ + LDGR selected harnesses: {selected}\n\ + runnable configured agents: {available}\n\ + recovery options:\n\ + - run `agentctl discover` for complete diagnostics\n\ + - run `ldgr install --harness ` to repair the LDGR selection\n\ + - fix PATH or the command in {}\n\ + - pass an available agent explicitly: `agentctl run ...`", + std::env::consts::OS, + std::env::consts::ARCH, + std::env::consts::FAMILY, + loaded.path.display(), + loaded.path.display(), + ) +} + +fn command_lookup(command: &str) -> Option { + platform::command_path(command) +} + +fn recovery_options(agents: &[AgentStatus], harnesses: &[HarnessStatus]) -> Vec { + let mut options = BTreeSet::new(); + options.insert("Run `agentctl config check` to validate Agentctl configuration.".to_owned()); + options.insert( + "Run `ldgr install --harness ` to update the preferred harnesses.".to_owned(), + ); + options.insert( + "Repair PATH or use an absolute executable path in the selected agent command.".to_owned(), + ); + for agent in agents.iter().filter(|agent| agent.available) { + options.insert(format!( + "Use configured agent `{}` with `agentctl run {} ...`.", + agent.name, agent.name + )); + } + let available_harnesses = harnesses + .iter() + .filter(|harness| harness.executable.is_some()) + .map(|harness| harness.name.clone()) + .collect::>(); + if !available_harnesses.is_empty() { + options.insert(format!( + "Available harness executables: {}.", + available_harnesses.join(", ") + )); + } + options.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn loaded(agents: &[(&str, &str)]) -> LoadedConfig { + let agents = agents + .iter() + .map(|(name, command)| { + ( + (*name).to_owned(), + AgentConfig { + command: vec![(*command).to_owned()], + prompt_stdin: true, + cwd: None, + }, + ) + }) + .collect::>(); + LoadedConfig { + path: PathBuf::from("config.toml"), + config: Config { + agents, + ..Config::default() + }, + } + } + + fn lookup(command: &str) -> Option { + (command == "codex").then(|| PathBuf::from("/bin/codex")) + } + + #[test] + fn invalid_toml_falls_back_to_legacy_json() { + let home = tempfile::tempdir().expect("home"); + fs::create_dir_all(home.path().join(".ldgr")).expect("ldgr dir"); + fs::write(home.path().join(LDGR_CONFIG_TOML), "not = [valid").expect("toml"); + fs::write( + home.path().join(LDGR_CONFIG_JSON), + r#"{"schema_version":1,"default_harness":"codex","selected_harnesses":["codex"]}"#, + ) + .expect("json"); + + let selection = load_ldgr_selection(home.path()); + + assert_eq!(selection.format.as_deref(), Some("json")); + assert_eq!(selection.default_harness.as_deref(), Some("codex")); + assert_eq!(selection.warnings.len(), 2); + } + + #[test] + fn unavailable_primary_ldgr_agent_uses_selected_available_fallback() { + let loaded = loaded(&[ + ("ldgr-loop", "pi"), + ("ldgr-loop-pi", "pi"), + ("ldgr-loop-codex", "codex"), + ]); + let selection = LdgrSelection { + default_harness: Some("pi".to_owned()), + selected_harnesses: vec!["pi".to_owned(), "codex".to_owned()], + ..LdgrSelection::default() + }; + + let resolved = resolve_agent_with(&loaded, Some("ldgr-loop"), true, &selection, lookup) + .expect("fallback"); + + assert_eq!(resolved.name, "ldgr-loop-codex"); + assert!( + resolved + .warnings + .iter() + .any(|warning| warning.contains("pi")) + ); + } + + #[test] + fn explicit_non_ldgr_agent_never_silently_falls_back() { + let loaded = loaded(&[("claude", "claude"), ("codex", "codex")]); + let selection = LdgrSelection { + default_harness: Some("codex".to_owned()), + selected_harnesses: vec!["codex".to_owned()], + ..LdgrSelection::default() + }; + + let error = resolve_agent_with(&loaded, Some("claude"), true, &selection, lookup) + .expect_err("explicit unavailable agent"); + + assert!(error.to_string().contains("not runnable")); + assert!( + error + .to_string() + .contains("runnable configured agents: codex") + ); + } +} diff --git a/src/exec.rs b/src/exec.rs index a5b5426..5389b9b 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use crate::config::CONFIG_DIR; +use crate::platform; use crate::process::ManagedChild; #[derive(Debug, Clone)] pub struct CaptureOptions { @@ -65,6 +66,7 @@ pub(crate) fn run_captured_with( if process_options.stdin.is_some() { command.stdin(Stdio::piped()); } + platform::configure_child_home(&mut command)?; let mut child = ManagedChild::spawn(&mut command) .with_context(|| format!("executing `{}`", format_command(argv)))?; @@ -261,8 +263,7 @@ fn create_raw_log_path(label: Option<&str>) -> Result { } fn home_jobs_dir() -> Result { - let home = std::env::var_os("HOME").context("HOME is not set")?; - Ok(PathBuf::from(home).join(CONFIG_DIR).join("jobs")) + Ok(platform::home_dir()?.join(CONFIG_DIR).join("jobs")) } fn job_id(label: Option<&str>) -> String { diff --git a/src/jobs.rs b/src/jobs.rs index 938d034..92b9c93 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -5,11 +5,13 @@ use std::process::{Command as ProcessCommand, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; +use crate::capture::{ExecutionAttempt, FailureKind}; use crate::config::{AgentConfig, CONFIG_DIR, SummaryConfig}; use crate::exec::{CaptureOptions, CapturedRun, ProcessOptions, format_command, run_captured_with}; +use crate::platform; use crate::process::spawn_detached; use crate::summary::{Summary, summarize, summarize_with_command}; @@ -20,6 +22,7 @@ pub struct AgentRunOptions { pub prompt: String, pub cwd: PathBuf, pub iterations: u32, + pub attempt: ExecutionAttempt, } #[derive(Debug, Clone, Serialize)] @@ -70,6 +73,8 @@ struct StoredJobRecord { iterations: u32, log_path: PathBuf, exit_code_path: PathBuf, + #[serde(default)] + attempt: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -81,6 +86,7 @@ struct SupervisorSpec { iterations: u32, exit_code_path: PathBuf, label: String, + attempt: ExecutionAttempt, } #[derive(Debug, Clone, Default)] @@ -96,7 +102,13 @@ pub fn run_foreground( validate_options(options)?; let mut reports = Vec::new(); for iteration in 1..=options.iterations { - let run = run_agent_once(options, summary_config, iteration)?; + let run = match run_agent_once(options, summary_config, iteration) { + Ok(run) => run, + Err(error) => { + options.attempt.record_error(FailureKind::Spawn)?; + return Err(error); + } + }; let summary = match &summary_config.summarizer_command { Some(command) => summarize_with_command(&run, summary_config, command) .unwrap_or_else(|_| summarize(&run, summary_config)), @@ -109,12 +121,16 @@ pub fn run_foreground( summary, }); if exit_code != 0 { + options + .attempt + .record_error(failure_from_status(run.exit_status))?; bail!( "agent iteration {iteration}/{} exited with code {exit_code}", options.iterations ); } } + options.attempt.complete(); Ok(reports) } @@ -143,6 +159,7 @@ pub fn launch_detached(options: &AgentRunOptions) -> Result { iterations: options.iterations, exit_code_path: exit_code_path.clone(), label: options.agent_name.clone(), + attempt: options.attempt.clone(), }; fs::write(&supervisor_path, toml::to_string_pretty(&supervisor)?) .with_context(|| format!("writing {}", supervisor_path.display()))?; @@ -154,9 +171,16 @@ pub fn launch_detached(options: &AgentRunOptions) -> Result { command.stdin(Stdio::null()); command.stdout(Stdio::from(stdout)); command.stderr(Stdio::from(stderr)); - - let child = spawn_detached(&mut command) - .with_context(|| format!("launching detached agent `{}`", format_command(&argv)))?; + platform::configure_child_home(&mut command)?; + + let child = match spawn_detached(&mut command) { + Ok(child) => child, + Err(error) => { + options.attempt.record_error(FailureKind::Spawn)?; + return Err(error) + .with_context(|| format!("launching detached agent `{}`", format_command(&argv))); + } + }; let stored = StoredJobRecord { id: job_id, @@ -168,6 +192,7 @@ pub fn launch_detached(options: &AgentRunOptions) -> Result { iterations: options.iterations, log_path, exit_code_path, + attempt: Some(options.attempt.clone()), }; fs::write(&job_path, toml::to_string_pretty(&stored)?) .with_context(|| format!("writing {}", job_path.display()))?; @@ -208,17 +233,23 @@ pub fn supervise(path: &Path) -> Result { Ok(run) => { exit_code = exit_status_code(run.exit_status); if exit_code != 0 { + spec.attempt + .record_error(failure_from_status(run.exit_status))?; break; } } Err(error) => { eprintln!("agent supervisor failed: {error:#}"); + spec.attempt.record_error(FailureKind::Spawn)?; exit_code = 1; break; } } } write_exit_record(&spec.exit_code_path, exit_code)?; + if exit_code == 0 { + spec.attempt.complete(); + } Ok(exit_code) } @@ -352,6 +383,11 @@ fn record_from_stored(stored: StoredJobRecord) -> Result { None => JobState::Stale, } }; + if state == JobState::Stale + && let Some(attempt) = &stored.attempt + { + attempt.record_error(FailureKind::UnexpectedDisappearance)?; + } Ok(JobRecord { id: stored.id, agent: stored.agent, @@ -388,8 +424,16 @@ fn read_exit_record(path: &Path) -> Result { fn write_exit_record(path: &Path, exit_code: i32) -> Result<()> { let finished_at = unix_timestamp()?; - fs::write(path, format!("{exit_code}\nfinished_at={finished_at}\n")) - .with_context(|| format!("writing {}", path.display())) + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + let mut file = fs::File::create(&temporary) + .with_context(|| format!("creating {}", temporary.display()))?; + file.write_all(format!("{exit_code}\nfinished_at={finished_at}\n").as_bytes()) + .with_context(|| format!("writing {}", temporary.display()))?; + file.sync_all() + .with_context(|| format!("syncing {}", temporary.display()))?; + drop(file); + fs::rename(&temporary, path) + .with_context(|| format!("atomically publishing terminal record {}", path.display())) } fn exit_status_code(status: ExitStatus) -> i32 { @@ -409,6 +453,17 @@ fn exit_status_code(status: ExitStatus) -> i32 { } } +fn failure_from_status(status: ExitStatus) -> FailureKind { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return FailureKind::Signal(signal); + } + } + FailureKind::ExitCode(status.code().unwrap_or(1)) +} + fn read_job(job_id: &str) -> Result { read_job_path(&jobs_dir()?.join(job_id).join("job.toml")) } @@ -431,8 +486,7 @@ fn read_tail_lines(path: &Path, tail: usize) -> Result> { } fn jobs_dir() -> Result { - let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?; - Ok(PathBuf::from(home).join(CONFIG_DIR).join("jobs")) + Ok(platform::home_dir()?.join(CONFIG_DIR).join("jobs")) } fn job_id(started_at: u64, agent_name: &str) -> String { @@ -464,18 +518,40 @@ fn unix_timestamp() -> Result { .as_secs()) } +#[cfg(unix)] +fn process_is_running(pid: u32) -> bool { + if pid == 0 { + return false; + } + let result = unsafe { libc::kill(pid as i32, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] fn process_is_running(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + if pid == 0 { return false; } - ProcessCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return false; + } + let mut exit_code = 0; + let success = unsafe { GetExitCodeProcess(handle, &mut exit_code) } != 0; + unsafe { + CloseHandle(handle); + } + success && exit_code == STILL_ACTIVE as u32 +} + +#[cfg(not(any(unix, windows)))] +fn process_is_running(_pid: u32) -> bool { + false } #[cfg(test)] @@ -495,10 +571,42 @@ mod tests { iterations: 1, log_path: temp_dir.path().join("output.log"), exit_code_path: temp_dir.path().join("exit-code"), + attempt: None, + }; + + let record = record_from_stored(stored).expect("record"); + + assert_eq!(record.state, JobState::Stale); + } + + #[test] + fn stale_supervisor_records_unexpected_disappearance() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let attempt = + ExecutionAttempt::begin(temp_dir.path(), "test-agent").expect("execution intent"); + let stored = StoredJobRecord { + id: "job".to_owned(), + agent: "test".to_owned(), + pid: 0, + started_at: 1, + command: "redacted".to_owned(), + cwd: temp_dir.path().to_path_buf(), + iterations: 1, + log_path: temp_dir.path().join("output.log"), + exit_code_path: temp_dir.path().join("exit-code"), + attempt: Some(attempt), }; let record = record_from_stored(stored).expect("record"); assert_eq!(record.state, JobState::Stale); + let inbox = temp_dir.path().join(".ldgr/recovery/inbox"); + let recovery = fs::read_dir(inbox) + .expect("recovery inbox") + .filter_map(|entry| entry.ok()) + .map(|entry| fs::read_to_string(entry.path()).expect("recovery record")) + .find(|payload| payload.contains("unexpected-disappearance")) + .expect("unexpected disappearance record"); + assert!(!recovery.contains("redacted")); } } diff --git a/src/lib.rs b/src/lib.rs index 0d0c698..a838bde 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,9 +4,13 @@ //! path and parsing context; typed errors can be introduced when callers need //! to match on stable failure categories. +pub mod capture; +pub mod compatibility; pub mod config; +pub mod discovery; pub mod exec; pub mod jobs; pub mod output; +pub mod platform; mod process; pub mod summary; diff --git a/src/main.rs b/src/main.rs index f6749c8..09dc781 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use clap::{Parser, Subcommand}; #[derive(Debug, Parser)] #[command(name = "agentctl")] #[command(about = "Compact, bounded command and agent reports for LLM tool loops")] +#[command(version)] struct Cli { #[command(subcommand)] command: Command, @@ -25,8 +26,7 @@ enum Command { argv: Vec, }, Run { - #[arg(default_value = "codex")] - agent: String, + agent: Option, #[arg(long, conflicts_with = "prompt_file")] prompt: Option, #[arg(long)] @@ -39,6 +39,9 @@ enum Command { cwd: Option, #[arg(long)] json: bool, + /// Do not use another LDGR-selected harness when the primary is unavailable. + #[arg(long)] + no_fallback: bool, }, Status { job_id: String, @@ -62,6 +65,11 @@ enum Command { #[command(subcommand)] command: ConfigCommand, }, + /// Detect the host platform, LDGR selection, and runnable harnesses. + Discover { + #[arg(long)] + json: bool, + }, } #[derive(Debug, Subcommand)] @@ -72,6 +80,7 @@ enum ConfigCommand { } fn main() -> Result<()> { + agentctl::platform::repair_process_home()?; let cli = Cli::parse(); match cli.command { @@ -121,20 +130,64 @@ fn main() -> Result<()> { iterations, cwd, json, + no_fallback, } => { - let loaded = agentctl::config::check()?; - let agent_config = - loaded.config.agents.get(&agent).cloned().with_context(|| { - format!("unknown agent `{agent}` in {}", loaded.path.display()) + let attempt = agentctl::capture::ExecutionAttempt::begin( + Path::new("."), + agent.as_deref().unwrap_or("auto"), + )?; + let initialized = (|| -> Result<_> { + let loaded = agentctl::config::check().with_context(|| { + "Agentctl configuration is invalid; run `agentctl discover` for recovery options" })?; - let prompt = read_prompt(prompt.as_deref(), prompt_file.as_deref())?; - let cwd = resolve_cwd(agent_config.cwd.as_deref(), cwd.as_deref())?; + let resolved = + agentctl::discovery::resolve_agent(&loaded, agent.as_deref(), !no_fallback)?; + if resolved.name.starts_with("ldgr-loop") || resolved.name == "ldgr-summary" { + agentctl::compatibility::negotiate(Path::new(".")).map_err(|error| { + anyhow::Error::new(error).context( + "LDGR launcher/Core compatibility negotiation failed before worker startup", + ) + })?; + } + let prompt = read_prompt(prompt.as_deref(), prompt_file.as_deref())?; + let cwd = resolve_cwd(resolved.agent.cwd.as_deref(), cwd.as_deref())?; + Ok((loaded, resolved, prompt, cwd)) + })(); + let (loaded, resolved, prompt, cwd) = match initialized { + Ok(initialized) => initialized, + Err(error) => { + let rendered = format!("{error:#}"); + let failure = if rendered.contains("compatibility negotiation") + || rendered.contains("requires LDGR Core") + { + let detected_core_version = + agentctl::compatibility::status(Path::new(".")).core_version; + agentctl::capture::FailureKind::CoreIncompatible { + detected_core_version, + } + } else if rendered.to_ascii_lowercase().contains("home") + || format!("{error:#}").contains("current directory") + { + agentctl::capture::FailureKind::Environment + } else { + agentctl::capture::FailureKind::Configuration + }; + attempt + .record_error(failure) + .context("persisting accepted agent bootstrap failure")?; + return Err(error); + } + }; + for warning in &resolved.warnings { + eprintln!("warning: {warning}"); + } let options = agentctl::jobs::AgentRunOptions { - agent_name: agent, - agent: agent_config, + agent_name: resolved.name, + agent: resolved.agent, prompt, cwd, iterations, + attempt, }; if detach { let record = agentctl::jobs::launch_detached(&options)?; @@ -207,6 +260,14 @@ fn main() -> Result<()> { print!("{}", agentctl::config::show()?); } }, + Command::Discover { json } => { + let report = agentctl::discovery::diagnose()?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + agentctl::discovery::print_human(&report); + } + } } Ok(()) diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..700bbd5 --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,333 @@ +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Result, anyhow}; +use serde::Serialize; + +#[cfg(unix)] +use std::ffi::CStr; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PlatformInfo { + pub os: String, + pub family: String, + pub arch: String, + pub runtime: String, + pub ci: bool, + pub container: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct HomeResolution { + pub path: PathBuf, + pub source: String, + pub synthesized_home: bool, +} + +pub fn detect() -> PlatformInfo { + let runtime = if cfg!(target_os = "linux") + && (env::var_os("WSL_DISTRO_NAME").is_some() + || env::var_os("WSL_INTEROP").is_some() + || fs::read_to_string("/proc/version") + .map(|value| value.to_ascii_lowercase().contains("microsoft")) + .unwrap_or(false)) + { + "wsl" + } else { + "native" + }; + let ci = env::var("CI") + .map(|value| { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" + ) + }) + .unwrap_or(false); + let container = env::var_os("container").is_some() + || env::var_os("CONTAINER").is_some() + || Path::new("/.dockerenv").is_file(); + PlatformInfo { + os: env::consts::OS.to_owned(), + family: env::consts::FAMILY.to_owned(), + arch: env::consts::ARCH.to_owned(), + runtime: runtime.to_owned(), + ci, + container, + } +} + +pub fn home_dir() -> Result { + Ok(home_resolution()?.path) +} + +/// Repair the process environment before configuration discovery. Native +/// Windows shells commonly provide USERPROFILE but not HOME. +pub fn repair_process_home() -> Result<()> { + #[cfg(windows)] + if let Some(profile) = + nonempty(env::var_os("USERPROFILE")).filter(|_| nonempty(env::var_os("HOME")).is_none()) + { + // SAFETY: agentctl calls this once on the main thread before it starts + // any worker, reader, or signal-forwarding threads. + unsafe { + env::set_var("HOME", profile); + } + } + Ok(()) +} + +pub fn home_resolution() -> Result { + resolve_home_from( + |name| env::var_os(name), + if cfg!(unix) { + system_account_home() + } else { + None + }, + ) +} + +pub fn configure_child_home(command: &mut Command) -> Result<()> { + if env::var_os("HOME").is_none() { + command.env("HOME", home_dir()?); + } + Ok(()) +} + +pub fn command_path(command: &str) -> Option { + find_command( + OsStr::new(command), + env::var_os("PATH").as_deref(), + env::var_os("PATHEXT").as_deref(), + cfg!(windows), + ) +} + +pub fn command_available(command: &str) -> bool { + command_path(command).is_some() +} + +fn resolve_home_from(get: F, system_home: Option) -> Result +where + F: Fn(&str) -> Option, +{ + for (name, synthesized_home) in [("AGENTCTL_HOME", true), ("HOME", false)] { + if let Some(value) = nonempty(get(name)) { + return Ok(HomeResolution { + path: PathBuf::from(value), + source: name.to_owned(), + synthesized_home, + }); + } + } + + if let Some(path) = system_home { + return Ok(HomeResolution { + path, + source: "system-account".to_owned(), + synthesized_home: true, + }); + } + + if let Some(value) = nonempty(get("USERPROFILE")) { + return Ok(HomeResolution { + path: PathBuf::from(value), + source: "USERPROFILE".to_owned(), + synthesized_home: true, + }); + } + + if let (Some(drive), Some(path)) = (nonempty(get("HOMEDRIVE")), nonempty(get("HOMEPATH"))) { + let mut combined = drive; + combined.push(path); + return Ok(HomeResolution { + path: PathBuf::from(combined), + source: "HOMEDRIVE+HOMEPATH".to_owned(), + synthesized_home: true, + }); + } + + Err(anyhow!( + "cannot determine the user home directory; set HOME, USERPROFILE, or AGENTCTL_HOME" + )) +} + +fn nonempty(value: Option) -> Option { + value.filter(|value| !value.is_empty()) +} + +#[cfg(unix)] +fn system_account_home() -> Option { + let uid = unsafe { libc::geteuid() }; + let suggested = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; + let buffer_len = if suggested > 0 { + suggested as usize + } else { + 16 * 1024 + }; + let mut buffer = vec![0_u8; buffer_len]; + let mut password: libc::passwd = unsafe { std::mem::zeroed() }; + let mut result = std::ptr::null_mut(); + let status = unsafe { + libc::getpwuid_r( + uid, + &mut password, + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + if status != 0 || result.is_null() || password.pw_dir.is_null() { + return None; + } + let home = unsafe { CStr::from_ptr(password.pw_dir) }; + let bytes = home.to_bytes(); + if bytes.is_empty() { + return None; + } + use std::os::unix::ffi::OsStrExt; + Some(PathBuf::from(OsStr::from_bytes(bytes))) +} + +#[cfg(not(unix))] +fn system_account_home() -> Option { + None +} + +fn find_command( + command: &OsStr, + path: Option<&OsStr>, + path_ext: Option<&OsStr>, + windows: bool, +) -> Option { + let candidate = Path::new(command); + if has_path_component(candidate) { + return executable_candidate(candidate, path_ext, windows); + } + + let path = path?; + for directory in env::split_paths(path) { + if let Some(found) = executable_candidate(&directory.join(candidate), path_ext, windows) { + return Some(found); + } + } + None +} + +fn has_path_component(path: &Path) -> bool { + path.components().count() > 1 || path.is_absolute() +} + +fn executable_candidate( + candidate: &Path, + path_ext: Option<&OsStr>, + windows: bool, +) -> Option { + if is_executable_file(candidate, windows) { + return Some(candidate.to_path_buf()); + } + if !windows || candidate.extension().is_some() { + return None; + } + + let extensions = path_ext + .and_then(OsStr::to_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(".COM;.EXE;.BAT;.CMD"); + for extension in extensions.split(';').filter(|value| !value.is_empty()) { + let extension = extension.trim_start_matches('.'); + let with_extension = candidate.with_extension(extension); + if is_executable_file(&with_extension, windows) { + return Some(with_extension); + } + let lower = candidate.with_extension(extension.to_ascii_lowercase()); + if is_executable_file(&lower, windows) { + return Some(lower); + } + } + None +} + +fn is_executable_file(path: &Path, windows: bool) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + if windows { + return true; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn home_falls_back_to_userprofile_when_home_is_missing() { + let variables = + BTreeMap::from([("USERPROFILE".to_owned(), OsString::from(r"C:\Users\agent"))]); + let home = resolve_home_from(|name| variables.get(name).cloned(), None).expect("home"); + assert_eq!(home.path, PathBuf::from(r"C:\Users\agent")); + assert_eq!(home.source, "USERPROFILE"); + assert!(home.synthesized_home); + } + + #[test] + fn explicit_agentctl_home_has_highest_precedence() { + let variables = BTreeMap::from([ + ("AGENTCTL_HOME".to_owned(), OsString::from("/agentctl")), + ("HOME".to_owned(), OsString::from("/home")), + ]); + let home = resolve_home_from(|name| variables.get(name).cloned(), None).expect("home"); + assert_eq!(home.path, PathBuf::from("/agentctl")); + assert_eq!(home.source, "AGENTCTL_HOME"); + } + + #[test] + fn system_account_home_precedes_windows_style_fallbacks() { + let variables = + BTreeMap::from([("USERPROFILE".to_owned(), OsString::from(r"C:\Users\agent"))]); + let home = resolve_home_from( + |name| variables.get(name).cloned(), + Some(PathBuf::from("/srv/agent")), + ) + .expect("home"); + assert_eq!(home.path, PathBuf::from("/srv/agent")); + assert_eq!(home.source, "system-account"); + } + + #[test] + fn windows_command_search_uses_pathext() { + let directory = tempfile::tempdir().expect("tempdir"); + let executable = directory.path().join("codex.CMD"); + fs::write(&executable, "@echo off").expect("write fixture"); + let path = env::join_paths([directory.path()]).expect("PATH"); + + let found = find_command( + OsStr::new("codex"), + Some(path.as_os_str()), + Some(OsStr::new(".EXE;.CMD")), + true, + ) + .expect("command"); + + assert_eq!(found, executable); + } +} diff --git a/src/process.rs b/src/process.rs index 7619dc2..b508014 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,6 +1,5 @@ use std::io; use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus}; -use std::time::{Duration, Instant}; #[cfg(unix)] use std::sync::OnceLock; @@ -13,7 +12,24 @@ use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; use signal_hook::iterator::Signals; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(windows)] +use std::os::windows::io::AsRawHandle; +#[cfg(windows)] +use std::os::windows::process::CommandExt; +#[cfg(unix)] +use std::time::{Duration, Instant}; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +#[cfg(windows)] +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS}; +#[cfg(unix)] const TERMINATION_GRACE: Duration = Duration::from_millis(750); #[cfg(unix)] @@ -29,12 +45,17 @@ pub(crate) struct ManagedChild { child: Child, #[cfg(unix)] process_group: i32, + #[cfg(windows)] + job: HANDLE, reaped: bool, } /// Spawn a detached supervisor as its own process-group leader. The supervisor /// is responsible for recording completion and terminating its group. pub(crate) fn spawn_detached(command: &mut Command) -> io::Result { + #[cfg(windows)] + command.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); + #[cfg(not(windows))] configure_process_group(command); command.spawn() } @@ -57,7 +78,25 @@ impl ManagedChild { }) } - #[cfg(not(unix))] + #[cfg(windows)] + { + let mut child = child; + let job = match create_kill_on_close_job(&child) { + Ok(job) => job, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + Ok(Self { + child, + job, + reaped: false, + }) + } + + #[cfg(not(any(unix, windows)))] Ok(Self { child, reaped: false, @@ -84,9 +123,17 @@ impl ManagedChild { Ok(status) } - fn clean_remaining_descendants(&self) { + fn clean_remaining_descendants(&mut self) { #[cfg(unix)] terminate_process_tree(self.process_group); + #[cfg(windows)] + if !self.job.is_null() { + unsafe { + TerminateJobObject(self.job, 1); + CloseHandle(self.job); + } + self.job = std::ptr::null_mut(); + } } fn clear_active_group(&self) { @@ -108,9 +155,8 @@ impl Drop for ManagedChild { return; } - #[cfg(unix)] - terminate_process_tree(self.process_group); - #[cfg(not(unix))] + self.clean_remaining_descendants(); + #[cfg(not(any(unix, windows)))] let _ = self.child.kill(); let _ = self.child.wait(); @@ -125,7 +171,48 @@ fn configure_process_group(command: &mut Command) { } #[cfg(not(unix))] -fn configure_process_group(_command: &mut Command) {} +fn configure_process_group(command: &mut Command) { + #[cfg(windows)] + command.creation_flags(CREATE_NEW_PROCESS_GROUP); + #[cfg(not(windows))] + let _ = command; +} + +#[cfg(windows)] +fn create_kill_on_close_job(child: &Child) -> io::Result { + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if job.is_null() { + return Err(io::Error::last_os_error()); + } + + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let configured = unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of::() as u32, + ) + }; + if configured == 0 { + let error = io::Error::last_os_error(); + unsafe { + CloseHandle(job); + } + return Err(error); + } + + let process = child.as_raw_handle() as HANDLE; + if unsafe { AssignProcessToJobObject(job, process) } == 0 { + let error = io::Error::last_os_error(); + unsafe { + CloseHandle(job); + } + return Err(error); + } + Ok(job) +} #[cfg(unix)] fn install_signal_forwarder() -> io::Result<()> { @@ -272,7 +359,7 @@ fn linux_child_pids(pid: i32) -> Vec { .collect() } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::process::Stdio; @@ -304,3 +391,54 @@ mod tests { assert!(!running, "descendant {pid} survived its agent command"); } } + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use std::process::Stdio; + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + #[test] + fn wait_terminates_descendants_in_the_windows_job_object() { + let temp = tempfile::tempdir().expect("tempdir"); + let pid_path = temp.path().join("descendant.pid"); + let escaped_path = pid_path.display().to_string().replace('\'', "''"); + let script = format!( + "$child = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep 30' -PassThru; Set-Content -NoNewline -LiteralPath '{escaped_path}' -Value $child.Id" + ); + let mut command = Command::new("powershell.exe"); + command + .args(["-NoProfile", "-Command", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = ManagedChild::spawn(&mut command).expect("spawn managed child"); + let status = child.wait().expect("wait managed child"); + assert!(status.success()); + + let pid = std::fs::read_to_string(pid_path) + .expect("descendant pid") + .parse::() + .expect("numeric pid"); + assert!( + !process_is_running(pid), + "descendant {pid} survived its Windows job object" + ); + } + + fn process_is_running(pid: u32) -> bool { + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return false; + } + let mut exit_code = 0; + let success = unsafe { GetExitCodeProcess(handle, &mut exit_code) } != 0; + unsafe { + CloseHandle(handle); + } + success && exit_code == STILL_ACTIVE as u32 + } +} diff --git a/tests/agent_jobs_cli.rs b/tests/agent_jobs_cli.rs index 8202082..f0d0889 100644 --- a/tests/agent_jobs_cli.rs +++ b/tests/agent_jobs_cli.rs @@ -4,7 +4,14 @@ use serde_json::Value; fn write_agent_config(project_dir: &tempfile::TempDir) { std::fs::write( project_dir.path().join("agentctl.toml"), - r#" + platform_agent_config(), + ) + .expect("write config"); +} + +#[cfg(unix)] +fn platform_agent_config() -> &'static str { + r#" [summary] max_output_bytes = 16384 tail_bytes = 4096 @@ -17,18 +24,30 @@ prompt_stdin = false [agents.stdin_loop] command = ["bash", "-c", "prompt=$(cat); echo prompt=$prompt; echo iteration=$AGENTCTL_AGENT_ITERATION"] prompt_stdin = true -"#, - ) - .expect("write config"); +"# } -#[test] -fn run_defaults_to_codex_and_reads_prompt_from_stdin() { - let home_dir = tempfile::tempdir().expect("home tempdir"); - let project_dir = tempfile::tempdir().expect("project tempdir"); - std::fs::write( - project_dir.path().join("agentctl.toml"), - r#" +#[cfg(windows)] +fn platform_agent_config() -> &'static str { + r#" +[summary] +max_output_bytes = 16384 +tail_bytes = 4096 +max_preview_lines = 12 + +[agents.trivial] +command = ["cmd", "/C", "echo done"] +prompt_stdin = false + +[agents.stdin_loop] +command = ["cmd", "/V:ON", "/C", "set /p prompt=& echo prompt=!prompt!& echo iteration=!AGENTCTL_AGENT_ITERATION!"] +prompt_stdin = true +"# +} + +#[cfg(unix)] +fn default_codex_test_config() -> &'static str { + r#" [summary] max_output_bytes = 16384 tail_bytes = 4096 @@ -37,7 +56,30 @@ max_preview_lines = 12 [agents.codex] command = ["bash", "-c", "prompt=$(cat); echo default-codex=$prompt"] prompt_stdin = true -"#, +"# +} + +#[cfg(windows)] +fn default_codex_test_config() -> &'static str { + r#" +[summary] +max_output_bytes = 16384 +tail_bytes = 4096 +max_preview_lines = 12 + +[agents.codex] +command = ["cmd", "/V:ON", "/C", "set /p prompt=& echo default-codex=!prompt!"] +prompt_stdin = true +"# +} + +#[test] +fn run_defaults_to_codex_and_reads_prompt_from_stdin() { + let home_dir = tempfile::tempdir().expect("home tempdir"); + let project_dir = tempfile::tempdir().expect("project tempdir"); + std::fs::write( + project_dir.path().join("agentctl.toml"), + default_codex_test_config(), ) .expect("write config"); @@ -281,9 +323,9 @@ iterations = 1 log_path = "{}" exit_code_path = "{}" "#, - project_dir.path().display(), - log_path.display(), - exit_code_path.display() + toml_path(project_dir.path()), + toml_path(&log_path), + toml_path(&exit_code_path) ), ) .expect("job record"); @@ -302,6 +344,10 @@ exit_code_path = "{}" assert_eq!(json["record"]["state"], "stale"); } +fn toml_path(path: &std::path::Path) -> String { + path.display().to_string().replace('\\', "\\\\") +} + #[test] fn foreground_agent_accepts_prompt_file_and_cwd_override() { let home_dir = tempfile::tempdir().expect("home tempdir"); diff --git a/tests/compatibility_cli.rs b/tests/compatibility_cli.rs new file mode 100644 index 0000000..dde781d --- /dev/null +++ b/tests/compatibility_cli.rs @@ -0,0 +1,122 @@ +use std::fs; +use std::path::Path; + +use assert_cmd::Command; +use serde_json::Value; + +#[test] +fn older_core_is_rejected_and_spooled_before_worker_startup() { + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + let bin = tempfile::tempdir().expect("bin"); + write_old_ldgr(bin.path()); + fs::write( + project.path().join("agentctl.toml"), + runnable_ldgr_profile(), + ) + .expect("agentctl config"); + + let path = prepend_path(bin.path()); + let output = Command::cargo_bin("agentctl") + .expect("agentctl") + .current_dir(project.path()) + .env("HOME", home.path()) + .env("PATH", path) + .args(["run", "ldgr-loop", "--prompt", "must not start"]) + .output() + .expect("run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr"); + assert!( + stderr.contains("requires LDGR Core >=0.1.13, <0.2.0"), + "{stderr}" + ); + assert!(stderr.contains("found ldgr 0.1.12"), "{stderr}"); + assert!( + stderr.contains("install a release bundle containing both binaries"), + "{stderr}" + ); + + let recovery = only_recovery(project.path()); + assert_eq!(recovery["error"]["domain"], "agentctl.compatibility"); + assert_eq!(recovery["error"]["code"], "core-incompatible"); + assert_eq!( + recovery["error"]["details"]["detected_core_version"], + "0.1.12" + ); +} + +fn only_recovery(project: &Path) -> Value { + let inbox = project.join(".ldgr/recovery/inbox"); + let records = fs::read_dir(inbox) + .expect("recovery inbox") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("core-incompatible.json")) + }) + .collect::>(); + assert_eq!(records.len(), 1, "{records:?}"); + serde_json::from_str(&fs::read_to_string(&records[0]).expect("recovery")) + .expect("recovery JSON") +} + +fn prepend_path(directory: &Path) -> std::ffi::OsString { + let mut paths = vec![directory.to_path_buf()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + std::env::join_paths(paths).expect("PATH") +} + +#[cfg(windows)] +fn write_old_ldgr(directory: &Path) { + fs::write( + directory.join("ldgr.cmd"), + "@echo off\r\nif \"%1\"==\"--version\" (echo ldgr 0.1.12& exit /b 0)\r\necho unknown compatibility command 1>&2\r\nexit /b 2\r\n", + ) + .expect("fake old ldgr"); +} + +#[cfg(unix)] +fn write_old_ldgr(directory: &Path) { + use std::os::unix::fs::PermissionsExt; + let path = directory.join("ldgr"); + fs::write( + &path, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then echo 'ldgr 0.1.12'; exit 0; fi\necho 'unknown compatibility command' >&2\nexit 2\n", + ) + .expect("fake old ldgr"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("permissions"); +} + +#[cfg(windows)] +fn runnable_ldgr_profile() -> &'static str { + r#" +[summary] +max_output_bytes = 1024 +tail_bytes = 256 +max_preview_lines = 4 + +[agents.ldgr-loop] +command = ["cmd", "/C", "echo worker-started"] +prompt_stdin = true +"# +} + +#[cfg(unix)] +fn runnable_ldgr_profile() -> &'static str { + r#" +[summary] +max_output_bytes = 1024 +tail_bytes = 256 +max_preview_lines = 4 + +[agents.ldgr-loop] +command = ["sh", "-c", "echo worker-started"] +prompt_stdin = true +"# +} diff --git a/tests/config_cli.rs b/tests/config_cli.rs index 544bdbb..6504f5d 100644 --- a/tests/config_cli.rs +++ b/tests/config_cli.rs @@ -63,7 +63,8 @@ fn config_check_uses_builtin_defaults_when_no_file_exists() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); - assert!(stdout.contains(".agentctl/config.toml")); + assert!(stdout.contains(".agentctl")); + assert!(stdout.contains("config.toml")); } #[test] @@ -76,11 +77,26 @@ fn top_level_help_lists_public_cli_surface() { assert!(output.status.success()); let help = String::from_utf8(output.stdout).expect("help utf8"); - for command in ["exec", "run", "status", "list", "logs", "config"] { + for command in [ + "exec", "run", "status", "list", "logs", "config", "discover", + ] { assert!(help.contains(command), "help should list {command}"); } } +#[test] +fn version_is_available_for_compatibility_checks() { + let output = Command::cargo_bin("agentctl") + .expect("agentctl binary") + .arg("--version") + .output() + .expect("version output"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("version utf8"); + assert!(stdout.starts_with("agentctl ")); +} + #[test] fn list_json_schema_is_empty_array_when_no_jobs_exist() { let home_dir = tempfile::tempdir().expect("home tempdir"); diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs new file mode 100644 index 0000000..ec79a0c --- /dev/null +++ b/tests/discovery_cli.rs @@ -0,0 +1,261 @@ +use std::fs; + +use assert_cmd::Command; +use serde_json::Value; + +#[test] +fn discover_reports_platform_ldgr_toml_and_available_agents() { + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + fs::create_dir_all(home.path().join(".ldgr")).expect("ldgr dir"); + fs::write( + home.path().join(".ldgr/config.toml"), + r#" +schema_version = 1 +default_harness = "codex" +selected_harnesses = ["codex", "claude"] +"#, + ) + .expect("ldgr config"); + fs::write( + project.path().join("agentctl.toml"), + platform_discovery_config(), + ) + .expect("agentctl config"); + + let output = Command::cargo_bin("agentctl") + .expect("agentctl") + .current_dir(project.path()) + .env("HOME", home.path()) + .args(["discover", "--json"]) + .output() + .expect("discover"); + + assert!(output.status.success(), "{output:?}"); + let report: Value = serde_json::from_slice(&output.stdout).expect("report"); + assert_eq!(report["schema"], "agentctl.discovery.v1"); + assert_eq!(report["platform"]["os"], std::env::consts::OS); + assert_eq!(report["platform"]["arch"], std::env::consts::ARCH); + assert_eq!(report["ldgr"]["format"], "toml"); + assert_eq!(report["ldgr"]["default_harness"], "codex"); + assert_eq!( + report["core_compatibility"]["agentctl_version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + report["core_compatibility"]["required_core"], + ">=0.1.13, <0.2.0" + ); + assert_eq!(report["recommended_agent"], "codex"); + let agents = report["agents"].as_array().expect("agents"); + assert!( + agents + .iter() + .any(|agent| agent["name"] == "codex" && agent["available"] == true) + ); +} + +#[test] +fn config_check_uses_userprofile_when_home_is_missing() { + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + + let output = Command::cargo_bin("agentctl") + .expect("agentctl") + .current_dir(project.path()) + .env_remove("AGENTCTL_HOME") + .env_remove("HOME") + .env("USERPROFILE", home.path()) + .args(["config", "check"]) + .output() + .expect("config check"); + + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains(".agentctl")); +} + +#[test] +fn unavailable_explicit_agent_returns_diagnostics_and_recovery_options() { + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + fs::write( + project.path().join("agentctl.toml"), + r#" +[summary] +max_output_bytes = 1024 +tail_bytes = 256 +max_preview_lines = 4 + +[agents.broken] +command = ["definitely-not-an-agentctl-harness"] +prompt_stdin = true +"#, + ) + .expect("agentctl config"); + + let output = Command::cargo_bin("agentctl") + .expect("agentctl") + .current_dir(project.path()) + .env("HOME", home.path()) + .args(["run", "broken", "--prompt", "test"]) + .output() + .expect("run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr"); + for expected in [ + "detected platform:", + "LDGR selected harnesses:", + "runnable configured agents:", + "recovery options:", + "agentctl discover", + "ldgr install --harness", + ] { + assert!( + stderr.contains(expected), + "stderr should contain {expected:?}: {stderr}" + ); + } +} + +#[test] +fn generic_ldgr_profile_falls_back_to_next_selected_runnable_harness() { + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + let fake_core = tempfile::tempdir().expect("fake Core"); + write_compatible_ldgr(fake_core.path()); + fs::create_dir_all(home.path().join(".ldgr")).expect("ldgr dir"); + fs::write( + home.path().join(".ldgr/config.toml"), + r#" +schema_version = 1 +default_harness = "pi" +selected_harnesses = ["pi", "codex"] +"#, + ) + .expect("ldgr config"); + fs::write( + project.path().join("agentctl.toml"), + platform_fallback_config(), + ) + .expect("agentctl config"); + + let output = Command::cargo_bin("agentctl") + .expect("agentctl") + .current_dir(project.path()) + .env("HOME", home.path()) + .env("PATH", prepend_path(fake_core.path())) + .args(["run", "ldgr-loop", "--prompt", "test", "--json"]) + .output() + .expect("run"); + + assert!(output.status.success(), "{output:?}"); + let stderr = String::from_utf8(output.stderr).expect("stderr"); + assert!(stderr.contains("requires `missing-pi`")); + assert!(stderr.contains("ldgr-loop-codex")); + let report: Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert!( + report[0]["summary"]["preview_lines"] + .to_string() + .contains("fallback-codex") + ); +} + +fn prepend_path(directory: &std::path::Path) -> std::ffi::OsString { + let mut paths = vec![directory.to_path_buf()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + std::env::join_paths(paths).expect("PATH") +} + +#[cfg(windows)] +fn write_compatible_ldgr(directory: &std::path::Path) { + fs::write( + directory.join("compatibility.json"), + r#"{"schema":"ldgr.launcher-compatibility.v1","compatible":true,"core_version":"0.1.13","core_executable":"ldgr.cmd","agentctl_version":"0.1.2","agentctl_requirement":">=0.1.2, <0.2.0","error_recovery_schema":1}"#, + ) + .expect("compatibility JSON"); + fs::write( + directory.join("ldgr.cmd"), + "@echo off\r\nif \"%1\"==\"--version\" (echo ldgr 0.1.13& exit /b 0)\r\nif \"%1\"==\"compatibility\" (type \"%~dp0compatibility.json\"& exit /b 0)\r\nexit /b 2\r\n", + ) + .expect("fake compatible ldgr"); +} + +#[cfg(unix)] +fn write_compatible_ldgr(directory: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let path = directory.join("ldgr"); + fs::write( + &path, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then echo 'ldgr 0.1.13'; exit 0; fi\nif [ \"${1:-}\" = \"compatibility\" ]; then printf '%s\\n' '{\"schema\":\"ldgr.launcher-compatibility.v1\",\"compatible\":true,\"core_version\":\"0.1.13\",\"core_executable\":\"ldgr\",\"agentctl_version\":\"0.1.2\",\"agentctl_requirement\":\">=0.1.2, <0.2.0\",\"error_recovery_schema\":1}'; exit 0; fi\nexit 2\n", + ) + .expect("fake compatible ldgr"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("permissions"); +} + +#[cfg(unix)] +fn platform_discovery_config() -> &'static str { + r#" +[summary] +max_output_bytes = 1024 +tail_bytes = 256 +max_preview_lines = 4 + +[agents.codex] +command = ["sh", "-c", "echo codex"] +prompt_stdin = true +"# +} + +#[cfg(windows)] +fn platform_discovery_config() -> &'static str { + r#" +[summary] +max_output_bytes = 1024 +tail_bytes = 256 +max_preview_lines = 4 + +[agents.codex] +command = ["cmd", "/C", "echo codex"] +prompt_stdin = true +"# +} + +#[cfg(unix)] +fn platform_fallback_config() -> &'static str { + r#" +[summary] +max_output_bytes = 1024 +tail_bytes = 256 +max_preview_lines = 4 + +[agents.ldgr-loop] +command = ["missing-pi"] +prompt_stdin = true + +[agents.ldgr-loop-codex] +command = ["sh", "-c", "echo fallback-codex"] +prompt_stdin = true +"# +} + +#[cfg(windows)] +fn platform_fallback_config() -> &'static str { + r#" +[summary] +max_output_bytes = 1024 +tail_bytes = 256 +max_preview_lines = 4 + +[agents.ldgr-loop] +command = ["missing-pi"] +prompt_stdin = true + +[agents.ldgr-loop-codex] +command = ["cmd", "/C", "echo fallback-codex"] +prompt_stdin = true +"# +} diff --git a/tests/error_capture_cli.rs b/tests/error_capture_cli.rs new file mode 100644 index 0000000..71b2741 --- /dev/null +++ b/tests/error_capture_cli.rs @@ -0,0 +1,140 @@ +use std::fs; +use std::path::Path; + +use assert_cmd::Command; +use serde_json::Value; + +#[test] +fn invalid_config_is_captured_before_worker_startup() { + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + fs::write(project.path().join("agentctl.toml"), "not = [valid").expect("invalid config"); + + agentctl(project.path(), home.path()) + .args(["run", "broken", "--prompt", "redacted prompt"]) + .assert() + .failure(); + + let recovery = only_recovery(project.path()); + assert_eq!(recovery["format"], "ldgr-error-recovery"); + assert_eq!(recovery["error"]["code"], "configuration-invalid"); + assert!( + !serde_json::to_string(&recovery) + .expect("json") + .contains("redacted prompt") + ); +} + +#[test] +fn spawn_and_nonzero_exit_are_captured_outside_the_worker() { + let home = tempfile::tempdir().expect("home"); + + let spawn_project = tempfile::tempdir().expect("spawn project"); + fs::write( + spawn_project.path().join("agentctl.toml"), + format!( + "{}\ncwd = \"missing-worker-directory\"\n", + agent_config(spawnable_command()) + ), + ) + .expect("spawn config"); + agentctl(spawn_project.path(), home.path()) + .args(["run", "fixture", "--prompt", "secret"]) + .assert() + .failure(); + assert_eq!( + only_recovery(spawn_project.path())["error"]["code"], + "worker-spawn-failed" + ); + + let exit_project = tempfile::tempdir().expect("exit project"); + fs::write( + exit_project.path().join("agentctl.toml"), + agent_config(nonzero_command()), + ) + .expect("exit config"); + agentctl(exit_project.path(), home.path()) + .args(["run", "fixture", "--prompt", "secret"]) + .assert() + .failure(); + let recovery = only_recovery(exit_project.path()); + assert_eq!(recovery["error"]["code"], "nonzero-exit"); + assert_eq!(recovery["error"]["details"]["exit_code"], 7); +} + +#[test] +fn no_writable_sink_fails_closed_before_config_or_worker_access() { + let home = tempfile::tempdir().expect("home"); + let project = tempfile::tempdir().expect("project"); + let blocked_state = project.path().join("blocked-state"); + fs::write(project.path().join(".ldgr"), "not a directory").expect("block project sink"); + fs::write(&blocked_state, "not a directory").expect("block user sink"); + + let output = agentctl(project.path(), home.path()) + .env("LOCALAPPDATA", &blocked_state) + .env("XDG_STATE_HOME", &blocked_state) + .args(["run", "worker-must-not-start", "--prompt", "secret"]) + .output() + .expect("run fail-closed fixture"); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains( + "FATAL: no durable LDGR recovery sink is writable; worker execution was not started" + ), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn agentctl(project: &Path, home: &Path) -> Command { + let mut command = Command::cargo_bin("agentctl").expect("agentctl binary"); + command.current_dir(project).env("HOME", home); + command +} + +fn only_recovery(project: &Path) -> Value { + let inbox = project.join(".ldgr/recovery/inbox"); + let records = fs::read_dir(&inbox) + .expect("recovery inbox") + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .collect::>(); + assert_eq!(records.len(), 1, "{records:?}"); + serde_json::from_str(&fs::read_to_string(&records[0]).expect("recovery record")) + .expect("recovery json") +} + +fn agent_config(command: &str) -> String { + format!( + r#" +[summary] +max_output_bytes = 16384 +tail_bytes = 4096 +max_preview_lines = 12 + +[agents.fixture] +command = {command} +prompt_stdin = false +"# + ) +} + +#[cfg(windows)] +fn nonzero_command() -> &'static str { + r#"["cmd", "/C", "exit 7"]"# +} + +#[cfg(windows)] +fn spawnable_command() -> &'static str { + r#"["cmd", "/C", "echo worker-must-not-start"]"# +} + +#[cfg(unix)] +fn spawnable_command() -> &'static str { + r#"["sh", "-c", "echo worker-must-not-start"]"# +} + +#[cfg(unix)] +fn nonzero_command() -> &'static str { + r#"["sh", "-c", "exit 7"]"# +} diff --git a/tests/exec_cli.rs b/tests/exec_cli.rs index fd56ca7..c8b2c26 100644 --- a/tests/exec_cli.rs +++ b/tests/exec_cli.rs @@ -52,27 +52,22 @@ fn exec_passthrough_failing_exit_code_and_summary() { let project_dir = tempfile::tempdir().expect("project tempdir"); generate_config(&home_dir, &project_dir); - let output = Command::cargo_bin("agentctl") - .expect("agentctl binary") + let mut command = Command::cargo_bin("agentctl").expect("agentctl binary"); + command .current_dir(project_dir.path()) .env("HOME", home_dir.path()) - .args([ - "exec", - "--json", - "--", - "sh", - "-c", - "echo warning: careful >&2; exit 7", - ]) - .output() - .expect("run failing agentctl exec"); + .args(["exec", "--json", "--"]) + .args(failing_command()); + let output = command.output().expect("run failing agentctl exec"); assert_eq!(output.status.code(), Some(7)); let json: Value = serde_json::from_slice(&output.stdout).expect("json summary"); assert_summary_schema(&json); assert_eq!(json.get("exit_code").and_then(Value::as_i64), Some(7)); assert_eq!( - json.pointer("/preview_lines/0").and_then(Value::as_str), + json.pointer("/preview_lines/0") + .and_then(Value::as_str) + .map(str::trim), Some("warning: careful") ); } @@ -83,27 +78,49 @@ fn exec_raw_streams_child_output_without_summary_json() { let project_dir = tempfile::tempdir().expect("project tempdir"); generate_config(&home_dir, &project_dir); - let output = Command::cargo_bin("agentctl") - .expect("agentctl binary") + let mut command = Command::cargo_bin("agentctl").expect("agentctl binary"); + command .current_dir(project_dir.path()) .env("HOME", home_dir.path()) - .args([ - "exec", - "--raw", - "--json", - "--", - "sh", - "-c", - "printf raw-out; printf raw-err >&2", - ]) - .output() - .expect("run raw agentctl exec"); + .args(["exec", "--raw", "--json", "--"]) + .args(raw_command()); + let output = command.output().expect("run raw agentctl exec"); assert!(output.status.success()); assert_eq!(String::from_utf8(output.stdout).expect("stdout"), "raw-out"); assert_eq!(String::from_utf8(output.stderr).expect("stderr"), "raw-err"); } +#[cfg(unix)] +fn failing_command() -> Vec<&'static str> { + vec!["sh", "-c", "echo warning: careful >&2; exit 7"] +} + +#[cfg(windows)] +fn failing_command() -> Vec<&'static str> { + vec![ + "powershell.exe", + "-NoProfile", + "-Command", + "[Console]::Error.WriteLine('warning: careful'); exit 7", + ] +} + +#[cfg(unix)] +fn raw_command() -> Vec<&'static str> { + vec!["sh", "-c", "printf raw-out; printf raw-err >&2"] +} + +#[cfg(windows)] +fn raw_command() -> Vec<&'static str> { + vec![ + "powershell.exe", + "-NoProfile", + "-Command", + "[Console]::Out.Write('raw-out'); [Console]::Error.Write('raw-err')", + ] +} + fn assert_summary_schema(json: &Value) { let object = json.as_object().expect("summary object"); assert_key_set( From af0ccf14499cfd253aed98b542db96672d7adb7e Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Mon, 3 Aug 2026 12:11:34 -0700 Subject: [PATCH 2/4] Fix forwarded Unix signal exit status --- src/process.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/process.rs b/src/process.rs index b508014..ef8198c 100644 --- a/src/process.rs +++ b/src/process.rs @@ -11,7 +11,7 @@ use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; #[cfg(unix)] use signal_hook::iterator::Signals; #[cfg(unix)] -use std::os::unix::process::CommandExt; +use std::os::unix::process::{CommandExt, ExitStatusExt}; #[cfg(windows)] use std::os::windows::io::AsRawHandle; #[cfg(windows)] @@ -35,6 +35,8 @@ const TERMINATION_GRACE: Duration = Duration::from_millis(750); #[cfg(unix)] static ACTIVE_PROCESS_GROUP: AtomicI32 = AtomicI32::new(0); #[cfg(unix)] +static FORWARDED_SIGNAL: AtomicI32 = AtomicI32::new(0); +#[cfg(unix)] static SIGNAL_FORWARDER: OnceLock<()> = OnceLock::new(); /// A child that owns an isolated process group. Waiting for the command also @@ -70,6 +72,7 @@ impl ManagedChild { #[cfg(unix)] { let process_group = child.id() as i32; + FORWARDED_SIGNAL.store(0, Ordering::SeqCst); ACTIVE_PROCESS_GROUP.store(process_group, Ordering::SeqCst); Ok(Self { child, @@ -120,6 +123,15 @@ impl ManagedChild { self.reaped = true; self.clean_remaining_descendants(); self.clear_active_group(); + + #[cfg(unix)] + { + let signal = FORWARDED_SIGNAL.swap(0, Ordering::SeqCst); + if signal > 0 { + return Ok(ExitStatus::from_raw(signal)); + } + } + Ok(status) } @@ -226,6 +238,15 @@ fn install_signal_forwarder() -> io::Result<()> { for signal in signals.forever() { let process_group = ACTIVE_PROCESS_GROUP.load(Ordering::SeqCst); if process_group > 0 { + // Preserve the first interruption so the foreground caller + // cannot report success merely because its child handled + // the forwarded signal and returned zero. + let _ = FORWARDED_SIGNAL.compare_exchange( + 0, + signal, + Ordering::SeqCst, + Ordering::SeqCst, + ); // Negative PIDs address the complete process group. unsafe { libc::kill(-process_group, signal); From 15c95233f65ba6667a4603c81aa9afadb0b225d0 Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Mon, 3 Aug 2026 12:17:58 -0700 Subject: [PATCH 3/4] Close Unix signal forwarding startup race --- src/process.rs | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/process.rs b/src/process.rs index ef8198c..8ff7f58 100644 --- a/src/process.rs +++ b/src/process.rs @@ -31,6 +31,8 @@ use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, DETACHED_P #[cfg(unix)] const TERMINATION_GRACE: Duration = Duration::from_millis(750); +#[cfg(unix)] +const PROCESS_GROUP_STARTING: i32 = -1; #[cfg(unix)] static ACTIVE_PROCESS_GROUP: AtomicI32 = AtomicI32::new(0); @@ -67,13 +69,33 @@ impl ManagedChild { enable_subreaper()?; configure_process_group(command); install_signal_forwarder()?; - let child = command.spawn()?; + #[cfg(unix)] + { + FORWARDED_SIGNAL.store(0, Ordering::SeqCst); + ACTIVE_PROCESS_GROUP.store(PROCESS_GROUP_STARTING, Ordering::SeqCst); + } + let child = match command.spawn() { + Ok(child) => child, + Err(error) => { + #[cfg(unix)] + { + ACTIVE_PROCESS_GROUP.store(0, Ordering::SeqCst); + FORWARDED_SIGNAL.store(0, Ordering::SeqCst); + } + return Err(error); + } + }; #[cfg(unix)] { let process_group = child.id() as i32; - FORWARDED_SIGNAL.store(0, Ordering::SeqCst); ACTIVE_PROCESS_GROUP.store(process_group, Ordering::SeqCst); + let pending_signal = FORWARDED_SIGNAL.load(Ordering::SeqCst); + if pending_signal > 0 { + unsafe { + libc::kill(-process_group, pending_signal); + } + } Ok(Self { child, process_group, @@ -237,19 +259,24 @@ fn install_signal_forwarder() -> io::Result<()> { std::thread::spawn(move || { for signal in signals.forever() { let process_group = ACTIVE_PROCESS_GROUP.load(Ordering::SeqCst); - if process_group > 0 { + if process_group != 0 { // Preserve the first interruption so the foreground caller // cannot report success merely because its child handled - // the forwarded signal and returned zero. + // the forwarded signal and returned zero. A negative value + // means spawn is in progress; reloading after recording the + // signal closes the registration race with the parent. let _ = FORWARDED_SIGNAL.compare_exchange( 0, signal, Ordering::SeqCst, Ordering::SeqCst, ); - // Negative PIDs address the complete process group. - unsafe { - libc::kill(-process_group, signal); + let registered_group = ACTIVE_PROCESS_GROUP.load(Ordering::SeqCst); + if registered_group > 0 { + // Negative PIDs address the complete process group. + unsafe { + libc::kill(-registered_group, signal); + } } } else { // Do not turn agentctl into a process that ignores Ctrl-C From c2ab892cf8a79b0a74ab238ad5b78a9063e6312e Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Mon, 3 Aug 2026 12:23:00 -0700 Subject: [PATCH 4/4] Fix macOS foreground termination fixture --- src/process.rs | 60 +++++------------------------------------ tests/agent_jobs_cli.rs | 9 ++++++- 2 files changed, 14 insertions(+), 55 deletions(-) diff --git a/src/process.rs b/src/process.rs index 8ff7f58..b508014 100644 --- a/src/process.rs +++ b/src/process.rs @@ -11,7 +11,7 @@ use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; #[cfg(unix)] use signal_hook::iterator::Signals; #[cfg(unix)] -use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::os::unix::process::CommandExt; #[cfg(windows)] use std::os::windows::io::AsRawHandle; #[cfg(windows)] @@ -31,14 +31,10 @@ use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, DETACHED_P #[cfg(unix)] const TERMINATION_GRACE: Duration = Duration::from_millis(750); -#[cfg(unix)] -const PROCESS_GROUP_STARTING: i32 = -1; #[cfg(unix)] static ACTIVE_PROCESS_GROUP: AtomicI32 = AtomicI32::new(0); #[cfg(unix)] -static FORWARDED_SIGNAL: AtomicI32 = AtomicI32::new(0); -#[cfg(unix)] static SIGNAL_FORWARDER: OnceLock<()> = OnceLock::new(); /// A child that owns an isolated process group. Waiting for the command also @@ -69,33 +65,12 @@ impl ManagedChild { enable_subreaper()?; configure_process_group(command); install_signal_forwarder()?; - #[cfg(unix)] - { - FORWARDED_SIGNAL.store(0, Ordering::SeqCst); - ACTIVE_PROCESS_GROUP.store(PROCESS_GROUP_STARTING, Ordering::SeqCst); - } - let child = match command.spawn() { - Ok(child) => child, - Err(error) => { - #[cfg(unix)] - { - ACTIVE_PROCESS_GROUP.store(0, Ordering::SeqCst); - FORWARDED_SIGNAL.store(0, Ordering::SeqCst); - } - return Err(error); - } - }; + let child = command.spawn()?; #[cfg(unix)] { let process_group = child.id() as i32; ACTIVE_PROCESS_GROUP.store(process_group, Ordering::SeqCst); - let pending_signal = FORWARDED_SIGNAL.load(Ordering::SeqCst); - if pending_signal > 0 { - unsafe { - libc::kill(-process_group, pending_signal); - } - } Ok(Self { child, process_group, @@ -145,15 +120,6 @@ impl ManagedChild { self.reaped = true; self.clean_remaining_descendants(); self.clear_active_group(); - - #[cfg(unix)] - { - let signal = FORWARDED_SIGNAL.swap(0, Ordering::SeqCst); - if signal > 0 { - return Ok(ExitStatus::from_raw(signal)); - } - } - Ok(status) } @@ -259,24 +225,10 @@ fn install_signal_forwarder() -> io::Result<()> { std::thread::spawn(move || { for signal in signals.forever() { let process_group = ACTIVE_PROCESS_GROUP.load(Ordering::SeqCst); - if process_group != 0 { - // Preserve the first interruption so the foreground caller - // cannot report success merely because its child handled - // the forwarded signal and returned zero. A negative value - // means spawn is in progress; reloading after recording the - // signal closes the registration race with the parent. - let _ = FORWARDED_SIGNAL.compare_exchange( - 0, - signal, - Ordering::SeqCst, - Ordering::SeqCst, - ); - let registered_group = ACTIVE_PROCESS_GROUP.load(Ordering::SeqCst); - if registered_group > 0 { - // Negative PIDs address the complete process group. - unsafe { - libc::kill(-registered_group, signal); - } + if process_group > 0 { + // Negative PIDs address the complete process group. + unsafe { + libc::kill(-process_group, signal); } } else { // Do not turn agentctl into a process that ignores Ctrl-C diff --git a/tests/agent_jobs_cli.rs b/tests/agent_jobs_cli.rs index f0d0889..cf73951 100644 --- a/tests/agent_jobs_cli.rs +++ b/tests/agent_jobs_cli.rs @@ -403,7 +403,7 @@ tail_bytes = 256 max_preview_lines = 12 [agents.tree] -command = ["bash", "-c", "setsid sleep 30 & echo $! > '{}'; wait"] +command = ["bash", "-c", "trap 'exit 143' TERM; setsid sleep 30 & echo $! > '{}'; while :; do sleep 1; done"] prompt_stdin = false "#, descendant_pid_path.display() @@ -432,6 +432,13 @@ prompt_stdin = false .parse::() .expect("numeric descendant pid"); + assert!( + agentctl + .try_wait() + .expect("check foreground agentctl") + .is_none(), + "foreground agentctl exited before it could be signaled" + ); let signal_result = unsafe { libc::kill(agentctl.id() as i32, libc::SIGTERM) }; assert_eq!(signal_result, 0, "signal foreground agentctl"); let status = agentctl.wait().expect("wait for foreground agentctl");