Skip to content

Le spie che solo un boot spegne, e i giri che nessuno contava - #85

Merged
zorahrel merged 10 commits into
mainfrom
topics/spie-e-giri-che-nessuno-conta
Sep 17, 2026
Merged

zorahrel merged 10 commits into
mainfrom
topics/spie-e-giri-che-nessuno-conta

Conversation

@zorahrel

Copy link
Copy Markdown
Contributor

Change approvata: openspec/changes/coda-che-riparte-da-sola (T4.3, T4.4, T4.5).

Cosa cambia

  • isChecksHold non si fida piu' della spia running nel DB, che solo un boot spegne. Se measure() lanciava prima della registrazione finale, il cancello cancellava la sua chiave ma la colonna restava running: da li' il giudice di stallo si riarmava all'infinito (1253 riarmi nel log) e lo spazzino StaleStream rispondeva sempre extend, quindi un turno muto non veniva mai finalizzato. 89 boot hanno trovato almeno una spia accesa. Ora decide il registro vivo del cancello, e la logica sta in checks-lights.ts con i suoi test invece che dentro server.ts, che di test non ne ha.
  • pending_deliveries conta i giri: un giro con le righe CI dura i comandi locali piu' fino a 65 minuti di poll, e ogni SIGTERM del watcher lo faceva ripartire da zero senza memoria di cosa fosse gia' verde. Il contatore ora riparte da zero su un commit nuovo, invece di ereditare i giri di una consegna diversa.
  • L'etichetta di un hold del provider dice la data quando non e' oggi. provider-hold.json conteneva un muro Codex messo il 13/09 e in scadenza il 19/09, e il log lo annunciava 43 volte come «resumes at 14:45»: un hold di ore e uno di sei giorni erano indistinguibili. E oltre una soglia l'hold diventa una domanda all'umano invece di un'attesa muta.

Verifica

Due giri di refuta avversaria. Il primo ha trovato una raffica di commenti che il fix stesso introduceva: planHeldNoted e' un Set che nasce vuoto a ogni processo, e con 44 riavvii in 25,7 ore e sette card in coda sarebbero stati circa 300 paragrafi identici al giorno — la stessa patologia che questa change esiste per chiudere. Riprodotto su 21 boot in 7 giorni: adesso e' una nota per card, e l'ultima dice «fra 2 giorni», non «fra 6».

Il secondo ha confermato ogni blocco chiuso rieseguendo la sequenza che lo provava, e ha verificato che ogni test nuovo e' rosso sul codice di origin/main. Due residui dichiarati e non nascosti: il cablaggio in server.ts resta senza test perche' server.ts non ha file di test, ed e' il buco strutturale che il ramo stesso cita; e check:spec-coverage non misura i requisiti di una change ancora aperta, quindi la copertura c'e' ma il cancello non la prova.

…per sempre

`isChecksHold` leggeva `checks_state === 'running'` come meta' di un OR, e
quella riga la spegne un solo punto: `clearStaleChecksRuns`, chiamata alla
costruzione della rotta, cioe' al boot. Se `measure()` lancia prima della
registrazione finale - `ChecksInterruptedError` del freno swap,
`throwIfStopping()`, un'eccezione qualsiasi - il gate cancella la sua chiave
e la riga resta «running»: da li' in poi il predicato e' vero per sempre per
quella sessione. 89 boot hanno trovato almeno una spia gia' accesa (71 volte
1, 16 volte 2, una 4, una 6), e i due orologi che lo leggono hanno pagato:
1253 riarmi del giudice di stallo e uno spazzino StaleStream che risponde
`extend` a ogni turno muto, quindi un turno morto non viene mai finalizzato.

Adesso decide il registro VIVO del gate e la riga lo conferma:
`clearStaleChecksRuns(isLive)` spegne solo le spie che il gate non conosce, e
la passata gira sui 30 secondi che lo spazzino paga gia'. La chiamata nuda del
boot resta com'era - li' il registro e' vuoto per costruzione - e una corsa che
sta macinando `test:unit` non viene toccata.
…o lo contasse

`resumePendingDeliveries` riemette la consegna che il riavvio ha tagliato, e
non contava niente: ogni giro ripartiva come il primo. Con
TOPICS_SERVER_WATCH=1 il SIGTERM arriva a ogni salvataggio sotto server/ (6
riavvii nell'ora 14/09T23, 4 nell'ora T20, 1-2 all'ora per tutto il 16/09) e
un giro con le righe CI costa i comandi locali - typecheck misurato 73-177 s,
piu' lint, deadcode e i rail statici - piu' fino a 65 minuti di poll su GitHub.
Dopo il #70 una consegna che aspetta solo la CI e' `deliveryOnlyWaitsOnChecks`,
quindi e' proprio quella che il cancello del riavvio lascia tagliare: il giro
poteva ricominciare all'infinito senza mai un verdetto e senza una riga sulla
card.

`pending_deliveries.rounds` conta i giri; oltre tre il boot non ne fa un
quarto, dimentica la riga e scrive sulla card che serve un commit nuovo o una
persona. Un commit diverso azzera il conto, perche' una riconsegna nuova non
eredita il debito di quella prima.
… solo l'ora

`holdUntilLabel` formattava ora e minuti, e l'ora da sola non distingue un
reset di finestra da un piano esaurito. Misurato il 17/09: provider-hold.json
teneva un muro Codex scritto il 13/09 alle 17:39 e in scadenza il 19/09 alle
14:45, e il log ha ripetuto «resumes at 14:45» 43 volte fra il 13/09T15:38 e
il 16/09T22:28 - ogni riga sembrava «nel pomeriggio». La board ha
dispatch_model=gpt-5.6-sol, quindi ci passano tutte le card.

Adesso l'etichetta porta la data quando la scadenza non e' oggi, e oltre le 24
ore l'attesa diventa una domanda: il motivo sulla card dice che e' il piano
esaurito e non il reset di una finestra, e la riga arriva anche nel thread, una
per episodio come fa gia' il tetto di spesa - l'altra attesa che non finisce da
sola. La nota va scritta PRIMA del chip: `noteHold` scrive lo stato `queued`
senza motivo, e nell'ordine inverso lasciava `dispatch_error` vuoto.
…ici al giorno

`planHeldNoted` vive nella closure del dispatcher, quindi nasce vuoto a ogni
processo, e l'unica altra difesa era la finestra di dedup di `addComment`: 10
secondi contro riavvii distanti ~35 minuti (44 in 25,7 h su questa macchina).
Con 7 card in coda dietro un muro Codex di 6 giorni fanno circa 300 paragrafi
identici al giorno nei thread, cioe' esattamente la pila che KANBAN-83 e i 314
commenti «Memoria quasi finita» esistono per chiudere.

Non basta `once`: la frase conta i giorni che restano, quindi viene RISCRITTA
una volta al giorno. `noteHold` prende ora uno slot opzionale, e chi ha
un'attesa che sopravvive a un riavvio passa il suo: `once` per il testo
identico, `replaces` per la stessa nota riformulata dal giorno dopo.

Provato con tre mutazioni sul test nuovo (cinque boot su tre giorni, stesso DB):
senza slot ne' once 3 paragrafi, con il solo once 3, con il solo slot la riga
viene riscritta a ogni boot e cambia id - cioe' `updated_at` toccato ogni 35
minuti, la colonna che KANBAN-84 ha gia' trovato a mentire sull'inattivita'.
`isChecksHold` e la passata periodica vivevano in `server.ts`, che non ha file
di test: la meta' che decide davvero - il predicato letto dal giudice di stallo
(1253 riarmi) e dalla passata StaleStream - era coperta da niente, mentre i
test del ramo si fermavano a `clearStaleChecksRuns`. Ora stanno in
`services/checks-lights.ts` con le dipendenze iniettate, e sono provati: con la
mutazione che rimette `checks_state === 'running'` accanto al registro il file
diventa rosso, e cosi' con la mutazione che toglie la guardia «rotta non ancora
costruita».

E la passata chiama `settleDelivery` su ogni spia che spegne. Senza, le due
meta' spingevano in direzioni opposte: T4.3 rende onesta la spia e lascia la
card parcheggiata senza giro (in-processo solo un `ChecksInterruptedError` con
motivo "swap" fa ripartire la consegna), mentre T4.4 concede ormai tre boot
prima di cancellarla. `settleDelivery` arriva a `server.ts` dallo stesso hook
che gia' porta il gate, ed e' un no-op per una card senza consegna in volo.
Due difetti nella stessa clausola `ON CONFLICT`, entrambi riprodotti nel file
di test nuovo (la tabella non ne aveva nessuno).

Primo: il reset pretendeva `pending_deliveries.commit_sha IS NOT NULL`, ma la
gamba `interrupted` scrive la riga mentre il server esce, PRIMA che il checkout
sia risolto - quindi la riga che accumula giri piu' facilmente e' proprio
quella con `commit_sha` NULL. I giri bruciati li ereditava la consegna VERA che
arrivava dopo, con un commit suo: abbandonata al primo boot senza averne fatto
uno. Un NULL memorizzato vuol dire «non l'abbiamo mai saputo», non «stessa
consegna».

Secondo: nominare `rounds` nell'`ON CONFLICT` fa lanciare SQLite al PREPARE, e
il catch che protegge questa tabella si mangiava la scrittura intera. Su un DB
ripristinato da un backup precedente alla migration la riga non veniva mai
scritta e ogni riavvio perdeva la sua consegna - il contrario della promessa
del docstring. La colonna si sonda una volta per database; senza, si scrive lo
statement di prima e il contatore risponde zero, cioe' «riprendi come prima che
la tabella contasse».
La verifica avversaria ha misurato che `specs/kanban/spec.md` non aveva nessuna
occorrenza di `isChecksHold`, `pending_deliver`, `holdUntilLabel`, `hold` o
`running`: T4.3, T4.4 e T4.5 consegnavano una colonna nuova nel DB, un
abbandono terminale che parcheggia una card e un commento nuovo sul thread
senza uno scenario approvato dietro. KANBAN-86, 87 e 88 li scrivono, con i
numeri misurati e i casi che la verifica ha trovato (spia orfana, riga nata
senza commit, DB senza la colonna, cinque boot su tre giorni).

E KANBAN-84 toccava il contrario di quel che serve: dice che l'inattivita' si
misura solo sui segni che il lavoro lascia, ma `taskIdleDays` prende
`MAX(created_at) FROM task_comments` senza filtro su autore. Le note di
KANBAN-86 e 87 nascono proprio sulle card che nessuno lavora piu', quindi
sarebbero loro ad azzerare l'orologio dell'abbandono: il requisito ora lo dice,
con il suo scenario. Il codice sta in T2.1, che e' di un'altra tornata.
# Conflicts:
#	openspec/changes/coda-che-riparte-da-sola/specs/kanban/spec.md
#	server/services/task-dispatcher.ts
@zorahrel
zorahrel merged commit 0b09659 into main Sep 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant