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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: ci

on:
push:
branches: [main]
pull_request:

env:
CARGO_TERM_COLOR: always

jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
# Hard gate: the whole workspace must compile and pass tests.
- name: build (workspace)
run: cargo build --workspace --all-targets
- name: test (workspace)
run: cargo test --workspace
# Smoke: the MCP server must actually START and speak MCP over stdio, not
# merely compile. No AWS creds needed — initialize + tools/list only touch
# the static tool catalog (AWS is resolved lazily, on real tool calls).
- name: smoke (oab-mcp runs + lists tools over stdio)
run: |
bin=./target/debug/oab-mcp
test -x "$bin" || { echo "binary not found at $bin"; exit 1; }
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| timeout 30 "$bin" > out.jsonl 2> err.log || true
echo "----- stdout -----"; cat out.jsonl
echo "----- stderr -----"; cat err.log
for t in deploy_list deploy_get get_agent_states deploy_apply deploy_scale deploy_delete; do
grep -q "\"$t\"" out.jsonl || { echo "SMOKE FAIL: tool '$t' not advertised"; exit 1; }
done
echo "SMOKE OK: server initialized and advertised all 6 tools"
# Publish the built server so a runtime that can't compile it locally
# (aws-sdk-ec2 exceeds small boxes' RAM) can still fetch and run it.
- name: upload oab-mcp binary
uses: actions/upload-artifact@v4
with:
name: oab-mcp-linux-x64
path: target/debug/oab-mcp
if-no-files-found: error
retention-days: 7
# Informational (non-blocking) — the authoring runtime has no rustfmt, so
# style is tidied opportunistically rather than gated.
- name: fmt (our crates)
run: cargo fmt -p agent-lifecycle --check
continue-on-error: true
- name: clippy (our crates)
run: cargo clippy -p agent-lifecycle -- -D warnings
continue-on-error: true
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
Cargo.lock
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[workspace]
members = ["crates/agent-lifecycle", "crates/oabctl", "crates/studio-cp", "crates/oab-mcp"]
resolver = "2"
8 changes: 8 additions & 0 deletions crates/agent-lifecycle/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "agent-lifecycle"
version = "0.1.0"
edition = "2021"
description = "Canonical agent lifecycle state machine — see docs/adr/agent-lifecycle.md"
license = "MIT"

[dependencies]
173 changes: 173 additions & 0 deletions crates/agent-lifecycle/src/ecs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! ECS runtime driver — projects ECS task signals onto the canonical model.
//!
//! Mapping (ADR §6): `lastStatus` ever RUNNING ⇒ `identity_verified`;
//! `desiredStatus == STOPPED` ⇒ `DesiredStatus::Stopped`; `healthStatus` + lease
//! ⇒ `Health`; CP/director cordon ⇒ `accepting_work`.

use crate::{DesiredStatus, Discriminator, Health, RuntimeDriver};

/// ECS `lastStatus` values relevant to the projection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EcsLastStatus {
Provisioning,
Pending,
Activating,
Running,
Deactivating,
Stopping,
Stopped,
}

/// ECS container `healthStatus`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EcsHealth {
Healthy,
Unhealthy,
Unknown,
}

/// A single ECS task observation (the subset the projection needs).
#[derive(Debug, Clone, Copy)]
pub struct EcsTask {
pub last_status: EcsLastStatus,
/// ECS `desiredStatus == STOPPED`.
pub desired_status_stopped: bool,
pub health: EcsHealth,
/// CP-issued lease still valid (heartbeat authorized).
pub lease_valid: bool,
/// CP/director cordon: `false` ⇒ not admitting new work (→ Paused).
pub accepting_work: bool,
}

/// Projects ECS task state onto the canonical lifecycle model.
pub struct EcsDriver;

impl RuntimeDriver for EcsDriver {
type Native = EcsTask;
/// Task ARN.
type InstanceId = String;

fn project(&self, task: &EcsTask, verified_before: bool) -> Discriminator {
let desired_status = if task.desired_status_stopped {
DesiredStatus::Stopped
} else {
DesiredStatus::Running
};

// `identity_verified` latches once `lastStatus` has ever reached RUNNING.
let identity_verified = verified_before || task.last_status == EcsLastStatus::Running;

// Faulted on an unhealthy check, an unknown/unobservable status
// (node lost), or a lost lease.
let health = match task.health {
EcsHealth::Healthy if task.lease_valid => Health::Ok,
_ => Health::Faulted,
};

Discriminator {
desired_status,
accepting_work: task.accepting_work,
health,
identity_verified,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::AgentState;

fn task(
ls: EcsLastStatus,
stopped: bool,
h: EcsHealth,
lease: bool,
accepting: bool,
) -> EcsTask {
EcsTask {
last_status: ls,
desired_status_stopped: stopped,
health: h,
lease_valid: lease,
accepting_work: accepting,
}
}

#[test]
fn activating_task_projects_to_starting() {
let d = EcsDriver.project(
&task(
EcsLastStatus::Activating,
false,
EcsHealth::Unknown,
false,
false,
),
false,
);
assert_eq!(d.classify(), AgentState::Starting);
}

#[test]
fn running_healthy_task_projects_to_running() {
let d = EcsDriver.project(
&task(
EcsLastStatus::Running,
false,
EcsHealth::Healthy,
true,
true,
),
true,
);
assert_eq!(d.classify(), AgentState::Running);
}

#[test]
fn unhealthy_after_verified() {
// Was RUNNING before, now healthStatus UNHEALTHY ⇒ Unhealthy (not Starting).
let d = EcsDriver.project(
&task(
EcsLastStatus::Running,
false,
EcsHealth::Unhealthy,
true,
false,
),
true,
);
assert_eq!(d.classify(), AgentState::Unhealthy);
}

#[test]
fn node_lost_unknown_is_unhealthy_not_stopped() {
// Unknown health while verified ⇒ Unhealthy(fenced), not Stopped.
let d = EcsDriver.project(
&task(
EcsLastStatus::Running,
false,
EcsHealth::Unknown,
false,
false,
),
true,
);
assert_eq!(d.classify(), AgentState::Unhealthy);
}

#[test]
fn desired_stopped_is_stopping_while_observable() {
let d = EcsDriver.project(
&task(
EcsLastStatus::Deactivating,
true,
EcsHealth::Healthy,
true,
false,
),
true,
);
assert_eq!(d.classify(), AgentState::Stopping);
}
}
Loading
Loading