Skip to content

Commit 5ee5c5e

Browse files
Terraphim CIclaude
andcommitted
feat(symphony): enforce dependency dispatch + PageRank-aware sorting
Fix 5 root causes from failed tlaplus-ts Symphony run: 1. Dependency enforcement: blocked_by populated from Gitea dependency API, Todo blocker rule prevents dispatch until all blockers terminal 2. Branch-per-issue: SYMPHONY_* env vars passed to hooks for issue-specific branches (symphony/issue-${NUMBER}) 3. Issue context in hooks: after_create generates CLAUDE.md with issue title/description for agent guidance 4. Per-state concurrency: configurable limits prevent overloading 5. Reconciliation: refresh blocker states before each dispatch cycle Additionally integrate Gitea Robot API for PageRank-aware dispatch: - Add pagerank_score field to Issue model - Fetch PageRank from /api/v1/robot/ready endpoint (single call) - Merge scores into candidates with graceful fallback - Sort dispatch candidates by PageRank desc, then priority, then age All 128 unit + 29 integration tests pass. Refs #671 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 56cbd04 commit 5ee5c5e

25 files changed

Lines changed: 922 additions & 384 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ crates/terraphim_settings/test_settings/settings.toml
7070
a.out
7171

7272
# Cache
73-
.cachebro/
73+
.cached-context/
7474
.ruff_cache/
7575

7676
# Stale logs and test results

crates/terraphim_symphony/QUICKSTART.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,23 @@ Shell scripts executed at workspace lifecycle points. All run with `sh -lc` in t
194194
| `hooks.before_remove` | Before workspace deletion | Logged and ignored |
195195
| `hooks.timeout_ms` | -- | Default: `60000` (60s). Hook execution timeout |
196196

197+
**Hook environment variables**: All hooks receive these environment variables for the current issue:
198+
199+
| Variable | Example | Description |
200+
|----------|---------|-------------|
201+
| `SYMPHONY_ISSUE_ID` | `42` | Tracker-internal issue ID |
202+
| `SYMPHONY_ISSUE_IDENTIFIER` | `owner/repo#7` | Human-readable issue key |
203+
| `SYMPHONY_ISSUE_NUMBER` | `7` | Issue number (extracted from identifier) |
204+
| `SYMPHONY_ISSUE_TITLE` | `Implement parser` | Issue title |
205+
206+
Use these in hooks to create per-issue branches and meaningful commit messages:
207+
208+
```yaml
209+
hooks:
210+
before_run: 'BRANCH="symphony/issue-${SYMPHONY_ISSUE_NUMBER}" && git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH" origin/main'
211+
after_run: 'BRANCH="symphony/issue-${SYMPHONY_ISSUE_NUMBER}" && git add -A && git commit -m "symphony: ${SYMPHONY_ISSUE_IDENTIFIER}" || true && git push -u origin "$BRANCH" || true'
212+
```
213+
197214
### Agent
198215
199216
| Setting | Default | Description |
@@ -350,7 +367,11 @@ WORKFLOW.md is watched for changes (via `file-watch` feature, enabled by default
350367

351368
**Git clone fails in after_create hook** -- Plain HTTPS URLs prompt for credentials in non-interactive shell contexts. Use token-embedded URLs: `git clone https://user:${TOKEN}@host/org/repo.git .`
352369

353-
**Liquid templates appearing literally in hooks** -- Hook scripts are plain shell commands run via `sh -lc`. They are NOT Liquid-rendered. Do not use `{{ }}` template syntax in hook values. Only the prompt body (below the YAML front matter) supports Liquid.
370+
**Liquid templates appearing literally in hooks** -- Hook scripts are plain shell commands run via `sh -lc`. They are NOT Liquid-rendered. Do not use `{{ }}` template syntax in hook values. Only the prompt body (below the YAML front matter) supports Liquid. Use `$SYMPHONY_ISSUE_NUMBER` and other `SYMPHONY_*` environment variables instead.
371+
372+
**Merge conflicts between agent workspaces** -- If multiple agents push to the same branch, use per-issue branches via `$SYMPHONY_ISSUE_NUMBER` in your `before_run` and `after_run` hooks. See the hook environment variables section above.
373+
374+
**Dependency enforcement not working with Gitea** -- Symphony fetches dependencies from the Gitea API (`/api/v1/repos/.../issues/{n}/dependencies`) and uses them to block dispatch. Ensure dependencies are created via the Gitea web UI or API. Issues in "Todo" state with non-terminal blockers will not be dispatched until their blockers are closed.
354375

355376
---
356377

@@ -407,8 +428,8 @@ workspace:
407428
408429
hooks:
409430
after_create: "git clone https://terraphim:${GITEA_TOKEN}@git.terraphim.cloud/terraphim/pagerank-viewer.git ."
410-
before_run: "git fetch origin && git checkout main && git pull"
411-
after_run: "git add -A && git commit -m 'symphony: auto-commit' && git push || true"
431+
before_run: 'git fetch origin && BRANCH="symphony/issue-${SYMPHONY_ISSUE_NUMBER}" && (git checkout "$BRANCH" 2>/dev/null && git pull origin "$BRANCH" || git checkout -b "$BRANCH" origin/main) || true'
432+
after_run: 'BRANCH="symphony/issue-${SYMPHONY_ISSUE_NUMBER}" && git add -A && git commit -m "symphony: ${SYMPHONY_ISSUE_IDENTIFIER} - ${SYMPHONY_ISSUE_TITLE}" || true && git push -u origin "$BRANCH" || true'
412433
timeout_ms: 120000
413434
414435
codex:

crates/terraphim_symphony/bin/symphony.rs

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ use std::path::PathBuf;
88
use tracing::info;
99
use tracing_subscriber::EnvFilter;
1010

11+
use terraphim_symphony::SymphonyError;
1112
use terraphim_symphony::config::ServiceConfig;
1213
use terraphim_symphony::orchestrator::SymphonyOrchestrator;
1314
use terraphim_symphony::tracker::gitea::GiteaTracker;
1415
use terraphim_symphony::tracker::linear::LinearTracker;
1516
use terraphim_symphony::workspace::WorkspaceManager;
16-
use terraphim_symphony::SymphonyError;
1717

1818
/// Symphony orchestration service.
1919
///
@@ -51,23 +51,20 @@ async fn main() -> anyhow::Result<()> {
5151
config.validate_for_dispatch()?;
5252

5353
// Build the tracker client
54-
let tracker: Box<dyn terraphim_symphony::IssueTracker> =
55-
match config.tracker_kind().as_deref() {
56-
Some("linear") => Box::new(LinearTracker::from_config(&config)?),
57-
Some("gitea") => Box::new(GiteaTracker::from_config(&config)?),
58-
Some(kind) => {
59-
return Err(SymphonyError::UnsupportedTrackerKind {
60-
kind: kind.into(),
61-
}
62-
.into());
63-
}
64-
None => {
65-
return Err(SymphonyError::ValidationFailed {
66-
checks: vec!["tracker.kind is required".into()],
67-
}
68-
.into());
54+
let tracker: Box<dyn terraphim_symphony::IssueTracker> = match config.tracker_kind().as_deref()
55+
{
56+
Some("linear") => Box::new(LinearTracker::from_config(&config)?),
57+
Some("gitea") => Box::new(GiteaTracker::from_config(&config)?),
58+
Some(kind) => {
59+
return Err(SymphonyError::UnsupportedTrackerKind { kind: kind.into() }.into());
60+
}
61+
None => {
62+
return Err(SymphonyError::ValidationFailed {
63+
checks: vec!["tracker.kind is required".into()],
6964
}
70-
};
65+
.into());
66+
}
67+
};
7168

7269
// Build the workspace manager
7370
let workspace_mgr = WorkspaceManager::new(&config)?;

crates/terraphim_symphony/examples/WORKFLOW-claude-code.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ workspace:
2121

2222
hooks:
2323
after_create: "git clone https://terraphim:${GITEA_TOKEN}@git.terraphim.cloud/terraphim/pagerank-viewer.git ."
24-
before_run: "git fetch origin && git checkout main && git pull"
25-
after_run: "git add -A && git commit -m 'symphony: auto-commit' && git push || true"
24+
before_run: "git fetch origin && BRANCH=\"symphony/issue-${SYMPHONY_ISSUE_NUMBER}\" && (git checkout \"$BRANCH\" 2>/dev/null && git pull origin \"$BRANCH\" || git checkout -b \"$BRANCH\" origin/main) || true"
25+
after_run: "BRANCH=\"symphony/issue-${SYMPHONY_ISSUE_NUMBER}\" && git add -A && git commit -m \"symphony: ${SYMPHONY_ISSUE_IDENTIFIER} - ${SYMPHONY_ISSUE_TITLE}\" || true && git push -u origin \"$BRANCH\" || true"
2626
timeout_ms: 120000
2727

2828
codex:

crates/terraphim_symphony/examples/WORKFLOW-tlaplus-ts.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ workspace:
2020
root: ~/symphony_workspaces
2121

2222
hooks:
23-
after_create: "git clone https://terraphim:${GITEA_TOKEN}@git.terraphim.cloud/terraphim/tlaplus-ts.git ."
24-
before_run: "git fetch origin && git checkout main && git pull"
25-
after_run: "git add -A && git commit -m 'symphony: auto-commit' && git push || true"
23+
after_create: "git clone https://terraphim:${GITEA_TOKEN}@git.terraphim.cloud/terraphim/tlaplus-ts.git . && cat > CLAUDE.md << 'CLAUDEEOF'\n# CLAUDE.md - Agent Instructions\n\n## Commit Discipline\n- Make atomic commits with descriptive messages referencing the issue\n- Format: feat(module): description (Refs #N)\n- Run tests before committing: npx vitest run\n- Run build before committing: npm run build\n- Run lint before committing: npm run lint\n\n## Testing\n- Never use mocks in tests\n- Write comprehensive tests using vitest\n- Ensure all existing tests still pass\n\n## Code Standards\n- TypeScript strict mode\n- ESM modules\n- Use British English in documentation\nCLAUDEEOF"
24+
before_run: "git fetch origin && BRANCH=\"symphony/issue-${SYMPHONY_ISSUE_NUMBER}\" && (git checkout \"$BRANCH\" 2>/dev/null && git pull origin \"$BRANCH\" || git checkout -b \"$BRANCH\" origin/main) || true"
25+
after_run: "BRANCH=\"symphony/issue-${SYMPHONY_ISSUE_NUMBER}\" && git add -A && git commit -m \"symphony: ${SYMPHONY_ISSUE_IDENTIFIER} - ${SYMPHONY_ISSUE_TITLE}\" || true && git push -u origin \"$BRANCH\" || true"
2626
timeout_ms: 120000
2727

2828
codex:

crates/terraphim_symphony/src/api/mod.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ async fn get_state(State(state): State<Arc<Mutex<ApiState>>>) -> impl IntoRespon
6969
async fn post_refresh(State(state): State<Arc<Mutex<ApiState>>>) -> impl IntoResponse {
7070
let locked = state.lock().await;
7171
locked.refresh_notify.notify_one();
72-
(StatusCode::ACCEPTED, Json(serde_json::json!({"status": "refresh_queued"})))
72+
(
73+
StatusCode::ACCEPTED,
74+
Json(serde_json::json!({"status": "refresh_queued"})),
75+
)
7376
}
7477

7578
/// GET /api/v1/:issue_identifier - issue-specific debug details.
@@ -95,7 +98,11 @@ async fn get_issue(
9598
.iter()
9699
.find(|r| r.issue_identifier == identifier)
97100
{
98-
return (StatusCode::OK, Json(serde_json::to_value(retrying).unwrap())).into_response();
101+
return (
102+
StatusCode::OK,
103+
Json(serde_json::to_value(retrying).unwrap()),
104+
)
105+
.into_response();
99106
}
100107

101108
let err = ApiError {
@@ -104,7 +111,11 @@ async fn get_issue(
104111
message: format!("issue {identifier} not found in running or retry state"),
105112
},
106113
};
107-
(StatusCode::NOT_FOUND, Json(serde_json::to_value(err).unwrap())).into_response()
114+
(
115+
StatusCode::NOT_FOUND,
116+
Json(serde_json::to_value(err).unwrap()),
117+
)
118+
.into_response()
108119
}
109120

110121
/// GET / - human-readable dashboard.

crates/terraphim_symphony/src/config/mod.rs

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,25 @@ impl ServiceConfig {
5454

5555
/// Tracker API endpoint URL.
5656
pub fn tracker_endpoint(&self) -> String {
57-
self.get_str(&["tracker", "endpoint"])
58-
.unwrap_or_else(|| match self.tracker_kind().as_deref() {
57+
self.get_str(&["tracker", "endpoint"]).unwrap_or_else(|| {
58+
match self.tracker_kind().as_deref() {
5959
Some("linear") => "https://api.linear.app/graphql".into(),
6060
Some("gitea") => std::env::var("GITEA_URL")
6161
.unwrap_or_else(|_| "https://git.terraphim.cloud".into()),
6262
_ => String::new(),
63-
})
63+
}
64+
})
6465
}
6566

6667
/// Tracker API key, with `$VAR` resolution.
6768
pub fn tracker_api_key(&self) -> Option<String> {
6869
let raw = self.get_str(&["tracker", "api_key"])?;
6970
let resolved = resolve_env_var(&raw);
70-
if resolved.is_empty() { None } else { Some(resolved) }
71+
if resolved.is_empty() {
72+
None
73+
} else {
74+
Some(resolved)
75+
}
7176
}
7277

7378
/// Tracker project slug (required for Linear).
@@ -85,6 +90,12 @@ impl ServiceConfig {
8590
self.get_str(&["tracker", "repo"])
8691
}
8792

93+
/// Whether to use the Gitea Robot API for PageRank-aware dispatch.
94+
/// Defaults to `true` when tracker kind is "gitea".
95+
pub fn tracker_use_robot_api(&self) -> bool {
96+
self.get_bool(&["tracker", "use_robot_api"]).unwrap_or(true)
97+
}
98+
8899
/// Active issue states (issues eligible for dispatch).
89100
pub fn active_states(&self) -> Vec<String> {
90101
self.get_str_list(&["tracker", "active_states"])
@@ -281,8 +292,7 @@ impl ServiceConfig {
281292
.is_none()
282293
{
283294
checks.push(
284-
"tracker.api_key or LINEAR_API_KEY environment variable is required"
285-
.into(),
295+
"tracker.api_key or LINEAR_API_KEY environment variable is required".into(),
286296
);
287297
}
288298
if self.tracker_project_slug().is_none() {
@@ -297,8 +307,7 @@ impl ServiceConfig {
297307
.is_none()
298308
{
299309
checks.push(
300-
"tracker.api_key or GITEA_TOKEN environment variable is required"
301-
.into(),
310+
"tracker.api_key or GITEA_TOKEN environment variable is required".into(),
302311
);
303312
}
304313
if self.tracker_gitea_owner().is_none() {
@@ -353,18 +362,20 @@ impl ServiceConfig {
353362

354363
fn get_u64(&self, path: &[&str]) -> Option<u64> {
355364
let val = self.get_value(path)?;
356-
val.as_u64().or_else(|| {
357-
val.as_str()
358-
.and_then(|s| s.parse::<u64>().ok())
359-
})
365+
val.as_u64()
366+
.or_else(|| val.as_str().and_then(|s| s.parse::<u64>().ok()))
360367
}
361368

362369
fn get_i64(&self, path: &[&str]) -> Option<i64> {
363370
let val = self.get_value(path)?;
364-
val.as_i64().or_else(|| {
365-
val.as_str()
366-
.and_then(|s| s.parse::<i64>().ok())
367-
})
371+
val.as_i64()
372+
.or_else(|| val.as_str().and_then(|s| s.parse::<i64>().ok()))
373+
}
374+
375+
fn get_bool(&self, path: &[&str]) -> Option<bool> {
376+
let val = self.get_value(path)?;
377+
val.as_bool()
378+
.or_else(|| val.as_str().and_then(|s| s.parse::<bool>().ok()))
368379
}
369380

370381
fn get_str_list(&self, path: &[&str]) -> Option<Vec<String>> {
@@ -451,9 +462,7 @@ mod tests {
451462

452463
#[test]
453464
fn custom_active_states() {
454-
let cfg = config_from_yaml(
455-
"tracker:\n active_states:\n - Backlog\n - Started",
456-
);
465+
let cfg = config_from_yaml("tracker:\n active_states:\n - Backlog\n - Started");
457466
assert_eq!(cfg.active_states(), vec!["Backlog", "Started"]);
458467
}
459468

@@ -533,9 +542,8 @@ mod tests {
533542

534543
#[test]
535544
fn per_state_concurrency_ignores_invalid() {
536-
let cfg = config_from_yaml(
537-
"agent:\n max_concurrent_agents_by_state:\n Todo: 0\n Bad: -1",
538-
);
545+
let cfg =
546+
config_from_yaml("agent:\n max_concurrent_agents_by_state:\n Todo: 0\n Bad: -1");
539547
let map = cfg.max_concurrent_agents_by_state();
540548
assert!(map.is_empty());
541549
}
@@ -554,9 +562,7 @@ mod tests {
554562

555563
#[test]
556564
fn validation_linear_missing_project_slug() {
557-
let cfg = config_from_yaml(
558-
"tracker:\n kind: linear\n api_key: test-key",
559-
);
565+
let cfg = config_from_yaml("tracker:\n kind: linear\n api_key: test-key");
560566
let err = cfg.validate_for_dispatch().unwrap_err();
561567
match err {
562568
SymphonyError::ValidationFailed { checks } => {
@@ -621,9 +627,7 @@ mod tests {
621627

622628
#[test]
623629
fn claude_settings_file_path() {
624-
let cfg = config_from_yaml(
625-
"agent:\n settings: /home/alex/.claude/symphony-settings.json",
626-
);
630+
let cfg = config_from_yaml("agent:\n settings: /home/alex/.claude/symphony-settings.json");
627631
assert_eq!(
628632
cfg.claude_settings().unwrap(),
629633
"/home/alex/.claude/symphony-settings.json"
@@ -632,9 +636,7 @@ mod tests {
632636

633637
#[test]
634638
fn claude_settings_tilde_path() {
635-
let cfg = config_from_yaml(
636-
"agent:\n settings: ~/.claude/symphony-settings.json",
637-
);
639+
let cfg = config_from_yaml("agent:\n settings: ~/.claude/symphony-settings.json");
638640
assert_eq!(
639641
cfg.claude_settings().unwrap(),
640642
"~/.claude/symphony-settings.json"
@@ -657,7 +659,11 @@ mod tests {
657659
let err = cfg.validate_for_dispatch().unwrap_err();
658660
match err {
659661
SymphonyError::ValidationFailed { checks } => {
660-
assert!(checks.iter().any(|c| c.contains("unsupported agent.runner")));
662+
assert!(
663+
checks
664+
.iter()
665+
.any(|c| c.contains("unsupported agent.runner"))
666+
);
661667
}
662668
_ => panic!("expected ValidationFailed"),
663669
}

crates/terraphim_symphony/src/config/template.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ const DEFAULT_PROMPT: &str = "You are working on an issue from the configured tr
1919
/// # Errors
2020
/// Returns `TemplateParseError` if the template syntax is invalid, or
2121
/// `TemplateRenderError` if a referenced variable or filter is unknown.
22-
pub fn render_prompt(
23-
template_str: &str,
24-
issue: &Issue,
25-
attempt: Option<u32>,
26-
) -> Result<String> {
22+
pub fn render_prompt(template_str: &str, issue: &Issue, attempt: Option<u32>) -> Result<String> {
2723
let source = if template_str.trim().is_empty() {
2824
DEFAULT_PROMPT
2925
} else {
@@ -171,6 +167,7 @@ mod tests {
171167
url: Some("https://tracker.example.com/MT-42".into()),
172168
labels: vec!["bug".into(), "urgent".into()],
173169
blocked_by: vec![],
170+
pagerank_score: None,
174171
created_at: Some(Utc::now()),
175172
updated_at: Some(Utc::now()),
176173
}

0 commit comments

Comments
 (0)