Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 32 additions & 15 deletions dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ function pollFocusUsage() {
if (!fa) return;
var url = "/v1/agents/" + encodeURIComponent(fa.workspace) + "/" + encodeURIComponent(fa.agent) + "/focus?lines=1&usage=1";
fetch(url).then(function(res) { return res.json(); }).then(function(json) {
if (!appState.focusAgent || appState.focusAgent.agent !== fa.agent) return;
if (!appState.focusAgent || appState.focusAgent.workspace !== fa.workspace || appState.focusAgent.agent !== fa.agent) return;
if (!json.data || !json.data.usage) return;
// Update just the usage stats section
var u = json.data.usage;
Expand All @@ -746,6 +746,12 @@ function pollFocusUsage() {
statsHtml += statRow("output tokens", formatTokens(u.output_tokens || 0));
statsHtml += statRow("cache read", formatTokens(u.cache_read || 0), "green");
statsHtml += statRow("cache write", formatTokens(u.cache_write || 0));
var ctxPct = json.data.context_pct;
if (ctxPct != null) {
var ctxColor = ctxPct <= 70 ? "green" : (ctxPct <= 90 ? "amber" : "red");
statsHtml += statRow("context", ctxPct + "%", ctxColor);
statsHtml += '<div class="focus-ctx-bar"><div class="focus-ctx-fill" style="width:' + ctxPct + '%;background:var(--' + (ctxColor === "green" ? "working" : ctxColor === "amber" ? "auth-fail" : "blocked") + ')"></div></div>';
}
$focusStats.innerHTML = statsHtml;
}).catch(function() { /* silently ignore usage poll errors */ });
}
Expand Down Expand Up @@ -788,23 +794,34 @@ function renderFocusView(data) {
if (wasAtBottom) termEl.scrollTop = termEl.scrollHeight;
}

// Usage stats
var u = data.usage || {};
var statsHtml = "";
statsHtml += statRow("input tokens", formatTokens(u.input_tokens || 0));
statsHtml += statRow("output tokens", formatTokens(u.output_tokens || 0));
statsHtml += statRow("cache read", formatTokens(u.cache_read || 0), "green");
statsHtml += statRow("cache write", formatTokens(u.cache_write || 0));
// Context bar
var ctxPct = data.context_pct;
if (ctxPct != null) {
// Usage stats — only update if this response includes usage data.
// The separate pollFocusUsage() handles the slow usage poll; don't
// overwrite it with zeros from the fast terminal poll.
if (data.usage && (data.usage.input_tokens || data.usage.output_tokens || data.usage.cache_read || data.usage.cache_write)) {
var u = data.usage;
var statsHtml = "";
statsHtml += statRow("input tokens", formatTokens(u.input_tokens || 0));
statsHtml += statRow("output tokens", formatTokens(u.output_tokens || 0));
statsHtml += statRow("cache read", formatTokens(u.cache_read || 0), "green");
statsHtml += statRow("cache write", formatTokens(u.cache_write || 0));
// Context bar
var ctxPct = data.context_pct;
if (ctxPct != null) {
var ctxColor = ctxPct <= 70 ? "green" : (ctxPct <= 90 ? "amber" : "red");
statsHtml += statRow("context", ctxPct + "%", ctxColor);
statsHtml += '<div class="focus-ctx-bar"><div class="focus-ctx-fill" style="width:' + ctxPct + '%;background:var(--' + (ctxColor === "green" ? "working" : ctxColor === "amber" ? "auth-fail" : "blocked") + ')"></div></div>';
} else {
statsHtml += statRow("context", "\u2014");
}
$focusStats.innerHTML = statsHtml;
} else if (data.context_pct != null && $focusStats.innerHTML === "") {
// At least show context bar even without full usage data
var ctxPct = data.context_pct;
var ctxColor = ctxPct <= 70 ? "green" : (ctxPct <= 90 ? "amber" : "red");
statsHtml += statRow("context", ctxPct + "%", ctxColor);
var statsHtml = statRow("context", ctxPct + "%", ctxColor);
statsHtml += '<div class="focus-ctx-bar"><div class="focus-ctx-fill" style="width:' + ctxPct + '%;background:var(--' + (ctxColor === "green" ? "working" : ctxColor === "amber" ? "auth-fail" : "blocked") + ')"></div></div>';
} else {
statsHtml += statRow("context", "\u2014");
$focusStats.innerHTML = statsHtml;
}
$focusStats.innerHTML = statsHtml;

// Diff
var d = data.diff || {};
Expand Down
186 changes: 179 additions & 7 deletions src/automation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ impl<'a> WorkflowResolver<'a> {
.agents
.iter()
.find(|a| a.name == effective_agent)
.and_then(|a| a.resolved_runtime(&self.config.defaults))
.and_then(|a| a.resolved_runtime(&self.config.defaults, &self.config.roles))
.unwrap_or_else(|| "unknown".to_string());
Comment thread
nutt-adam marked this conversation as resolved.
Resolved
steps.push(ResolvedStep::Prompt {
step_id: id.clone(),
Expand Down Expand Up @@ -1063,20 +1063,183 @@ impl<'a> WorkflowExecutor<'a> {
wait_for_idle,
wait_timeout_secs,
startup_grace_secs,
artifact_glob: step_artifact_glob,
artifact_name: step_artifact_name,
..
} = step
{
if runtime == "codex" || runtime == "claude-code" {
maybe_submit_buffered_prompt(session_name, &rendered)?;
}

// Artifact-polling mode: artifact_glob set but wait_for_idle is false.
// Poll for the artifact file instead of idle detection.
// This supports interactive skills (e.g. /office-hours) where the
// agent goes idle while waiting for human input.
let use_artifact_polling = step_artifact_glob.is_some()
&& step_artifact_name.is_some()
&& !*wait_for_idle;

if use_artifact_polling
&& let Some((ref expanded_pattern, ref pre_snap)) =
artifact_pre_snapshot
{
let poll_interval = Duration::from_secs(5);
let deadline = Duration::from_secs(*wait_timeout_secs);
let poll_start = std::time::Instant::now();
let art_name = step_artifact_name.as_deref().unwrap();

eprintln!(
" artifact-polling mode: waiting up to {}s for new file matching '{}'",
wait_timeout_secs, expanded_pattern
);

let mut artifact_found = false;
while poll_start.elapsed() < deadline {
std::thread::sleep(poll_interval);

// Check if session is still alive
if !TmuxSession::session_exists(session_name) {
// Session exited — check if artifact was produced
// before breaking
if let Ok(artifact_path) =
capture_artifact(pre_snap, expanded_pattern, art_name)
{
match store_artifact_output(
self.project_root,
&run_id,
art_name,
&artifact_path,
) {
Ok(saved) => {
output_files.insert(
art_name.to_string(),
saved.path.display().to_string(),
);
outputs.insert(art_name.to_string(), saved);
artifact_found = true;
}
Err(e) => {
failed_steps.push(step_index);
success = false;
step_results.push(StepResult {
index: step_index,
step_type: "prompt".to_string(),
status: StepStatus::Failed,
duration_ms: started.elapsed().as_millis()
as u64,
exit_code: None,
timed_out: false,
message: Some(format!(
"artifact store failed for '{}': {e}",
art_name
)),
stdout: None,
stderr: None,
});
break;
}
}
}
break;
}

// Check if new artifact file has appeared
if let Ok(artifact_path) =
capture_artifact(pre_snap, expanded_pattern, art_name)
{
match store_artifact_output(
self.project_root,
&run_id,
art_name,
&artifact_path,
) {
Ok(saved) => {
output_files.insert(
art_name.to_string(),
saved.path.display().to_string(),
);
outputs.insert(art_name.to_string(), saved);
artifact_found = true;
}
Err(e) => {
failed_steps.push(step_index);
success = false;
step_results.push(StepResult {
index: step_index,
step_type: "prompt".to_string(),
status: StepStatus::Failed,
duration_ms: started.elapsed().as_millis()
as u64,
exit_code: None,
timed_out: false,
message: Some(format!(
"artifact store failed for '{}': {e}",
art_name
)),
stdout: None,
stderr: None,
});
}
}
break;
}
}

if !artifact_found && success {
failed_steps.push(step_index);
success = false;
step_results.push(StepResult {
index: step_index,
step_type: "prompt".to_string(),
status: StepStatus::Failed,
duration_ms: started.elapsed().as_millis() as u64,
exit_code: None,
timed_out: true,
message: Some(format!(
"artifact '{}' not found after {}s of polling",
art_name, wait_timeout_secs
)),
stdout: None,
stderr: None,
});
break;
}

step_results.push(StepResult {
index: step_index,
step_type: "prompt".to_string(),
status: if artifact_found {
StepStatus::Success
} else {
StepStatus::Failed
},
duration_ms: started.elapsed().as_millis() as u64,
exit_code: if artifact_found { Some(0) } else { None },
timed_out: false,
message: Some(if artifact_found {
format!(
"artifact '{}' captured via polling after {}s",
art_name,
poll_start.elapsed().as_secs()
)
} else {
format!("artifact '{}' failed", art_name)
}),
stdout: None,
stderr: None,
});
continue;
}

if *wait_for_idle
&& !wait_for_prompt_activity_or_output(
runtime,
session_name,
&rendered,
baseline_pane_hash,
output_json.as_deref(),
Duration::from_secs(20),
Duration::from_secs((*startup_grace_secs).max(20)),
)?
{
failed_steps.push(step_index);
Expand All @@ -1088,10 +1251,10 @@ impl<'a> WorkflowExecutor<'a> {
duration_ms: started.elapsed().as_millis() as u64,
exit_code: None,
timed_out: true,
message: Some(
"prompt did not start activity or produce output within 20s"
.to_string(),
),
message: Some(format!(
"prompt did not start activity or produce output within {}s",
(*startup_grace_secs).max(20)
)),
stdout: None,
stderr: None,
});
Expand Down Expand Up @@ -2378,6 +2541,8 @@ impl<'a> WorkflowExecutor<'a> {
}
}

let (template_id, template_version) =
crate::state::parse_template_tag(&self.project_root.join("tutti.toml"));
append_automation_run(
self.project_root,
&AutomationRunRecord {
Expand All @@ -2391,6 +2556,8 @@ impl<'a> WorkflowExecutor<'a> {
agent_scope: agent_scope.map(|s| s.to_string()),
hook_event: options.hook_event.clone(),
hook_agent: options.hook_agent.clone(),
template_id,
template_version,
},
)?;

Expand Down Expand Up @@ -2643,7 +2810,7 @@ fn prompt_agent_with_files(
.agents
.iter()
.find(|a| a.name == agent)
.and_then(|a| a.resolved_runtime(&config.defaults))
.and_then(|a| a.resolved_runtime(&config.defaults, &config.roles))
.unwrap_or_else(|| "unknown".to_string());

if !TmuxSession::session_exists(&session_name) {
Expand Down Expand Up @@ -4672,6 +4839,7 @@ mod tests {
persistent: false,
memory: None,
env: HashMap::new(),
role: None,
}],
tool_packs: vec![],
workflows: vec![workflow],
Expand All @@ -4680,6 +4848,7 @@ mod tests {
observe: None,
budget: None,
webhooks: vec![],
roles: None,
}
}

Expand Down Expand Up @@ -5889,6 +6058,7 @@ mod tests {
persistent: false,
memory: None,
env: HashMap::new(),
role: None,
},
AgentConfig {
name: "reviewer".to_string(),
Expand All @@ -5902,6 +6072,7 @@ mod tests {
persistent: false,
memory: None,
env: HashMap::new(),
role: None,
},
],
tool_packs: vec![],
Expand Down Expand Up @@ -5960,6 +6131,7 @@ mod tests {
observe: None,
budget: None,
webhooks: vec![],
roles: None,
};

let dir = std::env::temp_dir().join("tutti-test-resolver-control-steps");
Expand Down
2 changes: 2 additions & 0 deletions src/budget/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ mod tests {
persistent: false,
memory: None,
env: HashMap::new(),
role: None,
}],
tool_packs: vec![],
workflows: vec![],
Expand All @@ -271,6 +272,7 @@ mod tests {
agent_weekly_tokens: caps,
}),
webhooks: vec![],
roles: None,
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pub fn run(agent_ref: &str, lines: u32, json: bool) -> Result<()> {
let resolved = super::agent_ref::resolve(agent_ref)?;
let agent = resolved.agent_config()?;
let runtime_name = agent
.resolved_runtime(&resolved.config.defaults)
.resolved_runtime(&resolved.config.defaults, &resolved.config.roles)
.unwrap_or_else(|| "unknown".to_string());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let session = TmuxSession::session_name(&resolved.workspace_name, &resolved.agent_name);

Expand Down
Loading
Loading