Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
# Changelog

## 0.3.0 - 2026-03-19

Highlights:
- Added startup grace window for `wait_for_agent_idle` to prevent false
idle/completion detection on brand-new prompt steps (#67). Fresh `sdlc-auto`
runs no longer fail at early steps because the agent is still visibly thinking.
- Added per-agent persistent memory (`memory` config field) for long-lived
context across sessions (#62, #63).
- Added merge gate enforcement for `tt land` steps: when `TT_ENFORCE_MERGE_GATE=1`,
land verifies required CI checks are green and all PR review threads are resolved.
Automation land steps enable this by default (#59).
- Added `tt permissions suggest <workflow>` for batch pre-approval of blocked
commands across an entire workflow (#53).
- Added permission fix hints in blocked-command error messages (#57).
- Added orchestration state machine and run ledger for deterministic workflow
resume and PR-summary formatting (#54, #55).
- Added explicit resume baseline from SDLC ledger (#58).
- Added issue claim lease with auto-release on failed/aborted runs (#33).
- Added Codex SDLC orchestration framework with helper scripts, CI dispatch,
and multi-agent topology docs (#26, #31, #32).
- Hardened runtime detection with diagnostics and fixtures (#14).
- Added resume intent log and compensator preflight (#13).
- Fixed automation branch alignment with agent worktrees (#61).
- Fixed ratatui 0.29 → 0.30 to resolve lru Stacked Borrows vulnerability (#43).
- Fixed Codex runtime prompt passing (positional instead of `--prompt`) (#32).
- Added `docs/pr-review-loop.md` documenting the canonical PR review/merge loop.
- Added VERSIONING.md policy and PR versioning checklist (#42).

Release impact:
- **Minor version bump** (0.2.0 → 0.3.0) per VERSIONING.md: new user-visible
capabilities (persistent memory, permissions suggest, merge gate, orchestration
state machine) and autonomy milestone (deterministic fresh-run startup).
- Existing workflows remain backward-compatible. Merge gate is opt-in via env var
for manual use; automation land steps enable it automatically.
- Permissions `--audit` output now emits tighter suggested rules (exact prefix
instead of trailing wildcard).

## 0.2.0 - 2026-03-14

Highlights:
Expand Down
7 changes: 7 additions & 0 deletions src/automation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use std::time::Duration;
use wait_timeout::ChildExt;

const DEFAULT_TIMEOUT_SECS: u64 = 900;
const DEFAULT_STARTUP_GRACE_SECS: u64 = 30;

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -164,6 +165,7 @@ impl<'a> WorkflowResolver<'a> {
output_json,
wait_for_idle,
wait_timeout_secs,
startup_grace_secs,
} => {
let effective_agent = agent_override.unwrap_or(agent.as_str());
self.ensure_agent_exists(effective_agent)?;
Expand All @@ -189,6 +191,8 @@ impl<'a> WorkflowResolver<'a> {
.resolve_prompt_output_path(effective_agent, output_json)?,
wait_for_idle: wait_for_idle.unwrap_or(false),
wait_timeout_secs: wait_timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS),
startup_grace_secs: startup_grace_secs
.unwrap_or(DEFAULT_STARTUP_GRACE_SECS),
});
}
WorkflowStepConfig::Command {
Expand Down Expand Up @@ -461,6 +465,7 @@ pub enum ResolvedStep {
output_json: Option<PathBuf>,
wait_for_idle: bool,
wait_timeout_secs: u64,
startup_grace_secs: u64,
},
Command {
step_id: Option<String>,
Expand Down Expand Up @@ -852,6 +857,7 @@ impl<'a> WorkflowExecutor<'a> {
output_json,
wait_for_idle,
wait_timeout_secs,
startup_grace_secs,
..
} = step
{
Expand All @@ -861,6 +867,7 @@ impl<'a> WorkflowExecutor<'a> {
session_name,
Duration::from_secs((*wait_timeout_secs).max(1)),
Duration::from_secs(5),
Duration::from_secs(*startup_grace_secs),
)?;
if !wait.is_completed() {
let (timed_out, message) = match wait.failure_reason {
Expand Down
1 change: 1 addition & 0 deletions src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ mod tests {
output_json: None,
wait_for_idle: false,
wait_timeout_secs: 900,
startup_grace_secs: 30,
},
ResolvedStep::Command {
step_id: None,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, Instant};

const DEFAULT_STARTUP_GRACE_SECS: u64 = 30;
const AUTO_UP_READY_TIMEOUT_SECS: u64 = 45;
const AUTO_UP_READY_STABLE_MS: u64 = 750;
const AUTO_UP_READY_POLL_MS: u64 = 250;
Expand Down Expand Up @@ -112,6 +113,7 @@ pub fn run(
&session,
Duration::from_secs(options.timeout_secs.max(1)),
Duration::from_secs(options.idle_stable_secs.max(1)),
Duration::from_secs(DEFAULT_STARTUP_GRACE_SECS),
)?;
if !outcome.is_completed() {
return Err(match outcome.failure_reason {
Expand Down
2 changes: 2 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ pub enum WorkflowStepConfig {
wait_for_idle: Option<bool>,
#[serde(default)]
wait_timeout_secs: Option<u64>,
#[serde(default)]
startup_grace_secs: Option<u64>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
Command {
#[serde(default)]
Expand Down
52 changes: 44 additions & 8 deletions src/health/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,16 @@ pub fn recovery_trigger(health: &AgentHealth) -> Option<RecoveryTrigger> {
None
}

/// Minimum consecutive Working polls (without hash change) required to count as activity.
/// Prevents flicker between Working/Unknown from falsely setting `saw_activity`.
const WORKING_STATUS_CONSECUTIVE_THRESHOLD: u32 = 2;

pub fn wait_for_agent_idle(
runtime_name: &str,
session_name: &str,
timeout: Duration,
idle_stability: Duration,
startup_grace: Duration,
) -> Result<WaitForIdleResult> {
let adapter = runtime::get_adapter(runtime_name, None);
let runtime_prefers_signal = adapter
Expand All @@ -198,6 +203,7 @@ pub fn wait_for_agent_idle(
let mut last_hash: Option<u64> = None;
let mut idle_since: Option<Instant> = None;
let mut runtime_fallback_since: Option<Instant> = None;
let mut consecutive_working_polls: u32 = 0;
let runtime_fallback_grace = idle_stability
.checked_mul(RUNTIME_SIGNAL_FALLBACK_GRACE_MULTIPLIER)
.unwrap_or(idle_stability);
Expand All @@ -212,7 +218,7 @@ pub fn wait_for_agent_idle(

let output = TmuxSession::capture_pane(session_name, DEFAULT_CAPTURE_LINES)?;
let pane_hash = hash_output(&output);
let changed = last_hash.is_none_or(|h| h != pane_hash);
let changed = last_hash.is_some_and(|h| h != pane_hash);
let runtime_status = adapter.as_ref().map(|a| a.detect_status(&output));

if let Some(adapter) = &adapter
Expand All @@ -224,19 +230,39 @@ pub fn wait_for_agent_idle(
));
}

// Fast-path: completion signal before any working activity was observed
// means the prompt was consumed and finished instantly.
if let Some(adapter) = &adapter
&& adapter.detect_completion_signal(&output).is_some()
&& saw_activity
{
return Ok(WaitForIdleResult::completed(
WaitCompletionSource::RuntimeSignal,
));
if saw_activity {
return Ok(WaitForIdleResult::completed(
WaitCompletionSource::RuntimeSignal,
));
}
// Pre-activity completion: only accept if we're past the startup grace,
// or treat as fast-path completion for genuine instant results.
if start.elapsed() >= startup_grace {
return Ok(WaitForIdleResult::completed_with_detail(
WaitCompletionSource::RuntimeSignal,
Some("completion_before_activity_after_grace".to_string()),
));
}
}

// Track whether the runtime reports Working status even without hash change.
let runtime_is_working = runtime_status
.as_ref()
.is_some_and(|s| matches!(s, AgentStatus::Working));
if runtime_is_working && !changed {
consecutive_working_polls += 1;
} else if !runtime_is_working {
consecutive_working_polls = 0;
}

if changed
|| runtime_status
.as_ref()
.is_some_and(|s| matches!(s, AgentStatus::Working))
|| (runtime_is_working
&& consecutive_working_polls >= WORKING_STATUS_CONSECUTIVE_THRESHOLD)
{
saw_activity = true;
idle_since = None;
Expand Down Expand Up @@ -267,6 +293,10 @@ pub fn wait_for_agent_idle(
} else {
idle_since = Some(Instant::now());
}
} else if !saw_activity && start.elapsed() < startup_grace {
// Within startup grace: keep polling without timing out.
// Do not advance idle_since or return timeout — the agent
// may not have consumed the prompt yet.
}

last_hash = Some(pane_hash);
Expand Down Expand Up @@ -492,6 +522,12 @@ mod tests {
);
}

#[test]
fn working_consecutive_threshold_is_at_least_two() {
// The threshold prevents a single Working flicker from falsely setting saw_activity.
assert!(WORKING_STATUS_CONSECUTIVE_THRESHOLD >= 2);
}

#[test]
fn transition_events_emits_rate_limited_and_provider_recovered() {
let previous = sample_health(
Expand Down
1 change: 1 addition & 0 deletions src/runtime/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub(super) static CONFIG: RuntimeConfig = RuntimeConfig {
"Editing",
"Running",
"Searching",
"Unravelling",
],
idle_patterns: &[
"What would you like to do?",
Expand Down
13 changes: 13 additions & 0 deletions src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,19 @@ mod tests {
assert_eq!(diagnostics.status, AgentStatus::Idle);
}

#[test]
fn claude_detect_working_from_searching_and_unravelling() {
let a = adapter("claude-code");
assert_eq!(
a.detect_status("Searching for relevant files..."),
AgentStatus::Working
);
assert_eq!(
a.detect_status("Unravelling... (thinking)"),
AgentStatus::Working
);
}

#[test]
fn diagnose_output_returns_runtime_error_for_unknown_runtime() {
let err = diagnose_output("unknown", "anything", None).unwrap_err();
Expand Down
Loading