@sylad/cadence 1.34.1 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/NOTICE +28 -0
- package/README.md +1 -1
- package/agents/qa-reviewer.md +5 -4
- package/agents/spec-writer.md +2 -0
- package/agents/ux-reviewer.md +28 -0
- package/dist/cli.js +13 -29
- package/dist/config.js +8 -21
- package/dist/corrections-sans-code.js +12 -0
- package/dist/deliver-ci.js +105 -0
- package/dist/deliver.js +61 -79
- package/dist/gate-config.js +47 -0
- package/dist/orchestrate/accept.js +5 -2
- package/dist/orchestrate/ajout.js +186 -0
- package/dist/orchestrate/baseline.js +57 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +118 -140
- package/dist/orchestrate/cycle.js +69 -55
- package/dist/orchestrate/depot.js +33 -0
- package/dist/orchestrate/gate-notes.js +27 -0
- package/dist/orchestrate/gate-parts.js +135 -0
- package/dist/orchestrate/gate-place.js +66 -0
- package/dist/orchestrate/lot-budget.js +50 -0
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/retrait.js +37 -0
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/state.js +48 -10
- package/dist/orchestrate/tiers.js +9 -0
- package/dist/orchestrate/workers.js +31 -4
- package/dist/plan-commit.js +4 -2
- package/dist/plan-uncommitted.js +26 -0
- package/dist/release.js +9 -7
- package/dist/remote-gate.js +278 -0
- package/dist/state.js +4 -0
- package/dist/verify-gate-remote.js +55 -0
- package/dist/verify-gate.js +37 -28
- package/docs/corrections.md +4 -0
- package/docs/deliver.md +22 -2
- package/docs/lead.md +3 -1
- package/docs/news.md +2 -4
- package/docs/orchestrate.md +22 -0
- package/docs/overview.md +1 -0
- package/docs/raf.md +3 -3
- package/docs/releasing.md +17 -8
- package/docs/remote-gate.md +89 -0
- package/docs/skills.md +5 -2
- package/docs/whats-new.md +6 -0
- package/package.json +3 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +3 -1
|
@@ -27,12 +27,15 @@ import { costlyHypotheses, specScreen, specText } from './spec.js';
|
|
|
27
27
|
import { fichesDuDossier } from '../spec.js';
|
|
28
28
|
import { candidates, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
|
|
29
29
|
import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
30
|
-
import {
|
|
30
|
+
import { installPrePush, keepGateGuard, longDuration, removePrePush } from './guard.js';
|
|
31
31
|
import { buildArgs, buildLocalArgs, killSessions, MODEL_ALIASES, mcpServersFor, readAgents, realClaude, enabledPlugins, settingsFiles } from './launch.js';
|
|
32
32
|
import { activeLock, REPO_LOCK, releaseLock, takeLock } from './lock.js';
|
|
33
33
|
import { groupLots, runPool } from './pool.js';
|
|
34
|
+
import { AddFeed, addNotAlone, addToLive, openInRepo, projectName } from './ajout.js';
|
|
35
|
+
import { repoRefusals, trackedDirty } from './depot.js';
|
|
36
|
+
import { removeWorktree } from './retrait.js';
|
|
34
37
|
import { repoBranch } from './integrate.js';
|
|
35
|
-
import { baseBranchOf, keptLines,
|
|
38
|
+
import { baseBranchOf, keptLines, lotBranch, lotTreeDir, planFileSet, recoverTree } from './worktree.js';
|
|
36
39
|
import { agentOf, schemaFor } from './schemas.js';
|
|
37
40
|
import { resolveLotRepos } from '../repos.js';
|
|
38
41
|
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
@@ -42,55 +45,8 @@ import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveMo
|
|
|
42
45
|
const REPO_WAIT_MS = 3 * 60 * 60_000;
|
|
43
46
|
const REPO_WAIT_POLL_MS = 5_000;
|
|
44
47
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
* où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
|
|
48
|
-
* 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
|
|
49
|
-
* de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
|
|
50
|
-
* Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
|
|
51
|
-
* Relevé à 600 k (L247), mesuré sur 16–22-10 : 145 lots à 540 k, p90 451 k, 3 au-dessus (tous maritime-atlas : 12 lots, p90 556 k, max 609 k) ; la marge
|
|
52
|
-
* avant refus du fix passe à 445 k. Les lots à 750 k (60 lots, p90 571 k, 0 dépassement) et à 1,3 M (20 lots, p90 904 k, 1 dépassement) tiennent : inchangés.
|
|
53
|
-
* Remesuré sur 17–23-10 (L280, `docs/perf/recalage-2026-10-24.md`) : 0 lot sur 309 au-dessus de son budget, p90 452 k à 600 k, 563 k à 750 k, 875 k à 1,3 M : tous inchangés, avec au moins 10 % de marge.
|
|
54
|
-
*/
|
|
55
|
-
const LOT_BUDGET_FLOOR = 600_000;
|
|
56
|
-
/** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
|
|
57
|
-
const LOT_BUDGET_ONE_DAY = 750_000;
|
|
58
|
-
/** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
|
|
59
|
-
const LOT_BUDGET_PER_DAY = 570_000;
|
|
60
|
-
/**
|
|
61
|
-
* Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
|
|
62
|
-
* L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
|
|
63
|
-
* conformité ni les passes de correction.
|
|
64
|
-
*/
|
|
65
|
-
const SCREEN_LOT_BUDGET = 1_300_000;
|
|
66
|
-
/**
|
|
67
|
-
* Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
|
|
68
|
-
* l'estimate d'écriture. Relevée de 350 k à 450 k (L218) : la chaîne avant implement du lot spec warhammer L120 a coûté 388 k (350 k : pire cas
|
|
69
|
-
* mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
|
|
70
|
-
*/
|
|
71
|
-
export const SPEC_STAGE_ALLOCATION = 450_000;
|
|
72
|
-
/** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
|
|
73
|
-
export function lotBudgetBasis(estimate, stage, escalationModel) {
|
|
74
|
-
const base = ordinaryBasis(estimate, stage);
|
|
75
|
-
return escalationModel ? `${base}, plus ${ESCALATION_RESERVE} réservés à l'escalade ${escalationModel}` : base;
|
|
76
|
-
}
|
|
77
|
-
function ordinaryBasis(estimate, stage) {
|
|
78
|
-
if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
|
|
79
|
-
return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
|
|
80
|
-
const ordinary = lotBudget(estimate);
|
|
81
|
-
const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
|
|
82
|
-
if (!stage)
|
|
83
|
-
return basis;
|
|
84
|
-
return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
|
|
85
|
-
}
|
|
86
|
-
/** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
|
|
87
|
-
export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
|
|
88
|
-
const lotBaseBudget = (estimate, stage) => {
|
|
89
|
-
const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
|
|
90
|
-
if (stage === 'screen')
|
|
91
|
-
return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
|
|
92
|
-
return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
|
|
93
|
-
};
|
|
48
|
+
export { lotBudget, lotBudgetBasis, SPEC_STAGE_ALLOCATION } from './lot-budget.js';
|
|
49
|
+
import { lotBudget, lotBudgetBasis } from './lot-budget.js';
|
|
94
50
|
/** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
|
|
95
51
|
export const DEFAULT_MAX_SESSIONS = 2;
|
|
96
52
|
/** `1500000`, `1.5M`, `800k`. */
|
|
@@ -137,7 +93,7 @@ function lotBudgetArg(text) {
|
|
|
137
93
|
return { project: target.project, lot: target.lot, raise, text: m[2] };
|
|
138
94
|
}
|
|
139
95
|
export function parseOrchestrateArgs(argv) {
|
|
140
|
-
const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, integrate: [], abandonCommits: false };
|
|
96
|
+
const a = { lots: [], lotBudgets: [], dryRun: false, waits: false, watch: false, continue: false, answers: [], drop: [], stopAfterCurrent: false, add: [], integrate: [], abandonCommits: false };
|
|
141
97
|
const optional = (i) => (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && !LOT_LIKE.test(argv[i + 1]) ? argv[i + 1] : undefined);
|
|
142
98
|
for (let i = 0; i < argv.length; i++) {
|
|
143
99
|
const t = argv[i];
|
|
@@ -191,6 +147,8 @@ export function parseOrchestrateArgs(argv) {
|
|
|
191
147
|
a.drop.push(lotArg(value(t)));
|
|
192
148
|
else if (t === '--stop-after-current')
|
|
193
149
|
a.stopAfterCurrent = true;
|
|
150
|
+
else if (t === '--add')
|
|
151
|
+
a.add.push(lotArg(value(t)));
|
|
194
152
|
else if (t === '--integrate')
|
|
195
153
|
a.integrate.push(lotArg(value(t)));
|
|
196
154
|
else if (t === '--replay')
|
|
@@ -230,12 +188,12 @@ export function projectEnv(repo) {
|
|
|
230
188
|
return { config: readOrchestrateConfig(configPath), loadPlan, configPath };
|
|
231
189
|
}
|
|
232
190
|
const isProject = (dir) => existsSync(join(dir, PLAN_DEFAULT)) || existsSync(join(dir, PLAN_LEGACY)) || (existsSync(join(dir, 'cadence.yaml')) && /^plan\s*:/m.test(readFileSync(join(dir, 'cadence.yaml'), 'utf8')));
|
|
233
|
-
function resolveTargets(args, io, refusals) {
|
|
191
|
+
export function resolveTargets(args, io, refusals) {
|
|
234
192
|
const out = [];
|
|
235
193
|
const seen = new Set();
|
|
236
194
|
for (const l of args.lots) {
|
|
237
195
|
const projectDir = l.project ? resolve(io.cwd, l.project) : io.cwd;
|
|
238
|
-
const project =
|
|
196
|
+
const project = projectName(io.cwd, l.project); // L421 : `.:L2` depuis le dépôt et `L2` désignent le même projet que les lots de la vague
|
|
239
197
|
const key = lotKey(project, l.lot);
|
|
240
198
|
if (seen.has(key)) {
|
|
241
199
|
refusals.push(`${key} : lot donné deux fois`);
|
|
@@ -259,9 +217,6 @@ function resolveTargets(args, io, refusals) {
|
|
|
259
217
|
}
|
|
260
218
|
return out;
|
|
261
219
|
}
|
|
262
|
-
async function trackedDirty(repo) {
|
|
263
|
-
return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
|
|
264
|
-
}
|
|
265
220
|
/** `--effort` date de claude 2.1.284 (L137). Une version illisible n'est pas refusée. */
|
|
266
221
|
function olderThanEffort(version) {
|
|
267
222
|
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
@@ -275,7 +230,7 @@ function notLoggedIn(deps) {
|
|
|
275
230
|
return deps.claudeAuth?.()?.loggedIn === false ? "claude n'est pas connecté : lancer `claude` puis /login (état : `claude auth status`)" : null;
|
|
276
231
|
}
|
|
277
232
|
/** Préconditions : tout ce qui peut être refusé l'est ici, avant d'agir, sans verrou ni écriture. */
|
|
278
|
-
async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
233
|
+
export async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
279
234
|
const refusals = [];
|
|
280
235
|
if (io.env.CADENCE_ORCHESTRATED)
|
|
281
236
|
refusals.push(`orchestrate ne se lance pas depuis une session orchestrée (vague ${io.env.CADENCE_ORCHESTRATED})`);
|
|
@@ -290,8 +245,16 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
290
245
|
const lots = [];
|
|
291
246
|
const repoChecked = new Set();
|
|
292
247
|
const byProject = new Map();
|
|
248
|
+
// L421 : les lots déjà portés par la vague qui reçoit l'ajout comptent comme « plus tôt dans la vague » pour les dépendances.
|
|
249
|
+
for (const l of opts.addTo?.lots ?? [])
|
|
250
|
+
byProject.set(l.project, [...(byProject.get(l.project) ?? []), l.lot]);
|
|
293
251
|
for (const t of targets) {
|
|
294
252
|
const key = lotKey(t.project, t.lot);
|
|
253
|
+
const already = opts.addTo?.lots.find((l) => l.project === t.project && l.lot === t.lot);
|
|
254
|
+
if (already) {
|
|
255
|
+
refusals.push(`${key} : lot déjà ${already.queued ? "en file d'ajout dans" : 'dans'} la vague ${opts.addTo.wave}`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
295
258
|
let env;
|
|
296
259
|
let plan;
|
|
297
260
|
try {
|
|
@@ -345,18 +308,7 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
345
308
|
if (repoChecked.has(repo))
|
|
346
309
|
continue;
|
|
347
310
|
repoChecked.add(repo);
|
|
348
|
-
|
|
349
|
-
if (dirty.length)
|
|
350
|
-
refusals.push(`${basename(repo)} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
|
|
351
|
-
const locked = indexLockRefusal(repo, io.now(), deps.gitRunning ?? gitProcessRunning);
|
|
352
|
-
if (locked)
|
|
353
|
-
refusals.push(`${basename(repo)} : ${locked}`);
|
|
354
|
-
const hook = canInstallPrePush(repo);
|
|
355
|
-
if (hook)
|
|
356
|
-
refusals.push(`${basename(repo)} : ${hook}`);
|
|
357
|
-
const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
|
|
358
|
-
if (busy && !opts.resume)
|
|
359
|
-
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
311
|
+
refusals.push(...(await repoRefusals(repo, t, { io, deps, launch }, opts)));
|
|
360
312
|
}
|
|
361
313
|
const node = resolveNode(t.repo, nvmVersionsDir(io.env));
|
|
362
314
|
if (node.kind === 'missing') {
|
|
@@ -670,39 +622,6 @@ function waveExists(wave, io) {
|
|
|
670
622
|
io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
|
|
671
623
|
return 2;
|
|
672
624
|
}
|
|
673
|
-
/**
|
|
674
|
-
* `--remove-worktree projet:lot` (L279, R20) : seule commande qui retire un worktree conservé. Refus (code 2, rien retiré) pour le worktree d'une vague vivante (même avec `--abandon-commits`), et tant qu'un commit du lot n'est ni
|
|
675
|
-
* intégré à la branche du dépôt ni confirmé abandonné par `--abandon-commits` ; un worktree entièrement intégré se retire sans option. `--wave` désigne la vague
|
|
676
|
-
* quand plusieurs ont laissé un worktree pour ce lot.
|
|
677
|
-
*/
|
|
678
|
-
function removeWorktree(args, io) {
|
|
679
|
-
const t = args.removeWorktree;
|
|
680
|
-
const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
|
|
681
|
-
const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
|
|
682
|
-
const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
|
|
683
|
-
const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
|
|
684
|
-
if (!repo || kept.length === 0) {
|
|
685
|
-
io.err(`orchestrate : ${key} : aucun worktree conservé`);
|
|
686
|
-
return 2;
|
|
687
|
-
}
|
|
688
|
-
if (kept.length > 1) {
|
|
689
|
-
io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
|
|
690
|
-
return 2;
|
|
691
|
-
}
|
|
692
|
-
const k = kept[0];
|
|
693
|
-
if (k.active) {
|
|
694
|
-
// une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
|
|
695
|
-
io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
|
|
696
|
-
return 2;
|
|
697
|
-
}
|
|
698
|
-
const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
|
|
699
|
-
if (!gone.ok) {
|
|
700
|
-
io.err(gone.dirty ? `orchestrate : ${key} : fichiers suivis modifiés dans le worktree (${gone.dirty.join(', ')}), --abandon-commits pour les abandonner` : gone.untracked ? `orchestrate : ${key} : fichiers non suivis dans le worktree (${gone.untracked.join(', ')}), --abandon-commits pour les abandonner` : `orchestrate : ${key} : ${gone.unintegrated} commit(s) non intégré(s), --abandon-commits pour les abandonner`);
|
|
701
|
-
return 2;
|
|
702
|
-
}
|
|
703
|
-
io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
|
|
704
|
-
return 0;
|
|
705
|
-
}
|
|
706
625
|
/** L382 : `claude auth status` peut durer jusqu'à 20 s ; en --continue, preflight() le relancerait pour chaque lot candidat de chaque tour. Une seule lecture par lancement (une vague). */
|
|
707
626
|
function onceAuth(deps) {
|
|
708
627
|
const read = deps.claudeAuth;
|
|
@@ -711,11 +630,47 @@ function onceAuth(deps) {
|
|
|
711
630
|
let cached;
|
|
712
631
|
return { ...deps, claudeAuth: () => (cached ??= { value: read() }).value };
|
|
713
632
|
}
|
|
633
|
+
/** `--status` (L395) : le tableau des vagues vivantes et de la vague suivie, une fois ou rafraîchi avec `--watch` jusqu'à ce que plus rien ne tourne. */
|
|
634
|
+
async function statusCommand(args, io, deps, launch) {
|
|
635
|
+
maxSessions(args, io); // une valeur invalide est refusée ici aussi
|
|
636
|
+
const view = () => statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
637
|
+
if (!args.watch) {
|
|
638
|
+
const { live, table } = view();
|
|
639
|
+
printStatus(io, live, table); // sans identifiant (pas de tableau) : les vagues vivantes suffisent
|
|
640
|
+
return 0;
|
|
641
|
+
}
|
|
642
|
+
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
643
|
+
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
644
|
+
const seconds = args.interval ?? DEFAULT_WATCH_INTERVAL;
|
|
645
|
+
for (;;) {
|
|
646
|
+
const { live, table, running } = view();
|
|
647
|
+
io.out(CLEAR_SCREEN);
|
|
648
|
+
printStatus(io, live, table);
|
|
649
|
+
if (!running)
|
|
650
|
+
return 0;
|
|
651
|
+
io.out('');
|
|
652
|
+
io.out(`rafraîchi toutes les ${seconds} s — Ctrl-C pour quitter`);
|
|
653
|
+
await sleep(seconds * 1000);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/** Les vagues vivantes, puis (s'il y en a un) le tableau de la vague suivie, séparés d'une ligne vide. */
|
|
657
|
+
function printStatus(io, live, table) {
|
|
658
|
+
for (const line of live)
|
|
659
|
+
io.out(line);
|
|
660
|
+
if (!table)
|
|
661
|
+
return;
|
|
662
|
+
if (live.length)
|
|
663
|
+
io.out('');
|
|
664
|
+
for (const line of table)
|
|
665
|
+
io.out(line);
|
|
666
|
+
}
|
|
714
667
|
/** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
|
|
715
668
|
export async function orchestrate(argv, io, deps) {
|
|
716
669
|
deps = onceAuth(deps);
|
|
717
670
|
const args = parseOrchestrateArgs(argv);
|
|
718
671
|
const launch = io.cwd;
|
|
672
|
+
if (args.add.length && addNotAlone(args))
|
|
673
|
+
throw new RafError('--add ne se combine avec aucune autre action (seul --wave désigne la vague qui reçoit les lots)');
|
|
719
674
|
const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
|
|
720
675
|
if ((args.until !== undefined || args.priority !== undefined) && !args.continue)
|
|
721
676
|
throw new RafError('--until et --priority s\'utilisent avec --continue');
|
|
@@ -767,50 +722,20 @@ export async function orchestrate(argv, io, deps) {
|
|
|
767
722
|
io.out(line);
|
|
768
723
|
return 0;
|
|
769
724
|
}
|
|
770
|
-
if (args.status !== undefined)
|
|
771
|
-
|
|
772
|
-
if (!args.watch) {
|
|
773
|
-
const { live, table } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
774
|
-
for (const line of live)
|
|
775
|
-
io.out(line);
|
|
776
|
-
if (!table)
|
|
777
|
-
return 0; // sans identifiant : les vagues vivantes suffisent
|
|
778
|
-
if (live.length)
|
|
779
|
-
io.out('');
|
|
780
|
-
for (const line of table)
|
|
781
|
-
io.out(line);
|
|
782
|
-
return 0;
|
|
783
|
-
}
|
|
784
|
-
// --watch : le même tableau, rafraîchi ; il s'arrête seul quand plus aucune vague ne tourne (le dernier rendu reste affiché)
|
|
785
|
-
const sleep = deps.watchSleep ?? ((ms) => new Promise((res) => setTimeout(res, ms)));
|
|
786
|
-
for (;;) {
|
|
787
|
-
const { live, table, running } = statusView(args, launch, configuredCap(io.env, args.maxSessions), io.now());
|
|
788
|
-
io.out(CLEAR_SCREEN);
|
|
789
|
-
for (const line of live)
|
|
790
|
-
io.out(line);
|
|
791
|
-
if (table) {
|
|
792
|
-
if (live.length)
|
|
793
|
-
io.out('');
|
|
794
|
-
for (const line of table)
|
|
795
|
-
io.out(line);
|
|
796
|
-
}
|
|
797
|
-
if (!running)
|
|
798
|
-
return 0;
|
|
799
|
-
io.out('');
|
|
800
|
-
io.out(`rafraîchi toutes les ${args.interval ?? DEFAULT_WATCH_INTERVAL} s — Ctrl-C pour quitter`);
|
|
801
|
-
await sleep((args.interval ?? DEFAULT_WATCH_INTERVAL) * 1000);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
725
|
+
if (args.status !== undefined)
|
|
726
|
+
return statusCommand(args, io, deps, launch);
|
|
804
727
|
if (args.removeWorktree)
|
|
805
728
|
return removeWorktree(args, io);
|
|
806
729
|
if (args.resume !== undefined)
|
|
807
730
|
return resume(args, argv, io, deps, launch, today);
|
|
731
|
+
if (args.add.length)
|
|
732
|
+
return addOnlyLive(args, io, deps, launch);
|
|
808
733
|
if (args.drop.length || args.stopAfterCurrent)
|
|
809
734
|
return control(args, io);
|
|
810
735
|
if (args.answers.length)
|
|
811
736
|
return answerOnly(args, io);
|
|
812
737
|
if (args.lots.length === 0 && !args.continue)
|
|
813
|
-
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--detail projet:lot | --watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
|
|
738
|
+
throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] [--continue [--until 18:00] [--priority a,b]] | --waits [--since AAAA-MM-JJ] | --status [vague] [--detail projet:lot | --watch [--interval 10]] | --resume [vague] [--answer projet:lot "réponse"] [--integrate projet:lot] [--replay projet:lot] | --add projet:lot [--wave vague] | --drop projet:lot | --stop-after-current | --remove-worktree projet:lot [--abandon-commits]');
|
|
814
739
|
const refusals = [];
|
|
815
740
|
const targets = resolveTargets(args, io, refusals);
|
|
816
741
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -1029,6 +954,8 @@ async function continueRounds(first, wave, all, store, io, deps, today, args, la
|
|
|
1029
954
|
const cap = maxSessions(args, io);
|
|
1030
955
|
let code = first;
|
|
1031
956
|
for (;;) {
|
|
957
|
+
for (const l of all)
|
|
958
|
+
exclude.add(lotKey(l.project, l.lot)); // L421 : les lots ajoutés à la main pendant la vague ne sont pas tirés une seconde fois
|
|
1032
959
|
const questions = all.filter((l) => l.status === 'question').length;
|
|
1033
960
|
const remaining = wave.status === 'suspended-budget' ? 0 : wave.budget - wave.consumed;
|
|
1034
961
|
const why = stopReason({ now: io.now(), until: args.untilAt, quotaHit: wave.status === 'suspended-quota', questions, streak: handedBackStreak(all), interrupted: wave.status === 'interrupted' && questions === 0, stopRequested: store.control().stopAfterCurrent, remaining });
|
|
@@ -1075,6 +1002,8 @@ function planTrees(ctxs, store, wave, log) {
|
|
|
1075
1002
|
if (why)
|
|
1076
1003
|
log(`orchestrate : ${basename(repo)} : worktrees demandés mais ${why} : lots joués à la suite`);
|
|
1077
1004
|
for (const c of group) {
|
|
1005
|
+
if (c.tree)
|
|
1006
|
+
continue; // L421 : un lot ajouté en cours de vague replanifie son groupe ; les arbres déjà posés (et leur état actif) ne sont pas refaits
|
|
1078
1007
|
const lotBr = lotBranch(wave, c.lot.project, c.lot.lot);
|
|
1079
1008
|
// Chemin de reprise d'un lot rendu hors intégration : sa branche d'une vague passée devient celle de cette vague (recoverTree), puis se retrouve comme une reprise (R11).
|
|
1080
1009
|
const dir = lotTreeDir(store.dir, c.lot.project, c.lot.lot, repo);
|
|
@@ -1240,6 +1169,22 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1240
1169
|
catch {
|
|
1241
1170
|
// registre illisible : le décompte des créneaux libres reste celui des dépôts
|
|
1242
1171
|
}
|
|
1172
|
+
const adds = new AddFeed({
|
|
1173
|
+
store, wave, wctx, io, deps, launch, ctxs, all, lots, held, freed, repos, freeRepo, saveWave, checks: LAUNCH_CHECKS,
|
|
1174
|
+
stopCause: () => (wctx.incident ? 'incident' : wctx.quota.hit ? "limite d'usage atteinte" : budget.exhausted ? 'budget de la vague épuisé' : store.control().stopAfterCurrent ? "la vague s'arrête" : null),
|
|
1175
|
+
budgetLeft: () => budget.limit - budget.consumed,
|
|
1176
|
+
planState: (c) => {
|
|
1177
|
+
try {
|
|
1178
|
+
const lot = projectEnv(c.lot.repo).loadPlan().lots().find((l) => l.id === c.lot.lot);
|
|
1179
|
+
return !lot ? 'lot inconnu' : !isOpen(lot.status) ? `le lot est ${lot.status}` : null;
|
|
1180
|
+
}
|
|
1181
|
+
catch (e) {
|
|
1182
|
+
return e.message;
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1185
|
+
announce: () => updateWaveReposSafe(home, repos.filter((x) => !freed.has(x)), lanes()),
|
|
1186
|
+
build: (t, known) => buildAdd(t, known, { wave: wave.id, ctxs, wctx, store, io, deps, launch }),
|
|
1187
|
+
});
|
|
1243
1188
|
// Ctrl-C, SIGTERM : les sessions sont tuées en bloc, les étapes marquées interrompues, les verrous et hooks libérés.
|
|
1244
1189
|
const forget = onTermination(async () => {
|
|
1245
1190
|
killSessions();
|
|
@@ -1293,7 +1238,7 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1293
1238
|
}, (lots) => lots.every((c) => c.tree), {
|
|
1294
1239
|
pollMs: deps.slotPollMs ?? ANSWER_POLL_MS,
|
|
1295
1240
|
// L407 : les réponses déposées par `--answer` pendant la vague sont prises entre deux étapes ; un lot qui n'est plus en question ne rejoue rien.
|
|
1296
|
-
take: (canRun) => {
|
|
1241
|
+
take: async (canRun) => {
|
|
1297
1242
|
const taken = [];
|
|
1298
1243
|
for (const e of store.answerQueue()) {
|
|
1299
1244
|
const c = ctxs.find((x) => lotKey(x.lot.project, x.lot.lot) === e.lot);
|
|
@@ -1322,9 +1267,11 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1322
1267
|
// registre illisible : le décompte reste celui du dernier écrit
|
|
1323
1268
|
}
|
|
1324
1269
|
}
|
|
1325
|
-
return taken;
|
|
1270
|
+
return [...taken, ...(await adds.take(canRun))];
|
|
1326
1271
|
},
|
|
1327
1272
|
});
|
|
1273
|
+
// L421 : un ajout encore en file quand plus rien ne joue ne sera plus lu : écarté et dit.
|
|
1274
|
+
adds.drain('la vague a fini de jouer');
|
|
1328
1275
|
handBackDroppedQuestions(ctxs); // un lot en question retiré pendant la vague : rendu avant de fixer le statut de la vague
|
|
1329
1276
|
}
|
|
1330
1277
|
finally {
|
|
@@ -1384,6 +1331,37 @@ function answerLive(args, io, store) {
|
|
|
1384
1331
|
}
|
|
1385
1332
|
return 0;
|
|
1386
1333
|
}
|
|
1334
|
+
const LAUNCH_CHECKS = { resolveTargets, preflight: (targets, run, addTo) => preflight({ dryRun: false }, targets, run.io, run.deps, run.launch, { addTo }) };
|
|
1335
|
+
/** `--add` (L421) : la vague vivante de ce dossier reçoit les lots (le travail est dans `ajout.ts`). */
|
|
1336
|
+
async function addOnlyLive(args, io, deps, launch) {
|
|
1337
|
+
const store = liveStore('--add', args, io);
|
|
1338
|
+
return typeof store === 'number' ? store : addToLive(args, { io, deps, launch }, store, LAUNCH_CHECKS);
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Le contexte d'un lot ajouté en cours de vague (L421) : le contrôle préalable d'un lot de lancement (les lots de la vague comptent pour les dépendances), le lien du Node,
|
|
1342
|
+
* puis un worktree seulement si tous les lots encore à jouer de son dépôt y sont aussi ; sinon il attend la fin de ceux qui jouent dans l'arbre du dépôt.
|
|
1343
|
+
*/
|
|
1344
|
+
async function buildAdd(t, known, w) {
|
|
1345
|
+
const pre = await LAUNCH_CHECKS.preflight([t], w, { wave: w.wave, lots: known });
|
|
1346
|
+
const lot = pre.lots[0];
|
|
1347
|
+
if (pre.refusals.length || !lot)
|
|
1348
|
+
return { refusals: pre.refusals };
|
|
1349
|
+
if (lot.node)
|
|
1350
|
+
lot.node.link = (w.deps.linkNode ?? linkNodeBin)(w.store.dir, lot.node.version, lot.node.bin);
|
|
1351
|
+
const ctx = contexts([lot], [projectEnv(lot.repo)], w.wctx)[0];
|
|
1352
|
+
const open = openInRepo(w.ctxs, ctx);
|
|
1353
|
+
planTrees(open.every((o) => o.tree) ? [...open, ctx] : [ctx], w.store, w.wave, w.wctx.log);
|
|
1354
|
+
return { ctx };
|
|
1355
|
+
}
|
|
1356
|
+
/** Le registre des vagues vivantes : dépôts tenus et créneaux occupés ; illisible, le décompte reste celui du dernier écrit. */
|
|
1357
|
+
function updateWaveReposSafe(home, repos, lanes) {
|
|
1358
|
+
try {
|
|
1359
|
+
updateWaveRepos(home, process.pid, repos, lanes);
|
|
1360
|
+
}
|
|
1361
|
+
catch {
|
|
1362
|
+
// registre illisible : le décompte reste celui du dernier écrit
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1387
1365
|
/** La vague vivante, pour `--answer` seul (sans --resume) : la même recherche que `--drop`. */
|
|
1388
1366
|
function answerOnly(args, io) {
|
|
1389
1367
|
if (args.drop.length || args.stopAfterCurrent || args.budget !== undefined || args.dryRun || args.continue || args.lots.length)
|
|
@@ -18,8 +18,11 @@ import { citedRefs } from '../link.js';
|
|
|
18
18
|
import { repoShas, repoWork } from '../repos.js';
|
|
19
19
|
import { isOpen, RafError } from '../plan.js';
|
|
20
20
|
import { isPlanOnly } from '../audit.js';
|
|
21
|
-
import { compareBaseline, failedTests
|
|
22
|
-
import {
|
|
21
|
+
import { compareBaseline, failedTests } from './baseline.js';
|
|
22
|
+
import { noteTight, noteUnstable } from './gate-notes.js';
|
|
23
|
+
import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
|
|
24
|
+
import { baselineCandidates, gateLabel, gateReplayable, gateUnnamed, redAloneGate, redCandidates, replayGate, runGate } from './gate-parts.js';
|
|
25
|
+
import { withPlace } from './gate-place.js';
|
|
23
26
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
24
27
|
import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
|
|
25
28
|
import { SlotAbandoned } from './registry.js';
|
|
@@ -38,7 +41,7 @@ import { decisionsText, humanDecisions } from './decisions.js';
|
|
|
38
41
|
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
39
42
|
import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
|
|
40
43
|
import { mutexFor } from './mutex.js';
|
|
41
|
-
import { readTier, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
|
|
44
|
+
import { readTier, stillThird, takeTiers, tierMoved, tierRepos, wipBranch } from './tiers.js';
|
|
42
45
|
import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
|
|
43
46
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
44
47
|
export const MAX_PASSES = 2;
|
|
@@ -196,33 +199,49 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
|
|
|
196
199
|
* pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Avec `orchestrate.testReplay`, un rouge nommé y est rejoué seul (L354) avant d'être retenu.
|
|
197
200
|
* Null quand le worktree ne peut être monté : l'appelant garde alors les tests pour rouges.
|
|
198
201
|
*/
|
|
199
|
-
async function baselineFailures(c,
|
|
202
|
+
async function baselineFailures(c, test) {
|
|
200
203
|
const l = c.lot;
|
|
201
204
|
const sha = startSha(c);
|
|
205
|
+
const command = gateLabel(test); // clé de cache ; pour des parties (L512) le libellé « dir : run ; … », stable d'une lecture à l'autre
|
|
202
206
|
if (!sha)
|
|
203
207
|
return null;
|
|
204
208
|
if (l.baseline?.verified && l.baseline.sha === sha && l.baseline.command === command)
|
|
205
209
|
return l.baseline.failures;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
try {
|
|
209
|
-
git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
|
|
210
|
-
links.push(...linkIgnored(l.repo, dir));
|
|
211
|
-
// Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
|
|
212
|
-
// ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
|
|
213
|
-
if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
|
|
214
|
-
return null;
|
|
215
|
-
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
210
|
+
return atStartSha(c, sha, async (place) => {
|
|
211
|
+
const r = await runGate(place.run, test);
|
|
216
212
|
// Ligne de base comprise (L354) : un rouge du sha de départ est rejoué seul dans le même worktree avant d'être retenu. Sous charge, un test
|
|
217
|
-
// qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel.
|
|
213
|
+
// qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel ; en parties (L512), partie par partie.
|
|
218
214
|
const found = r.code === 0 ? [] : failedTests(r.output);
|
|
219
|
-
const named = c.config.testReplay && found.length
|
|
220
|
-
const { red: failures, unstable } = named.length ? await
|
|
215
|
+
const named = gateReplayable(test, c.config.testReplay) && found.length ? baselineCandidates(r) : [];
|
|
216
|
+
const { red: failures, unstable } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: found, unstable: [] };
|
|
221
217
|
if (unstable.length)
|
|
222
218
|
c.wave.log(`${lotKey(l.project, l.lot)} · ligne de base ${sha.slice(0, 7)} : ${unstable.length} test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) comme déjà rouge(s) : ${unstable.join(' ; ')}`);
|
|
223
219
|
l.baseline = { sha, command, failures, verified: true };
|
|
224
220
|
save(c);
|
|
225
221
|
return failures;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** Parmi les tests rouges `names` du lot, ceux rouges SEULS aussi au sha de départ (L510) : la suite entière du départ a pu les voir verts (ordre, charge), ils n'en sont pas moins déjà rouges avant le lot. Vide sans `testReplay`, sans sha de départ, build du départ rouge ou worktree non monté. */
|
|
225
|
+
async function redAtStart(c, names, ran) {
|
|
226
|
+
const sha = startSha(c);
|
|
227
|
+
return !sha || !gateReplayable(c.config.test, c.config.testReplay) || !names.length ? [] : (await atStartSha(c, sha, (place) => redAloneGate(place.run, c.config.testReplay, names, ran))) ?? [];
|
|
228
|
+
}
|
|
229
|
+
/** Joue `fn` dans un worktree jetable du sha de départ (ligne de base L213, rouges au départ L510), liens vers les dossiers ignorés posés puis retirés avec lui. Null si le worktree ne se monte pas, si le build du départ est rouge ou si `fn` lève. */
|
|
230
|
+
async function atStartSha(c, sha, fn) {
|
|
231
|
+
const l = c.lot;
|
|
232
|
+
const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
|
|
233
|
+
const links = [];
|
|
234
|
+
try {
|
|
235
|
+
git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
|
|
236
|
+
links.push(...linkIgnored(l.repo, dir));
|
|
237
|
+
// Sur l'hôte `gate.host` quand il y en a un (L403) ; le worktree local reste le lieu du rejeu si la connexion tombe.
|
|
238
|
+
return await withPlace({ config: c.config, repo: l.repo, sha, dir, needClean: false, label: `porte du lot ${l.lot}`, detail: `(ligne de base ${sha.slice(0, 7)})`, local: (cmd) => sh(c, cmd, 30 * 60_000, {}, dir), say: (line) => c.wave.log(`${lotKey(l.project, l.lot)} · ${line}`) }, async (place) => {
|
|
239
|
+
// Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
|
|
240
|
+
// ne dit rien des tests : rien à rendre, l'appelant garde les tests pour rouges.
|
|
241
|
+
if (c.config.build && (await place.run(c.config.build)).code !== 0)
|
|
242
|
+
return null;
|
|
243
|
+
return fn(place);
|
|
244
|
+
});
|
|
226
245
|
}
|
|
227
246
|
catch {
|
|
228
247
|
return null;
|
|
@@ -246,9 +265,10 @@ async function baselineFailures(c, command) {
|
|
|
246
265
|
}
|
|
247
266
|
}
|
|
248
267
|
/** Des tests rouges comparés à la ligne de base (L213) : tous déjà rouges au départ, ou au moins un nouveau, ou rien de comparable. */
|
|
249
|
-
async function againstBaseline(c, command,
|
|
250
|
-
const now = failedTests(output);
|
|
251
|
-
|
|
268
|
+
async function againstBaseline(c, command, ran) {
|
|
269
|
+
const now = failedTests(ran.output);
|
|
270
|
+
// Un échec sans test nommé (en chaîne, ou dans une partie rouge, L512) : rien n'est comparable ni excusable, les tests restent rouges.
|
|
271
|
+
if (!now.length || gateUnnamed(ran))
|
|
252
272
|
return { kind: 'unknown' };
|
|
253
273
|
const base = await baselineFailures(c, command);
|
|
254
274
|
if (!base?.length)
|
|
@@ -1065,7 +1085,7 @@ function checkTiers(c, kind, step, before) {
|
|
|
1065
1085
|
const w = c.wave;
|
|
1066
1086
|
const key = lotKey(l.project, l.lot);
|
|
1067
1087
|
const wip = wipBranch(l.project, l.lot);
|
|
1068
|
-
for (const [repo, b] of before) {
|
|
1088
|
+
for (const [repo, b] of stillThird(before, w.store.lots(), key)) {
|
|
1069
1089
|
const name = basename(repo);
|
|
1070
1090
|
let a;
|
|
1071
1091
|
try {
|
|
@@ -1829,7 +1849,7 @@ async function acceptProblems(c, fiche, json, headBefore) {
|
|
|
1829
1849
|
l.warnings.push(NO_TEST_ACCEPT);
|
|
1830
1850
|
}
|
|
1831
1851
|
else {
|
|
1832
|
-
const ordinary = await sh(c, c.config.test);
|
|
1852
|
+
const ordinary = await runGate((cmd) => sh(c, cmd), c.config.test); // porte en parties (L512) : chaque partie dans son dossier, sortie fusionnée
|
|
1833
1853
|
if (ordinary.code !== 0)
|
|
1834
1854
|
out.push(`orchestrate.test échoue (code ${ordinary.code}) avec les tests rouges commités : les fichiers listés dans ${json} ne sont pas exclus de la config du lanceur (partagée avec la CI) tant que vert est false\n${tail(ordinary.output, 15)}`);
|
|
1835
1855
|
}
|
|
@@ -1980,7 +2000,8 @@ async function acceptGreen(c) {
|
|
|
1980
2000
|
*/
|
|
1981
2001
|
async function programChecks(c, red, runs) {
|
|
1982
2002
|
try {
|
|
1983
|
-
|
|
2003
|
+
// Sur l'hôte `gate.host` (L403) quand il y en a un : le HEAD du worktree du lot, s'il est propre ; sinon, ou sans hôte, en local.
|
|
2004
|
+
await withPlace({ config: c.config, repo: c.lot.repo, sha: tryGit(wd(c), ['rev-parse', 'HEAD']), dir: wd(c), needClean: true, label: `porte du lot ${c.lot.lot}`, local: (cmd) => sh(c, cmd), say: (line) => c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} · ${line}`) }, (place) => runChecks(c, red, runs, place));
|
|
1984
2005
|
}
|
|
1985
2006
|
finally {
|
|
1986
2007
|
// L395 : la porte se lit dans l'état du lot tant qu'une commande (ou le rejeu qui la suit) tourne, jamais après.
|
|
@@ -1988,19 +2009,19 @@ async function programChecks(c, red, runs) {
|
|
|
1988
2009
|
save(c);
|
|
1989
2010
|
}
|
|
1990
2011
|
}
|
|
1991
|
-
async function runChecks(c, red, runs) {
|
|
2012
|
+
async function runChecks(c, red, runs, place) {
|
|
1992
2013
|
const l = c.lot;
|
|
1993
2014
|
for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
|
|
1994
2015
|
if (!command)
|
|
1995
2016
|
continue;
|
|
1996
2017
|
l.gate = { label, started: new Date().toISOString() };
|
|
1997
2018
|
save(c);
|
|
1998
|
-
const r = await
|
|
1999
|
-
const run = { label, command, code: r.code };
|
|
2019
|
+
const r = await runGate(place.run, command); // porte en parties (L512) : toutes jouées, chacune dans son dossier ; le CheckRun porte le libellé « dir : run ; … »
|
|
2020
|
+
const run = { label, command: gateLabel(command), code: r.code, ...place.stamp() };
|
|
2000
2021
|
runs.push(run);
|
|
2001
2022
|
if (r.code === 0)
|
|
2002
2023
|
continue;
|
|
2003
|
-
const vs = label === 'tests' ? await againstBaseline(c, command, r
|
|
2024
|
+
const vs = label === 'tests' ? await againstBaseline(c, command, r) : { kind: 'unknown' };
|
|
2004
2025
|
const onlyExisting = (n) => {
|
|
2005
2026
|
run.dejaRouges = n;
|
|
2006
2027
|
const note = `${n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
|
|
@@ -2013,11 +2034,11 @@ async function runChecks(c, red, runs) {
|
|
|
2013
2034
|
}
|
|
2014
2035
|
// Un rouge absent de la ligne de base est rejoué SEUL avant d'être compté (L354) : sous charge, des tests qui passent seuls rougissent ensemble.
|
|
2015
2036
|
// Sans `orchestrate.testReplay` rien n'est rejoué : le rouge est compté tel quel et rien n'est tenu pour instable (L354).
|
|
2016
|
-
const
|
|
2017
|
-
const
|
|
2018
|
-
const { red: still, unstable } = named.length ? await replayAlone(c, replay, named, r.output) : { red: [], unstable: [] };
|
|
2037
|
+
const named = label !== 'tests' || !gateReplayable(c.config.test, c.config.testReplay) ? [] : vs.kind === 'new' ? vs.nouveaux : redCandidates(r);
|
|
2038
|
+
const { red: still, unstable, tight } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: [], unstable: [], tight: [] };
|
|
2019
2039
|
if (unstable.length) {
|
|
2020
|
-
noteUnstable(c, unstable);
|
|
2040
|
+
noteUnstable(c, unstable.filter((t) => !tight.some((k) => k.test === t)));
|
|
2041
|
+
noteTight(c, tight);
|
|
2021
2042
|
run.instables = unstable;
|
|
2022
2043
|
}
|
|
2023
2044
|
if (unstable.length && !still.length) {
|
|
@@ -2027,37 +2048,30 @@ async function runChecks(c, red, runs) {
|
|
|
2027
2048
|
run.code = 0;
|
|
2028
2049
|
continue;
|
|
2029
2050
|
}
|
|
2051
|
+
// L510 : un rouge qui reste rouge seul dans le lot mais que la suite entière du départ avait vu vert (ordre, charge) peut être rouge seul au départ aussi : déjà rouge, ni correction ni escalade.
|
|
2052
|
+
const atStart = still.length ? await redAtStart(c, still, r) : [];
|
|
2053
|
+
if (atStart.length) {
|
|
2054
|
+
const note = `${atStart.length} test(s) déjà rouge(s) au sha de départ ${startSha(c)?.slice(0, 7)} (rouge(s) seuls aussi, absent(s) de la ligne de base), pas un défaut du lot : ${atStart.join(' ; ')}`;
|
|
2055
|
+
if (!l.warnings.includes(note))
|
|
2056
|
+
l.warnings.push(note);
|
|
2057
|
+
if (atStart.length === still.length) {
|
|
2058
|
+
run.dejaRouges = atStart.length + (vs.kind === 'new' ? vs.existants : 0);
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
const fresh = still.filter((t) => !atStart.includes(t));
|
|
2030
2063
|
const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
|
|
2031
2064
|
if (vs.kind === 'new') {
|
|
2032
|
-
const left = vs.nouveaux.filter((t) => !unstable.includes(t));
|
|
2033
|
-
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
|
|
2065
|
+
const left = vs.nouveaux.filter((t) => !unstable.includes(t) && !atStart.includes(t));
|
|
2066
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
|
|
2034
2067
|
return;
|
|
2035
2068
|
}
|
|
2036
2069
|
// Des instables sont écartés : la sortie de la suite entière les montre encore en FAIL, le constat ne nomme que ceux qui restent rouges.
|
|
2037
|
-
const shown = unstable.length ? `${
|
|
2038
|
-
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${shown}${alone}` });
|
|
2070
|
+
const shown = unstable.length || atStart.length ? `${fresh.length} test(s) rouge(s) après rejeu seul :\n${fresh.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
|
|
2071
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) :\n${shown}${alone}` });
|
|
2039
2072
|
return;
|
|
2040
2073
|
}
|
|
2041
2074
|
}
|
|
2042
|
-
/**
|
|
2043
|
-
* Rejoue SEULS (la commande `orchestrate.testReplay` où `{files}` devient les fichiers de ces tests, dans `cwd` : le worktree du lot, ou celui de la ligne de base) les tests rouges d'une suite entière (L354). Le 26-10, vitest à 31 workers par suite sur une machine à 4 sessions rougissait des tests
|
|
2044
|
-
* qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`. Le rejeu lui-même est `replayRed` (workers.ts), partagé avec `verify --gate` (L387).
|
|
2045
|
-
*/
|
|
2046
|
-
function replayAlone(c, template, names, output, cwd = wd(c)) {
|
|
2047
|
-
return replayRed((command) => sh(c, command, undefined, undefined, cwd), template, names, output);
|
|
2048
|
-
}
|
|
2049
|
-
/** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
|
|
2050
|
-
function noteUnstable(c, tests) {
|
|
2051
|
-
const l = c.lot;
|
|
2052
|
-
const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
|
|
2053
|
-
if (!fresh.length)
|
|
2054
|
-
return;
|
|
2055
|
-
l.instables = [...(l.instables ?? []), ...fresh];
|
|
2056
|
-
const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
|
|
2057
|
-
l.warnings.push(text);
|
|
2058
|
-
c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
|
|
2059
|
-
save(c);
|
|
2060
|
-
}
|
|
2061
2075
|
/**
|
|
2062
2076
|
* Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
|
|
2063
2077
|
* temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
|
|
@@ -2269,7 +2283,7 @@ async function work(c, kind) {
|
|
|
2269
2283
|
const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
|
|
2270
2284
|
const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
|
|
2271
2285
|
if (deferred && red.length === 0 && gateGreen && baseOnly)
|
|
2272
|
-
l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${c.config.test}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
|
|
2286
|
+
l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${gateLabel(c.config.test ?? '')}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
|
|
2273
2287
|
else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
|
|
2274
2288
|
red.push(claimed);
|
|
2275
2289
|
if (red.length) {
|