Skip to content

Commit 5e1f9d1

Browse files
committed
Merge pull request 'feat(orchestrator): OutputPoster and drain-before-drop #146' (#168) from task/146-output-poster-v2 into main
2 parents 12bba44 + 593d918 commit 5e1f9d1

8 files changed

Lines changed: 157 additions & 3 deletions

File tree

crates/terraphim_orchestrator/src/config.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ pub struct OrchestratorConfig {
3737
/// Directory for flow run state persistence.
3838
#[serde(default)]
3939
pub flow_state_dir: Option<PathBuf>,
40+
/// Gitea configuration for posting agent output to issues.
41+
#[serde(default)]
42+
pub gitea: Option<GiteaOutputConfig>,
43+
}
44+
45+
/// Configuration for posting agent output to Gitea issues.
46+
#[derive(Debug, Clone, Serialize, Deserialize)]
47+
pub struct GiteaOutputConfig {
48+
pub base_url: String,
49+
pub token: String,
50+
pub owner: String,
51+
pub repo: String,
4052
}
4153

4254
/// Lightweight reference to an SFIA skill code and level.
@@ -97,6 +109,9 @@ pub struct AgentDefinition {
97109
/// Maximum CPU seconds allowed per agent execution.
98110
#[serde(default)]
99111
pub max_cpu_seconds: Option<u64>,
112+
/// Gitea issue number to post output to (optional).
113+
#[serde(default)]
114+
pub gitea_issue: Option<u64>,
100115
}
101116

102117
/// Agent layer in the dark factory hierarchy.

crates/terraphim_orchestrator/src/flow/executor.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ impl FlowExecutor {
169169
fallback_model: None,
170170
grace_period_secs: None,
171171
max_cpu_seconds: None,
172+
gitea_issue: None,
172173
};
173174

174175
// Build provider for spawner

crates/terraphim_orchestrator/src/lib.rs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,23 @@ pub mod handoff;
4040
pub mod mode;
4141
pub mod nightwatch;
4242
pub mod persona;
43+
pub mod output_poster;
4344
pub mod scheduler;
4445
pub mod scope;
4546

4647
pub use compound::{CompoundReviewResult, CompoundReviewWorkflow, ReviewGroupDef, SwarmConfig};
4748
pub use concurrency::{ConcurrencyController, FairnessPolicy, ModeQuotas};
4849
pub use config::{
49-
AgentDefinition, AgentLayer, CompoundReviewConfig, ConcurrencyConfig, NightwatchConfig,
50-
OrchestratorConfig, TrackerConfig, TrackerStates, WorkflowConfig,
50+
AgentDefinition, AgentLayer, CompoundReviewConfig, ConcurrencyConfig, GiteaOutputConfig,
51+
NightwatchConfig, OrchestratorConfig, TrackerConfig, TrackerStates, WorkflowConfig,
5152
};
5253
pub use cost_tracker::{BudgetVerdict, CostSnapshot, CostTracker};
5354
pub use dispatcher::{DispatchTask, Dispatcher, DispatcherStats};
5455
pub use dual_mode::DualModeOrchestrator;
5556
pub use error::OrchestratorError;
5657
pub use handoff::{HandoffBuffer, HandoffContext, HandoffLedger};
5758
pub use mode::{IssueMode, TimeMode};
59+
pub use output_poster::OutputPoster;
5860
pub use nightwatch::{
5961
dual_panel_evaluate, validate_certificate, Claim, CorrectionAction, CorrectionLevel,
6062
DriftAlert, DriftMetrics, DriftScore, DualPanelResult, NightwatchMonitor, RateLimitTracker,
@@ -129,6 +131,8 @@ pub struct AgentOrchestrator {
129131
persona_registry: PersonaRegistry,
130132
/// Renderer for persona metaprompts.
131133
metaprompt_renderer: MetapromptRenderer,
134+
/// Output poster for posting agent output to Gitea issues.
135+
output_poster: Option<OutputPoster>,
132136
/// Circuit breakers for each provider to prevent cascading failures.
133137
#[allow(dead_code)]
134138
circuit_breakers: Arc<Mutex<HashMap<String, CircuitBreaker>>>,
@@ -203,6 +207,9 @@ impl AgentOrchestrator {
203207
None => MetapromptRenderer::new().expect("default template should always compile"),
204208
};
205209

210+
// Initialize output poster if Gitea config is provided
211+
let output_poster = config.gitea.as_ref().map(OutputPoster::new);
212+
206213
Ok(Self {
207214
config,
208215
spawner,
@@ -221,6 +228,7 @@ impl AgentOrchestrator {
221228
cost_tracker,
222229
persona_registry,
223230
metaprompt_renderer,
231+
output_poster,
224232
circuit_breakers: Arc::new(Mutex::new(HashMap::new())),
225233
active_flows: HashMap::new(),
226234
})
@@ -736,7 +744,35 @@ impl AgentOrchestrator {
736744
}
737745
}
738746

739-
// Process exits
747+
// Drain output from exiting agents BEFORE removing them
748+
for (name, def, status) in &exited {
749+
// Drain remaining output events
750+
let mut output_lines: Vec<String> = Vec::new();
751+
if let Some(managed) = self.active_agents.get_mut(name) {
752+
while let Ok(event) = managed.output_rx.try_recv() {
753+
self.nightwatch.observe(name, &event);
754+
match &event {
755+
crate::OutputEvent::Stdout { line, .. } => {
756+
output_lines.push(line.clone());
757+
}
758+
crate::OutputEvent::Stderr { line, .. } => {
759+
output_lines.push(format!("[stderr] {}", line));
760+
}
761+
_ => {}
762+
}
763+
}
764+
}
765+
766+
// Post output to Gitea if configured
767+
if let (Some(poster), Some(issue)) = (&self.output_poster, def.gitea_issue) {
768+
let exit_code = status.code();
769+
if let Err(e) = poster.post_agent_output(name, issue, &output_lines, exit_code).await {
770+
warn!(agent = %name, issue = issue, error = %e, "failed to post output to Gitea");
771+
}
772+
}
773+
}
774+
775+
// NOW remove from active_agents and handle exits
740776
for (name, def, status) in exited {
741777
self.active_agents.remove(&name);
742778
self.handle_agent_exit(&name, &def, status);
@@ -1100,6 +1136,7 @@ mod tests {
11001136
fallback_model: None,
11011137
grace_period_secs: None,
11021138
max_cpu_seconds: None,
1139+
gitea_issue: None,
11031140
},
11041141
AgentDefinition {
11051142
name: "sync".to_string(),
@@ -1120,6 +1157,7 @@ mod tests {
11201157
fallback_model: None,
11211158
grace_period_secs: None,
11221159
max_cpu_seconds: None,
1160+
gitea_issue: None,
11231161
},
11241162
],
11251163
restart_cooldown_secs: 60,
@@ -1129,6 +1167,7 @@ mod tests {
11291167
persona_data_dir: None,
11301168
flows: vec![],
11311169
flow_state_dir: None,
1170+
gitea: None,
11321171
}
11331172
}
11341173

@@ -1293,6 +1332,7 @@ task = "test"
12931332
fallback_model: None,
12941333
grace_period_secs: None,
12951334
max_cpu_seconds: None,
1335+
gitea_issue: None,
12961336
}],
12971337
restart_cooldown_secs: 0, // instant restart for testing
12981338
max_restart_count: 3,
@@ -1301,6 +1341,7 @@ task = "test"
13011341
persona_data_dir: None,
13021342
flows: vec![],
13031343
flow_state_dir: None,
1344+
gitea: None,
13041345
}
13051346
}
13061347

@@ -1376,6 +1417,7 @@ task = "test"
13761417
fallback_model: None,
13771418
grace_period_secs: None,
13781419
max_cpu_seconds: None,
1420+
gitea_issue: None,
13791421
}];
13801422
let mut orch = AgentOrchestrator::new(config).unwrap();
13811423

@@ -1510,6 +1552,7 @@ task = "test"
15101552
fallback_model: None,
15111553
grace_period_secs: None,
15121554
max_cpu_seconds: None,
1555+
gitea_issue: None,
15131556
}];
15141557

15151558
// Set up persona data dir with a test persona
@@ -1592,6 +1635,7 @@ sfia_skills = [{ code = "TEST", name = "Testing", level = 4, description = "Desi
15921635
fallback_model: None,
15931636
grace_period_secs: None,
15941637
max_cpu_seconds: None,
1638+
gitea_issue: None,
15951639
}];
15961640

15971641
// No persona_data_dir, so registry will be empty

crates/terraphim_orchestrator/src/mode/time.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ mod tests {
176176
fallback_model: None,
177177
grace_period_secs: None,
178178
max_cpu_seconds: None,
179+
gitea_issue: None,
179180
}
180181
}
181182

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//! Posts agent output to Gitea issues after agent exit.
2+
3+
use terraphim_tracker::GiteaTracker;
4+
use terraphim_tracker::gitea::GiteaConfig;
5+
6+
use crate::config::GiteaOutputConfig;
7+
8+
/// Posts collected agent output to a Gitea issue comment.
9+
pub struct OutputPoster {
10+
tracker: GiteaTracker,
11+
}
12+
13+
impl OutputPoster {
14+
/// Create a new OutputPoster from Gitea output configuration.
15+
pub fn new(config: &GiteaOutputConfig) -> Self {
16+
let gitea_config = GiteaConfig {
17+
base_url: config.base_url.clone(),
18+
token: config.token.clone(),
19+
owner: config.owner.clone(),
20+
repo: config.repo.clone(),
21+
active_states: vec!["open".to_string()],
22+
terminal_states: vec!["closed".to_string()],
23+
use_robot_api: false,
24+
};
25+
Self {
26+
tracker: GiteaTracker::new(gitea_config).expect("Failed to create GiteaTracker"),
27+
}
28+
}
29+
30+
/// Post agent output as a comment on the given Gitea issue.
31+
///
32+
/// Truncates output to 60000 bytes to stay within Gitea's comment size limit.
33+
pub async fn post_agent_output(
34+
&self,
35+
agent_name: &str,
36+
issue_number: u64,
37+
output_lines: &[String],
38+
exit_code: Option<i32>,
39+
) -> Result<(), String> {
40+
if output_lines.is_empty() {
41+
tracing::debug!(agent = %agent_name, issue = issue_number, "no output to post");
42+
return Ok(());
43+
}
44+
45+
let exit_str = match exit_code {
46+
Some(code) => format!("exit code {}", code),
47+
None => "unknown exit".to_string(),
48+
};
49+
50+
let mut body = format!(
51+
"**Agent `{}`** completed ({}).\n\n<details>\n<summary>Output ({} lines)</summary>\n\n```\n",
52+
agent_name,
53+
exit_str,
54+
output_lines.len()
55+
);
56+
57+
let joined = output_lines.join("\n");
58+
// Truncate to stay within Gitea limits (~65535 bytes)
59+
let max_output = 60000;
60+
if joined.len() > max_output {
61+
body.push_str(&joined[..max_output]);
62+
body.push_str("\n... (truncated)\n");
63+
} else {
64+
body.push_str(&joined);
65+
}
66+
body.push_str("\n```\n\n</details>");
67+
68+
match self.tracker.post_comment(issue_number, &body).await {
69+
Ok(comment) => {
70+
tracing::info!(
71+
agent = %agent_name,
72+
issue = issue_number,
73+
comment_id = comment.id,
74+
"posted agent output to Gitea"
75+
);
76+
Ok(())
77+
}
78+
Err(e) => {
79+
let msg = format!("failed to post output for {}: {}", agent_name, e);
80+
tracing::error!("{}", msg);
81+
Err(msg)
82+
}
83+
}
84+
}
85+
}
86+
87+

crates/terraphim_orchestrator/src/scheduler.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ mod tests {
163163
fallback_model: None,
164164
grace_period_secs: None,
165165
max_cpu_seconds: None,
166+
gitea_issue: None,
166167
}
167168
}
168169

crates/terraphim_orchestrator/tests/orchestrator_tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ fn test_config() -> OrchestratorConfig {
4444
fallback_model: None,
4545
grace_period_secs: None,
4646
max_cpu_seconds: None,
47+
gitea_issue: None,
4748
},
4849
AgentDefinition {
4950
name: "sync".to_string(),
@@ -64,6 +65,7 @@ fn test_config() -> OrchestratorConfig {
6465
fallback_model: None,
6566
grace_period_secs: None,
6667
max_cpu_seconds: None,
68+
gitea_issue: None,
6769
},
6870
AgentDefinition {
6971
name: "reviewer".to_string(),
@@ -84,6 +86,7 @@ fn test_config() -> OrchestratorConfig {
8486
fallback_model: None,
8587
grace_period_secs: None,
8688
max_cpu_seconds: None,
89+
gitea_issue: None,
8790
},
8891
],
8992
restart_cooldown_secs: 60,
@@ -93,6 +96,7 @@ fn test_config() -> OrchestratorConfig {
9396
persona_data_dir: None,
9497
flows: vec![],
9598
flow_state_dir: None,
99+
gitea: None,
96100
}
97101
}
98102

crates/terraphim_orchestrator/tests/scheduler_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ fn make_agent(name: &str, layer: AgentLayer, schedule: Option<&str>) -> AgentDef
2020
fallback_model: None,
2121
grace_period_secs: None,
2222
max_cpu_seconds: None,
23+
gitea_issue: None,
2324
}
2425
}
2526

0 commit comments

Comments
 (0)