Kaskade: triggers-edge mellom orkestreringer (oppgave 24.8)

Etter vellykket pipeline-utførelse sjekker handle_orchestrate() om
orkestreringen har utgående triggers-edges til andre orchestration-noder.
Hvert gyldig mål enqueues som ny orchestrate-jobb med kaskade-kontekst.

Syklusdeteksjon via cascade_chain i jobb-payload:
- Sporer alle orchestration-IDer allerede utført i kjeden
- Blokkerer target som allerede finnes i kjeden (direkte + indirekte syklus)
- Dybdegrense på 10 ledd (MAX_CASCADE_DEPTH)
- Blokkerte kaskader logges i orchestration_log med status=skipped

Nedstrøms orkestreringer mottar:
- trigger_event: "cascade"
- upstream_orchestration_id i trigger_context
- {event.upstream_orchestration_id} tilgjengelig i script

Kaskade-feil er ikke-fatale — selve orkestreringen rapporteres som suksess.

8 nye enhetstester for syklusdeteksjon og dybdegrense.
This commit is contained in:
vegard 2026-03-18 17:57:26 +00:00
parent d435f6ab33
commit 44327df370
6 changed files with 319 additions and 6 deletions

View file

@ -387,7 +387,7 @@ Dette gjelder også feilhåndtering: et feilet VED_FEIL-steg
blir en work_item som Claude Code løser i neste sesjon — blir en work_item som Claude Code løser i neste sesjon —
reparerer scriptet, legger til manglende verktøy, osv. reparerer scriptet, legger til manglende verktøy, osv.
## 7. Koblinger mellom orkestreringer ## 7. Koblinger mellom orkestreringer (kaskade)
Orkestreringer er noder med edges: Orkestreringer er noder med edges:
@ -400,6 +400,34 @@ Orkestreringer er noder med edges:
Output fra én orkestrering kan trigge neste via `triggers`-edge. Output fra én orkestrering kan trigge neste via `triggers`-edge.
Kaskade via edges, ikke hardkodet. Kaskade via edges, ikke hardkodet.
### Implementasjon (oppgave 24.8)
Etter vellykket pipeline-utførelse sjekker `handle_orchestrate()` om
orkestreringen har utgående `triggers`-edges. For hvert gyldig mål
(må være `node_kind = 'orchestration'`) legges en ny `orchestrate`-jobb
i køen med:
- `trigger_event: "cascade"` — skiller kaskade fra primær-triggere
- `trigger_context.upstream_orchestration_id` — ID til kilden
- `trigger_context.op: "CASCADE"` — for betingelsesmatching
- `cascade_chain` — liste med alle orchestration-IDer allerede utført
Nedstrøms script kan bruke `{event.upstream_orchestration_id}` for å
referere til oppstrøms orkestrering.
### Syklusdeteksjon
Kaskadekjeden (`cascade_chain`) spores som en array i jobb-payloaden.
Før enqueue av hvert mål sjekkes:
1. **Dybdegrense:** Maks 10 ledd i kjeden (konfigurerbart via `MAX_CASCADE_DEPTH`)
2. **Syklussjekk:** Target-ID finnes ikke allerede i kjeden (inkludert
orkestreringen som nettopp fullførte)
Blokkerte kaskader logges i `orchestration_log` med `status = 'skipped'`
og `tool_binary = 'cascade'`. Kaskade-feil er ikke-fatale — den
fullførte orkestreringen rapporteres fortsatt som suksess.
## 8. Brukergrensesnitt ## 8. Brukergrensesnitt
``` ```

View file

@ -76,6 +76,7 @@ valideres i maskinrommet.
| `source_material` | Kildemateriale (avledet node → kilde) | `{ "context": "quoted", "excerpt": "..." }` | | `source_material` | Kildemateriale (avledet node → kilde) | `{ "context": "quoted", "excerpt": "..." }` |
| `assigned_to` | Tildelt (work_item → person/agent) | — | | `assigned_to` | Tildelt (work_item → person/agent) | — |
| `observes` | Overvåker (orchestration → target node) | — | | `observes` | Overvåker (orchestration → target node) | — |
| `triggers` | Kaskade-kobling (orchestration → orchestration) | — |
| `derived_from` | Prosessert versjon av (f.eks. lydstudio-output → original) | — | | `derived_from` | Prosessert versjon av (f.eks. lydstudio-output → original) | — |
| `has_studio` | Studio-sesjon (sesjon → medienode) | — | | `has_studio` | Studio-sesjon (sesjon → medienode) | — |

View file

@ -378,14 +378,49 @@ async fn handle_orchestrate(
)); ));
} }
Ok(serde_json::json!({ let result_json = serde_json::json!({
"status": "executed", "status": "executed",
"orchestration_id": orch_id.to_string(), "orchestration_id": orch_id.to_string(),
"steps_compiled": compiled.steps.len(), "steps_compiled": compiled.steps.len(),
"steps_run": pipeline_result.steps_run, "steps_run": pipeline_result.steps_run,
"steps_ok": pipeline_result.steps_ok, "steps_ok": pipeline_result.steps_ok,
"technical": compiled.technical, "technical": compiled.technical,
})) });
// === Kaskade: triggers-edge til nedstrøms orkestreringer (oppgave 24.8) ===
let cascade_chain: Vec<String> = job
.payload
.get("cascade_chain")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
match crate::orchestration_trigger::enqueue_cascade(
db,
orch_id,
&cascade_chain,
&result_json,
)
.await
{
Ok(0) => {} // Ingen kaskade-mål
Ok(n) => {
tracing::info!(
orchestration_id = %orch_id,
cascade_targets = n,
"Kaskade: {n} nedstrøms orkestrering(er) lagt i kø"
);
}
Err(e) => {
// Kaskade-feil er ikke-fatal — selve orkestreringen lyktes
tracing::error!(
orchestration_id = %orch_id,
error = %e,
"Feil ved kaskade-evaluering (orkestreringen selv lyktes)"
);
}
}
Ok(result_json)
} }
/// Synops-render binary path. /// Synops-render binary path.

View file

@ -294,7 +294,8 @@ async fn enqueue_orchestration(
"source_id": ctx.source_id.map(|id| id.to_string()), "source_id": ctx.source_id.map(|id| id.to_string()),
"target_id": ctx.target_id.map(|id| id.to_string()), "target_id": ctx.target_id.map(|id| id.to_string()),
"op": ctx.op, "op": ctx.op,
} },
"cascade_chain": []
}); });
// Prioritet 5 = normal (mellom batch-jobber og brukerforespørsler) // Prioritet 5 = normal (mellom batch-jobber og brukerforespørsler)
@ -310,3 +311,245 @@ async fn enqueue_orchestration(
Ok(()) Ok(())
} }
// =============================================================================
// Kaskade: triggers-edge mellom orkestreringer (oppgave 24.8)
// =============================================================================
/// Maks kaskadedybde — sikring mot dype kjeder selv uten syklus.
const MAX_CASCADE_DEPTH: usize = 10;
/// Etter vellykket orkestrering: finn `triggers`-edges til andre
/// orkestreringer og legg dem i jobbkøen.
///
/// Syklusdeteksjon: `cascade_chain` inneholder alle orchestration-IDer
/// som allerede er utført i denne kaskaden. Hvis target allerede finnes
/// i kjeden, logges det og target hoppes over.
///
/// Output fra den fullførte orkestreringen sendes videre som
/// `upstream_output` i trigger_context, slik at nedstrøms orkestreringer
/// kan bruke `{event.upstream_*}`-variabler.
pub async fn enqueue_cascade(
db: &PgPool,
completed_orchestration_id: Uuid,
cascade_chain: &[String],
pipeline_result: &serde_json::Value,
) -> Result<usize, String> {
// Sjekk dybdegrense
if cascade_chain.len() >= MAX_CASCADE_DEPTH {
tracing::warn!(
orchestration_id = %completed_orchestration_id,
depth = cascade_chain.len(),
max = MAX_CASCADE_DEPTH,
"Kaskade-dybdegrense nådd — stopper videre kaskade"
);
return Ok(0);
}
// Finn alle triggers-edges fra denne orkestreringen
let targets = sqlx::query_as::<_, (Uuid,)>(
r#"
SELECT target_id FROM edges
WHERE source_id = $1
AND edge_type = 'triggers'
"#,
)
.bind(completed_orchestration_id)
.fetch_all(db)
.await
.map_err(|e| format!("Feil ved henting av triggers-edges: {e}"))?;
if targets.is_empty() {
return Ok(0);
}
// Bygg oppdatert kaskadekjede
let mut new_chain = cascade_chain.to_vec();
new_chain.push(completed_orchestration_id.to_string());
let mut enqueued = 0;
for (target_id,) in &targets {
let target_str = target_id.to_string();
// Syklusdeteksjon: er target allerede i kjeden?
if new_chain.contains(&target_str) {
tracing::warn!(
source = %completed_orchestration_id,
target = %target_id,
chain = ?new_chain,
"Syklus oppdaget i kaskade — hopper over"
);
// Logg syklusen i orchestration_log
let _ = sqlx::query(
r#"
INSERT INTO orchestration_log
(orchestration_id, job_id, step_number, tool_binary, args,
is_fallback, status, error_msg, duration_ms)
VALUES ($1, '00000000-0000-0000-0000-000000000000', 0,
'cascade', '[]'::jsonb, false, 'skipped',
$2, 0)
"#,
)
.bind(completed_orchestration_id)
.bind(format!(
"Syklus oppdaget: {} allerede i kaskadekjede {:?}",
target_id, new_chain
))
.execute(db)
.await;
continue;
}
// Verifiser at target faktisk er en orchestration-node
let is_orch = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM nodes WHERE id = $1 AND node_kind = 'orchestration'"
)
.bind(target_id)
.fetch_one(db)
.await
.map_err(|e| format!("Feil ved verifisering av kaskade-mål: {e}"))?;
if is_orch == 0 {
tracing::warn!(
target = %target_id,
"triggers-edge peker til ikke-orchestration-node — hopper over"
);
continue;
}
// Bygg payload med kaskade-kontekst
let payload = serde_json::json!({
"orchestration_id": target_str,
"trigger_event": "cascade",
"trigger_context": {
"node_id": pipeline_result.get("orchestration_id")
.and_then(|v| v.as_str()),
"node_kind": "orchestration",
"op": "CASCADE",
"upstream_orchestration_id": completed_orchestration_id.to_string(),
"upstream_steps_ok": pipeline_result.get("steps_ok"),
},
"cascade_chain": new_chain,
});
crate::jobs::enqueue(db, "orchestrate", payload, None, 5)
.await
.map_err(|e| format!("Feil ved enqueue av kaskade-jobb: {e}"))?;
tracing::info!(
source = %completed_orchestration_id,
target = %target_id,
depth = new_chain.len(),
"Kaskade: nedstrøms orkestrering lagt i kø"
);
enqueued += 1;
}
Ok(enqueued)
}
/// Hjelpefunksjon for testing: sjekk om en kaskade ville blitt blokkert
/// av syklusdeteksjon eller dybdegrense.
///
/// Returnerer `Ok(true)` hvis kaskaden er trygg, `Err(grunn)` hvis blokkert.
pub fn check_cascade_safety(
completed_id: &str,
target_id: &str,
cascade_chain: &[String],
) -> Result<bool, String> {
if cascade_chain.len() >= MAX_CASCADE_DEPTH {
return Err(format!(
"Dybdegrense ({MAX_CASCADE_DEPTH}) nådd ved kjede med {} elementer",
cascade_chain.len()
));
}
let mut chain = cascade_chain.to_vec();
chain.push(completed_id.to_string());
if chain.contains(&target_id.to_string()) {
return Err(format!(
"Syklus: {target_id} finnes allerede i kjede {chain:?}"
));
}
Ok(true)
}
// =============================================================================
// Tester
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cascade_no_cycle() {
let chain = vec!["aaa".to_string(), "bbb".to_string()];
assert!(check_cascade_safety("ccc", "ddd", &chain).is_ok());
}
#[test]
fn test_cascade_direct_cycle() {
// A → B → A: syklus!
let chain = vec!["aaa".to_string()];
let result = check_cascade_safety("bbb", "aaa", &chain);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Syklus"));
}
#[test]
fn test_cascade_self_trigger() {
// A → A: syklus via seg selv
let chain: Vec<String> = vec![];
let result = check_cascade_safety("aaa", "aaa", &chain);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Syklus"));
}
#[test]
fn test_cascade_indirect_cycle() {
// A → B → C → A: syklus
let chain = vec!["aaa".to_string(), "bbb".to_string()];
let result = check_cascade_safety("ccc", "aaa", &chain);
assert!(result.is_err());
}
#[test]
fn test_cascade_depth_limit() {
let chain: Vec<String> = (0..MAX_CASCADE_DEPTH)
.map(|i| format!("node-{i}"))
.collect();
let result = check_cascade_safety("node-next", "node-target", &chain);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Dybdegrense"));
}
#[test]
fn test_cascade_at_depth_limit_minus_one() {
// Nøyaktig under grensen — skal være OK
let chain: Vec<String> = (0..MAX_CASCADE_DEPTH - 1)
.map(|i| format!("node-{i}"))
.collect();
let result = check_cascade_safety("node-next", "node-target", &chain);
assert!(result.is_ok());
}
#[test]
fn test_cascade_empty_chain() {
// Første orkestrering i kjeden — ingen syklus mulig
let chain: Vec<String> = vec![];
assert!(check_cascade_safety("aaa", "bbb", &chain).is_ok());
}
#[test]
fn test_cascade_completed_equals_target_with_chain() {
// completed=B, target=B, chain=[A]: B trigger seg selv
let chain = vec!["aaa".to_string()];
let result = check_cascade_safety("bbb", "bbb", &chain);
assert!(result.is_err());
}
}

View file

@ -28,6 +28,8 @@ pub struct ExecutionContext {
pub cas_hash: Option<String>, pub cas_hash: Option<String>,
pub communication_id: Option<String>, pub communication_id: Option<String>,
pub collection_id: Option<String>, pub collection_id: Option<String>,
/// ID til oppstrøms orkestrering (ved kaskade via triggers-edge)
pub upstream_orchestration_id: Option<String>,
} }
impl ExecutionContext { impl ExecutionContext {
@ -45,6 +47,7 @@ impl ExecutionContext {
cas_hash: s("cas_hash"), cas_hash: s("cas_hash"),
communication_id: s("communication_id"), communication_id: s("communication_id"),
collection_id: s("collection_id"), collection_id: s("collection_id"),
upstream_orchestration_id: s("upstream_orchestration_id"),
} }
} }
@ -64,6 +67,7 @@ impl ExecutionContext {
"event.cas_hash" => self.cas_hash.clone(), "event.cas_hash" => self.cas_hash.clone(),
"event.communication_id" => self.communication_id.clone(), "event.communication_id" => self.communication_id.clone(),
"event.collection_id" => self.collection_id.clone(), "event.collection_id" => self.collection_id.clone(),
"event.upstream_orchestration_id" => self.upstream_orchestration_id.clone(),
_ => None, _ => None,
} }
.unwrap_or_else(|| arg.to_string()) .unwrap_or_else(|| arg.to_string())
@ -446,6 +450,7 @@ mod tests {
cas_hash: Some("sha256:abc".into()), cas_hash: Some("sha256:abc".into()),
communication_id: Some("comm-456".into()), communication_id: Some("comm-456".into()),
collection_id: Some("coll-789".into()), collection_id: Some("coll-789".into()),
upstream_orchestration_id: None,
}; };
assert_eq!(ctx.substitute("{event.node_id}"), "node-123"); assert_eq!(ctx.substitute("{event.node_id}"), "node-123");
@ -471,6 +476,7 @@ mod tests {
cas_hash: Some("sha256:abc".into()), cas_hash: Some("sha256:abc".into()),
communication_id: None, communication_id: None,
collection_id: None, collection_id: None,
upstream_orchestration_id: None,
}; };
let args = vec![ let args = vec![
@ -495,6 +501,7 @@ mod tests {
cas_hash: None, cas_hash: None,
communication_id: None, communication_id: None,
collection_id: None, collection_id: None,
upstream_orchestration_id: None,
}; };
// Manglende variabel beholder {event.*}-syntaks // Manglende variabel beholder {event.*}-syntaks

View file

@ -324,8 +324,7 @@ automatisk eskalering av intelligens ved feil, kompilering av velprøvde mønstr
- [x] 24.5 Script-executor: vaktmesteren parser kompilert script og eksekverer steg sekvensielt via generisk dispatch. VED_FEIL-håndtering. Logger i `orchestration_log`. - [x] 24.5 Script-executor: vaktmesteren parser kompilert script og eksekverer steg sekvensielt via generisk dispatch. VED_FEIL-håndtering. Logger i `orchestration_log`.
- [x] 24.6 Orchestration UI: editor med tre visninger (Enkel/Teknisk/Kompilert) som tabber. Sanntids kompileringsfeil. Trigger-velger, "Test kjøring"-knapp, kjørehistorikk. Ref: `docs/concepts/orkestrering.md`. - [x] 24.6 Orchestration UI: editor med tre visninger (Enkel/Teknisk/Kompilert) som tabber. Sanntids kompileringsfeil. Trigger-velger, "Test kjøring"-knapp, kjørehistorikk. Ref: `docs/concepts/orkestrering.md`.
- [x] 24.7 AI-assistert oppretting: `synops-ai` med auto-generert systemprompt (fra cli_tool-noder) foreslår script fra fritekst-beskrivelse. Vaktmesteren validerer. Eventually-modus: lagre som work_item for Claude Code. - [x] 24.7 AI-assistert oppretting: `synops-ai` med auto-generert systemprompt (fra cli_tool-noder) foreslår script fra fritekst-beskrivelse. Vaktmesteren validerer. Eventually-modus: lagre som work_item for Claude Code.
- [~] 24.8 Kaskade: `triggers`-edge mellom orkestreringer. Output fra én trigger neste. Syklusdeteksjon for å unngå uendelige loops. - [x] 24.8 Kaskade: `triggers`-edge mellom orkestreringer. Output fra én trigger neste. Syklusdeteksjon for å unngå uendelige loops.
> Påbegynt: 2026-03-18T17:50
- [ ] 24.9 Seed-orkestreringer: opprett standard-orkestreringer for podcast-pipeline, publiseringsflyt, og AI-beriking basert på eksisterende hardkodet logikk i vaktmesteren. Skrives i menneskelig scriptspråk. - [ ] 24.9 Seed-orkestreringer: opprett standard-orkestreringer for podcast-pipeline, publiseringsflyt, og AI-beriking basert på eksisterende hardkodet logikk i vaktmesteren. Skrives i menneskelig scriptspråk.
## Fase 25: Web Clipper — `synops-clip` ## Fase 25: Web Clipper — `synops-clip`