Salta ai contenuti

Pipeline e automazione

Le pipeline sono la metà deterministica di Control Center. Un agente decide che cosa dire; una pipeline decide che cosa succede dopo, sempre nello stesso modo. Una pipeline è un DAG di passaggi che possono inviare agenti, eseguire script, creare un ramo in base a una condizione, eseguire un fan-out su una raccolta e passare dati tra un passaggio e l’altro: persistente, riprendibile e con costi tracciati.

Esistono per tre motivi: concatenare un processo ripetitivo in più passaggi, coordinare diversi agenti in sequenza o in parallelo e reagire a qualcosa che è accaduto senza che ci fosse una persona.

Un modello (PipelineDefinition) è il DAG dichiarativo: passaggi, input dichiarati e configurazione. Vive nel database dello spazio di lavoro stesso. Un’esecuzione (PipelineRun) è una singola esecuzione di quel modello e porta con sé un contenitore di stato modificabile, i totali di costo e token e lo stato di ogni passaggio.

Un modello ha molte esecuzioni. Le esecuzioni sopravvivono a un riavvio: il motore ricarica le esecuzioni in corso e le riprende da dove si erano fermate.

Due meccanismi di salvaguardia fanno deliberatamente fallire l’intera esecuzione anziché riprendere un passaggio, perché riprendere sarebbe peggio che fermarsi:

  • Un passaggio rimasto suspended più a lungo di suspendedStepTimeout (24 ore per impostazione predefinita) sta aspettando ticket che non raggiungeranno mai uno stato terminale, quindi viene fatto fallire per liberare l’esecuzione. L’eccezione è extras['awaitApproval'] == true, ovvero il gate di approvazione parziale orchestration.awaitApproval. human.gate non è esente.
  • Un passaggio dichiarato extras: {'idempotent': false} che era in corso quando il server si è fermato potrebbe aver già applicato il proprio effetto collaterale (uno squash merge, la pulizia di un worktree). Rieseguirlo potrebbe applicarlo due volte, quindi il motore lo fa fallire e lascia a te il retry.

Un passaggio ha due parti ortogonali, e tenerle separate è ciò che permette al motore di restare piccolo.

Il tipo (StepKind) dice al motore quando un passaggio diventa eseguibile rispetto ai passaggi a monte: è l’unica cosa che lo scheduler deve sapere. Ce ne sono sei: trigger, listen, join, router, forEach e terminal. Il corpo (bodyKey) è il lavoro che il passaggio svolge una volta pianificato. I corpi sono closure registrate per chiave all’avvio del server, quindi molti passaggi di tipi diversi possono condividere un unico corpo: diversi passaggi listen che eseguono tutti conversation.promptAgent, per esempio.

Poiché la semantica di pianificazione risiede in sei tipi, il catalogo dei comportamenti può crescere senza che nessuno tocchi lo scheduler. Vedi Passaggi delle pipeline per il catalogo completo di tipi, corpi e relativa configurazione.

Vale la pena conoscere due conseguenze di questo design basato su registry prima di scrivere un modello a mano:

  • Un passaggio il cui bodyKey non è registrato produce un avviso al caricamento del modello e poi non viene mai eseguito. A runtime non fallisce nulla in modo evidente. I passaggi trigger e terminal sono esenti da questo avviso: il trigger esegue comunque il corpo no-op pipeline.trigger così i listener a valle si attivano; un passaggio terminal non ha corpo ed è una sentinella dello scheduler.
  • Le chiavi nella mappa extras di un nodo sono a corrispondenza esatta e in caso contrario vengono ignorate in silenzio. Solo extras['spaceId'] colloca il lavoro di un passaggio agente in una conversazione esistente. extras['mode'] (chat / plan / review) viene letto sul nodo che apre la stanza (messaging.createSpace, predefinito review): un passaggio agente successivo non può riassegnare i permessi di una conversazione a cui si è unito. Qualsiasi altra grafia dell’una o dell’altra chiave viene ignorata.

I passaggi non si chiamano tra loro. Leggono e scrivono su un contenitore di stato condiviso dell’esecuzione: un passaggio legge i propri input per chiave, viene eseguito e riscrive il proprio output sotto il suo outputKey. Un StateReducer unisce le scritture concorrenti quando rami paralleli puntano alla stessa chiave, e un TemplateRenderer sostituisce i segnaposto {{...}} nei prompt e negli script attingendo dallo stesso stato.

Ecco perché una pipeline è ispezionabile a posteriori: il contenitore di stato racconta tutta la storia dell’esecuzione.

Le righe PipelineTrigger dichiarano quando un modello si avvia automaticamente. Esistono quattro tipi:

Trigger Avvia un’esecuzione quando
Evento di dominio Si attiva un evento di dominio corrispondente — per esempio PullRequestPublished o TicketAssigned
Pianificazione Un’espressione cron/interval giunge a scadenza
Manuale La esegui a mano; la presenza di un trigger manuale abilitato è ciò che inserisce il modello nel selettore di esecuzione
Webhook Un sistema esterno invia una POST a /webhooks/<token> sulla porta HTTP del server

I trigger di evento sono un insieme chiuso: il dispatcher conosce 16 tipi di evento e li propone nel selettore (EventPayloadMapper.knownEventTypes), e nient’altro può avviare una pipeline. Quattordici di questi 16 hanno una mappatura del payload. Due — TicketCreated e TicketStatusChanged — non ne hanno, quindi un trigger su uno dei due resta abilitato e non si attiva mai. Vedi Eventi di dominio per l’elenco completo.

Il token del webhook funge anche da segreto HMAC: l’header X-Hub-Signature-256: sha256=<hex> della richiesta viene verificato rispetto a HMAC-SHA256(body, token), le consegne non verificate vengono rifiutate e non possono essere riprodotte, e i duplicati vengono scartati da dedupe_key. Poiché le righe dei trigger sono univoche su (spazio di lavoro, tipo di evento, modello), un modello può avere al massimo un trigger webhook e un trigger per tipo di evento di dominio.

I modelli integrati inizializzano i propri trigger manuali e di evento come abilitati; i trigger pianificati come disabilitati, quindi una scansione ricorrente è sempre opt-in.

Tredici modelli di pipeline vengono inizializzati in ogni spazio di lavoro e otto di essi arrivano disabilitati: external_pr_welcome, cross_review, ticket_to_pr, pr_triage, pre_merge_gate, release_notes, dep_audit e pr_digest.

Questa è la parte che sorprende. I loro trigger vengono inizializzati come abilitati, ma PipelineEngine.start rifiuta un modello disabilitato e restituisce null: così il trigger si attiva a vuoto e non compare alcuna esecuzione. L’interruttore è il modello, non il trigger. Abilitalo in Impostazioni → Spazio di lavoro → Modelli di pipeline prima di aspettarti che uno di quegli otto faccia qualcosa.

Re-inizializzare uno spazio di lavoro conserva le tue scelte: l’impostazione isEnabled e le righe dei trigger esistenti sopravvivono. C’è esattamente una deroga deliberata: la pianificazione giornaliera di pr_merged_cleanup viene forzata ad abilitata anche se in precedenza era disattivata, perché si tratta di garbage collection. Senza di essa, le righe dei worktree, le partizioni del grafo del codice e le copie copy-on-write si accumulano, e non è mai stato pensato come una scelta dell’utente.

Un’altra asimmetria da conoscere: il modello integrato pr_review di proposito non pubblica più su GitHub. La pubblicazione di una revisione è un passaggio separato, soggetto a controllo dell’utente, e una pipeline che pubblicasse direttamente lo farebbe due volte. cross_review, ticket_to_pr e pr_triage, invece, pubblicano ancora un commento direttamente su GitHub quando vengono eseguiti.

PipelineEngine crea il record dell’esecuzione, pianifica il nodo di avvio corrispondente al tipo di evento dell’esecuzione e poi percorre il DAG: man mano che i passaggi si completano, valuta i trigger a valle, risolve i rami del router, attende i join, applica le policy di retry e il continue-on-fail e persiste stato, costo ed errori a ogni passaggio. Gli archi che partono da un altro avvio vengono ignorati per quell’esecuzione, quindi un’attivazione pianificata non attende una condizione solo manuale.

Ciò che non sa è qualsiasi cosa riguardante i ticket, l’invio, GitHub o la messaggistica. Il suo unico punto di estensione è PipelineBodyRegistry. I corpi registrati lì sono ciò che contiene AgentDispatchPort, MessagingPort e i loro fratelli: ecco perché aggiungere una capacità alle pipeline non significa mai modificare lo scheduler.

Un passaggio che fallisce registra il proprio messaggio di errore e passa a failed. Se il passaggio dichiara continueOnFail, i passaggi a valle proseguono e l’errore viene archiviato nello stato dell’esecuzione; altrimenti l’esecuzione fallisce. Un passaggio con una StepRetryPolicy viene ritentato per prima, con backoff lineare o esponenziale, prima che si applichi l’una o l’altra di queste regole.