//! Motor durável do orquestrador — `tick()` (SDD-OAD-005 ingestão + 006 avanço).
//!
//! `tick` é determinístico e idempotente e produz **uma etapa por demanda por
//! chamada** (`sdd auto tick` = um passo): cria a orquestração na primeira vez,
//! gera a etapa apontada pelo `cursor` e **pausa ao concluir um gate** (PRD,
//! Tech Spec, Refinamento), deixando a aprovação para a SDD-OAD-008.
//!
//! Reconciliação: `tui::stage::can_navigate` é baseado em **aprovação** e não
//! serve ao avanço autônomo (que produz etapas como `recorded`, não `approved`);
//! o motor usa `cursor` + detecção de gate. A geração é abstraída por
//! `StageRunner` (testável com `FakeRunner`); o runner real é a extração de
//! `tui::runner` (SDD-OAD-002), plugada na SDD-OAD-009.
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::tui::stage::Stage;
use super::demand::Demand;
use super::state::{self, EngineState, EngineStatus};
use super::{queue, write_atomic};
/// Pedido de geração de uma etapa. Campos de provider/timeout entram na
/// SDD-OAD-009 (quando o runner real é plugado).
// slug/description/artifact_dir são lidos pelo runner real (SDD-OAD-009);
// o FakeRunner usa apenas stage/title.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct StageRequest {
pub slug: String,
pub stage: Stage,
pub title: String,
pub description: String,
pub artifact_dir: PathBuf,
}
/// Artefato Markdown produzido por um `StageRunner`.
#[derive(Clone, Debug)]
pub struct StageArtifact {
// `stage` é parte do contrato; conferido pelo runner real (SDD-OAD-009).
#[allow(dead_code)]
pub stage: Stage,
pub markdown: String,
}
/// Falha de geração de etapa.
// `Failed` é construído pelo runner real (SDD-OAD-009); o FakeRunner só retorna Ok.
#[allow(dead_code)]
#[derive(Debug)]
pub enum RunnerError {
/// Provider sem modo headless confiável → etapa vira `manual:<stage>`.
NotHeadless,
/// Falha de execução com mensagem.
Failed(String),
}
impl std::fmt::Display for RunnerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotHeadless => write!(f, "provider sem modo headless"),
Self::Failed(msg) => write!(f, "falha na geração: {msg}"),
}
}
}
impl std::error::Error for RunnerError {}
/// Abstração do único ponto que fala com o modelo. A impl real (SDD-OAD-002/009)
/// é um wrapper sobre `runtime::stage_runner` (extraído de `tui::runner`).
pub trait StageRunner {
fn run(&self, req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError>;
}
/// Runner determinístico para testar o motor sem provider real.
pub struct FakeRunner;
impl StageRunner for FakeRunner {
fn run(&self, req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError> {
let markdown = fake_stage_markdown(req.stage, &req.title);
Ok(StageArtifact {
stage: req.stage,
markdown,
})
}
}
fn fake_stage_markdown(stage: Stage, title: &str) -> String {
match stage {
Stage::ProjectDiscovery => format!(
"# Project Discovery - {title}\n\n## Rastreabilidade\n\n- Orquestração: {title}\n\n## Stack\n\n- A determinar.\n\n## Comandos\n\n- A determinar.\n\n## Estrutura\n\n- A determinar.\n"
),
Stage::RiskClassification => format!(
"# Risk Classification - {title}\n\n## Rastreabilidade\n\n- Origem: 00-project-discovery.md\n\n## Nível de risco\n\n- Médio.\n\n## Justificativa\n\n- Avaliação sintética de teste.\n"
),
Stage::Idea => format!(
"# Idea - {title}\n\n## Rastreabilidade\n\n- Orquestração: {title}\n- Origem: 00-project-discovery.md\n- Origem complementar: 00-risk-classification.md\n\n## Problema\n\n- Problema descrito pela demanda.\n\n## Objetivo\n\n- Produzir um plano SDD rastreável.\n\n## Usuários\n\n- Time de engenharia.\n\n## Critérios de sucesso\n\n- Artefato validável pelo contrato SDD.\n"
),
Stage::Prd => format!(
"# PRD - {title}\n\n## Rastreabilidade\n\n- Origem: 01-idea.md\n\n## Resumo\n\n- Requisitos derivados da ideia aprovada.\n\n## Objetivos\n\n- Definir comportamento esperado.\n\n## Não objetivos\n\n- Não executar código nesta etapa.\n\n## Usuários\n\n- Time de produto e engenharia.\n\n## Requisitos\n\n- RF-01: manter rastreabilidade.\n\n## Critérios de aceite\n\nDado uma demanda SDD\nQuando o PRD for gerado\nEntão ele deve conter critérios validáveis.\n\n## Métricas\n\n- Taxa de artefatos válidos.\n\n## Riscos\n\n- Escopo insuficiente.\n\n## Diagramas\n\nNão aplicável: demanda sintética de teste sem fluxo visual.\n"
),
Stage::Techspec => format!(
"# Tech Spec - {title}\n\n## Rastreabilidade\n\n- Origem: 02-prd.md\n\n## Visão técnica\n\n- Implementação orientada pelo contrato SDD.\n\n## Arquitetura\n\n- CLI determinístico e artifact store local.\n\n## Contratos\n\n- Markdown validável por schema.\n\n## Dados\n\n- Sem migração de dados.\n\n## Segurança\n\n- Sem segredos persistidos.\n\n## Performance\n\n- Sem requisito específico.\n\n## Observabilidade\n\n- Eventos JSONL locais.\n\n## Testes\n\n- Validar artefatos e fluxo CLI.\n\n## Riscos\n\n- Dependência de aprovação humana.\n\n## Plano de implementação\n\n- Gerar tasks atômicas.\n\n## Diagramas\n\nNão aplicável: especificação sintética de teste.\n"
),
Stage::Tasks => format!(
"# Tasks - {title}\n\n## Rastreabilidade\n\n- Origem: 03-techspec.md\n\n## Backlog\n\n| ID | Título | Estimativa | Dependências |\n|----|--------|------------|--------------|\n| T-01 | Implementar fluxo mínimo | 1h | — |\n\n## Dependências\n\n- T-01 sem dependências.\n\n## Critérios de aceite\n\nDado o plano SDD\nQuando T-01 for executada\nEntão o resultado deve ser verificável.\n\n## Definition of Done\n\n- Testes executados e evidências registradas.\n\n## Estratégia de testes\n\n- Rodar validação determinística do CLI.\n\n## Ordem sugerida\n\n1. T-01.\n\n## Prompts Agent\n\n- Task ID: T-01\n- Objetivo verificável: executar o fluxo mínimo preservando o contrato SDD.\n- Validação: rode os checks disponíveis e registre evidências.\n"
),
Stage::Refinement => format!(
"# Refinement - {title}\n\n## Rastreabilidade\n\n- Origem: 04-tasks.md\n\n## Resumo\n\n- Refinamento sintético para liberar execução.\n\n## Solução proposta\n\n- Executar T-01 em fatia pequena.\n\n## Pontos de observação\n\n- Validar antes de avançar.\n\n## Checklist\n\n- [ ] Evidência de teste registrada.\n\n## Subtasks\n\n- T-01: 1.0h.\n\n## Definition of Done\n\n- Critérios de aceite cobertos.\n\n## Flags e configurações\n\n- Não aplicável.\n"
),
Stage::Execution => format!(
"# Execution - {title}\n\n## Rastreabilidade\n\n- Origem: 04-tasks.md\n\n## Tarefa\n\n- T-01.\n\n## Resumo da implementação\n\n- Execução sintética registrada.\n\n## Arquivos alterados\n\n- Nenhum.\n\n## Testes e evidências\n\n- Avaliação determinística.\n\n## Riscos e pendências\n\n- Sem execução real de workspace neste runner.\n"
),
Stage::Adr => format!(
"# ADR - {title}\n\n## Rastreabilidade\n\n- Origem: 06-execution.md\n\n## Status\n\n- Registrada.\n\n## Contexto\n\n- Decisão sintética de teste.\n\n## Problema\n\n- Registrar decisões do fluxo.\n\n## Decisão\n\n- Manter artifact store como fonte canônica.\n\n## Alternativas consideradas\n\n- Não registrar ADR.\n\n## Consequências\n\n- Mais rastreabilidade.\n\n## Impactos e riscos\n\n- Baixo risco.\n\n## Plano de adoção\n\n- Usar CLI.\n\n## Plano de reversão\n\n- Reverter artefato.\n\n## Evidências\n\n- Avaliação determinística.\n\n## Histórico de revisão\n\n- Criado pelo runner sintético.\n"
),
Stage::Review => format!(
"# Review - {title}\n\n## Rastreabilidade\n\n- Origem: 06-execution.md\n\n## Escopo revisado\n\n- Execução sintética.\n\n## Evidências\n\n- Validação determinística.\n\n## Achados\n\n- Nenhum achado crítico.\n\n## Testes\n\n- `sdd eval stage`.\n\n## Veredito\n\nAprovado com ressalvas.\n"
),
Stage::Memory => format!(
"# Memory - {title}\n\n## Estado final\n\n- Ciclo registrado.\n\n## Rastreabilidade\n\n- Origem: 07-review.md\n\n## Decisões\n\n- Preservar rastreabilidade local.\n\n## Padrões úteis\n\n- Validar antes de avançar.\n\n## Testes e comandos\n\n- `sdd eval orchestration`.\n\n## Pendências\n\n- Nenhuma no runner sintético.\n\n## Contexto para o próximo agente\n\n- Continuar a partir do artifact store local.\n"
),
}
}
/// Resultado observável de um `tick` (para CLI/testes).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TickReport {
/// (slug, stage) de etapas produzidas neste tick.
pub produced: Vec<(String, String)>,
/// Demandas que pausaram num gate (awaiting approval) neste tick.
pub paused: Vec<String>,
/// Demandas que alcançaram `ready_for_exec` neste tick.
pub ready: Vec<String>,
/// Demandas sem ação (awaiting/manual/error/ready já persistidos).
pub skipped: Vec<String>,
/// Erros por demanda.
pub errors: Vec<String>,
}
/// Etapas que exigem checkpoint humano.
fn is_gate(stage: Stage) -> bool {
matches!(stage, Stage::Prd | Stage::Techspec | Stage::Refinement)
}
/// Etapas do planejamento autônomo (a execução é fronteira de handoff).
pub(crate) fn is_planning(stage: Stage) -> bool {
matches!(
stage,
Stage::Idea | Stage::Prd | Stage::Techspec | Stage::Tasks | Stage::Refinement
)
}
/// Próxima etapa na ordem canônica, se houver.
pub(crate) fn next_stage(stage: Stage) -> Option<Stage> {
let all = Stage::ALL;
let idx = all.iter().position(|s| *s == stage)?;
all.get(idx + 1).copied()
}
/// Um passo do motor: produz no máximo uma etapa por demanda.
pub fn tick(root: &Path, runner: &dyn StageRunner, owner: &str, now: &str) -> Result<TickReport> {
let mut report = TickReport::default();
for demand in queue::list(root)? {
let existing = state::load(root, &demand.slug)?;
let active = match &existing {
None => true,
Some(st) => st.status == EngineStatus::Orchestrating,
};
if !active {
report.skipped.push(demand.slug.clone());
continue;
}
if let Err(err) = step(root, runner, &demand, existing, owner, now, &mut report) {
report.errors.push(format!("{}: {err:#}", demand.slug));
}
}
Ok(report)
}
/// Roda `tick` repetidamente até nenhuma demanda progredir (todas pausadas em
/// gate, prontas, manuais ou em erro) ou atingir `max_iterations`. Retorna o
/// relatório de cada iteração — base do `sdd auto run` (SDD-OAD-013).
pub fn run_until_idle(
root: &Path,
runner: &dyn StageRunner,
owner: &str,
now: &str,
max_iterations: u32,
) -> Result<Vec<TickReport>> {
let mut reports = Vec::new();
for _ in 0..max_iterations {
let report = tick(root, runner, owner, now)?;
let progressed = !report.produced.is_empty();
reports.push(report);
if !progressed {
break;
}
}
Ok(reports)
}
/// Cria a orquestração (se nova), produz a etapa do `cursor` e atualiza o estado.
fn step(
root: &Path,
runner: &dyn StageRunner,
demand: &Demand,
existing: Option<EngineState>,
owner: &str,
now: &str,
report: &mut TickReport,
) -> Result<()> {
let mut st = match existing {
Some(st) => st,
None => {
let mut s = EngineState::new(&demand.slug)?;
s.status = EngineStatus::Orchestrating;
s
}
};
st.last_tick = Some(now.to_string());
// Lock por demanda — se outro owner já segura, deixa para o próximo tick.
if !state::acquire_lock(root, &mut st, owner, now)? {
report.skipped.push(demand.slug.clone());
return Ok(());
}
let step_result = (|| -> Result<()> {
st.last_error = None;
// Cria/garante docs/<slug>/ + traceability-map (reuso in-process, sem stdout).
let dest = crate::artifact_dir(root, &demand.title);
std::fs::create_dir_all(&dest)?;
let index = crate::ensure_index(&dest, &demand.title)?;
let Some(stage) = Stage::from_key(&st.cursor) else {
return Err(anyhow::anyhow!("cursor inválido '{}'", st.cursor));
};
let request = StageRequest {
slug: demand.slug.clone(),
stage,
title: demand.title.clone(),
description: demand.description.clone(),
artifact_dir: dest.clone(),
};
match runner.run(&request) {
Ok(artifact) => {
let file = crate::stage_file(stage.key()).expect("stage canônica tem arquivo");
write_atomic(&dest.join(file), artifact.markdown.as_bytes())?;
crate::update_stage_state(&index, stage.key(), "recorded")?;
let evaluation =
crate::domain::evaluation::evaluate_stage(root, &demand.title, stage.key())?;
let _ = crate::log_jsonl(
root,
"evaluations.jsonl",
crate::domain::evaluation::evaluation_event("auto_eval_stage", &evaluation),
);
if evaluation.has_critical() {
return Err(anyhow::anyhow!(
"avaliação crítica bloqueou avanço de {}",
stage.key()
));
}
report
.produced
.push((demand.slug.clone(), stage.key().to_string()));
if is_gate(stage) {
// Pausa: cursor permanece no gate; aprovação (008) avança.
st.status = EngineStatus::AwaitingApproval;
report.paused.push(demand.slug.clone());
} else {
match next_stage(stage) {
Some(next) if is_planning(next) => {
st.cursor = next.key().to_string();
st.status = EngineStatus::Orchestrating;
}
_ => {
st.status = EngineStatus::ReadyForExec;
report.ready.push(demand.slug.clone());
}
}
}
Ok(())
}
Err(RunnerError::NotHeadless) => {
st.status = EngineStatus::Manual(stage.key().to_string());
report.skipped.push(demand.slug.clone());
Ok(())
}
Err(RunnerError::Failed(msg)) => Err(anyhow::anyhow!("{msg}")),
}
})();
if let Err(error) = step_result {
let message = format!("{}: {error:#}", demand.slug);
st.status = EngineStatus::Error;
st.last_error = Some(crate::runtime::redaction::redact_text(&message));
report.errors.push(message);
}
// release_lock persiste o estado final (cursor/status atualizados).
state::release_lock(root, &mut st)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};
struct FailingRunner;
impl StageRunner for FailingRunner {
fn run(&self, _req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError> {
Err(RunnerError::NotHeadless)
}
}
fn enqueue_demand(root: &Path, id: &str, title: &str) -> Demand {
let d = Demand::new(
id,
DemandType::Story,
title,
"desc",
DemandSource::Cli,
None,
"2026-06-08T00:00:00Z",
)
.unwrap();
queue::enqueue(root, &d).unwrap();
d
}
#[test]
fn first_tick_ingests_and_produces_idea() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
let report = tick(root, &FakeRunner, "tick-1", "2026-06-08T00:00:00Z").unwrap();
assert_eq!(report.produced, vec![(d.slug.clone(), "idea".to_string())]);
assert!(report.paused.is_empty());
let dest = root.join("docs").join(&d.slug);
assert!(dest.join("traceability-map.yaml").is_file());
assert!(dest.join("01-idea.md").is_file());
assert!(!dest.join("02-prd.md").exists());
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.cursor, "prd");
assert!(st.lock.is_none(), "lock deve ser liberado ao fim do tick");
let map = std::fs::read_to_string(dest.join("traceability-map.yaml")).unwrap();
let idea_block = map.split("idea:").nth(1).unwrap();
assert!(idea_block.contains("state: recorded"));
}
#[test]
fn second_tick_advances_to_prd_and_pauses_at_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
tick(root, &FakeRunner, "tick-1", "t0").unwrap();
let report = tick(root, &FakeRunner, "tick-1", "t1").unwrap();
assert_eq!(report.produced, vec![(d.slug.clone(), "prd".to_string())]);
assert_eq!(report.paused, vec![d.slug.clone()]);
let dest = root.join("docs").join(&d.slug);
assert!(dest.join("02-prd.md").is_file());
assert!(
!dest.join("03-techspec.md").exists(),
"não deve passar do gate"
);
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::AwaitingApproval);
assert_eq!(st.cursor, "prd", "cursor permanece no gate até aprovação");
}
#[test]
fn awaiting_demand_is_skipped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
tick(root, &FakeRunner, "tick-1", "t0").unwrap(); // idea
tick(root, &FakeRunner, "tick-1", "t1").unwrap(); // prd → awaiting
let report = tick(root, &FakeRunner, "tick-1", "t2").unwrap();
assert_eq!(report.skipped, vec![d.slug.clone()]);
assert!(report.produced.is_empty());
// Sem nova etapa produzida.
assert!(!root
.join("docs")
.join(&d.slug)
.join("03-techspec.md")
.exists());
}
#[test]
fn runner_not_headless_marks_manual() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
let report = tick(root, &FailingRunner, "tick-1", "t0").unwrap();
assert_eq!(report.skipped, vec![d.slug.clone()]);
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Manual("idea".to_string()));
assert!(!root.join("docs").join(&d.slug).join("01-idea.md").exists());
}
#[test]
fn step_error_releases_lock_and_records_last_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
std::fs::create_dir_all(root.join("docs")).unwrap();
std::fs::write(root.join("docs").join(&d.slug), "not a directory").unwrap();
let report = tick(root, &FakeRunner, "tick-1", "t0").unwrap();
assert!(report.errors.iter().any(|error| error.contains(&d.slug)));
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Error);
assert!(st.lock.is_none(), "lock deve ser liberado mesmo em erro");
assert!(st
.last_error
.as_deref()
.is_some_and(|error| error.contains(&d.slug)));
}
#[test]
fn empty_queue_is_noop() {
let dir = tempfile::tempdir().unwrap();
let report = tick(dir.path(), &FakeRunner, "tick-1", "t0").unwrap();
assert_eq!(report, TickReport::default());
}
#[test]
fn run_until_idle_drives_to_first_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
let reports = run_until_idle(root, &FakeRunner, "tick", "t0", 50).unwrap();
let produced: Vec<String> = reports
.iter()
.flat_map(|r| r.produced.iter().map(|(_, s)| s.clone()))
.collect();
assert_eq!(produced, vec!["idea".to_string(), "prd".to_string()]);
let st = state::load(root, &d.slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::AwaitingApproval);
assert_eq!(st.cursor, "prd");
}
}