@sylad/cadence 1.35.0 → 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.
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "cadence",
10
10
  "description": "Session start and close rituals driven by a versioned plan (raf), and deliveries proven by their effect. Needs the cadence CLI (npm i -g @sylad/cadence).",
11
- "version": "1.35.0",
11
+ "version": "1.36.0",
12
12
  "source": "./",
13
13
  "author": {
14
14
  "name": "Sylvain Ladoire"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cadence",
3
3
  "description": "A repo-native working method: session start and close rituals driven by a versioned plan (raf), deliveries proven by their effect, and three reviewer agents (UX, code, QA).",
4
- "version": "1.35.0",
4
+ "version": "1.36.0",
5
5
  "author": { "name": "Sylvain Ladoire" },
6
6
  "homepage": "https://github.com/Sylad/cadence",
7
7
  "repository": "https://github.com/Sylad/cadence",
@@ -0,0 +1,105 @@
1
+ import { basename } from 'node:path';
2
+ import { isPlanOnly } from './audit.js';
3
+ import { isAncestor, readCommits } from './git.js';
4
+ import { appendStep, lastDelivery } from './state.js';
5
+ const POLL_CI = 15_000;
6
+ const CI_APPEAR = 300_000;
7
+ const CI_OK = new Set(['success', 'skipped', 'neutral']);
8
+ /** « HH:MM:SS » UTC d'un instant en millisecondes : l'heure qui ouvre chaque ligne d'étape de la sortie. */
9
+ const clockTime = (ms) => new Date(ms).toISOString().slice(11, 19);
10
+ export const stamped = (deps, line) => `${clockTime(deps.now())} ${line}`;
11
+ /**
12
+ * Livraison précédente quand la CI du sha n'a pas à être attendue : un seul fichier hors plan dans précédente..sha suffit à
13
+ * l'exiger. Null (CI attendue) sans plan, sans livraison précédente, si elle n'est pas un ancêtre du sha ou l'égale, si elle n'est pas un sha complet (ligne du journal écrite à la main), si la
14
+ * lecture échoue ou rend aucun commit : on ne saute la CI que sur une preuve (au moins un commit, tous plan seul).
15
+ */
16
+ export function planOnlySince(ctx, sha) {
17
+ if (!ctx.plan)
18
+ return null;
19
+ const prev = lastDelivery(ctx.state);
20
+ if (!prev || !/^[0-9a-f]{40,64}$/.test(prev) || prev === sha || !isAncestor(ctx.root, prev, sha))
21
+ return null;
22
+ try {
23
+ const commits = readCommits(ctx.root, { range: `${prev}..${sha}` });
24
+ return commits.length > 0 && commits.every((c) => isPlanOnly(c.sha, ctx.plan, ctx.root)) ? prev : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** Un tour de `gh run list` : message d'échec, null si tous les runs sont verts, undefined tant qu'il faut attendre. */
31
+ function pollOnce(ctx, deps, sha, start, state) {
32
+ let runs = [];
33
+ let error = null;
34
+ try {
35
+ runs = deps.gh(sha);
36
+ state.failingSince = null;
37
+ }
38
+ catch (e) {
39
+ // Erreur passagère (réseau, quota d'API) : on réessaie ; persistante, on abandonne avec sa cause.
40
+ error = e.message;
41
+ state.failingSince ??= deps.now();
42
+ }
43
+ const elapsed = deps.now() - start;
44
+ if (error !== null) {
45
+ if (deps.now() - state.failingSince >= CI_APPEAR || elapsed >= ctx.config.ciTimeout * 1000)
46
+ return `gh run list en échec : ${error}`;
47
+ ctx.err(` gh run list en échec, nouvel essai : ${error}`);
48
+ return undefined;
49
+ }
50
+ if (runs.length === 0) {
51
+ return elapsed >= CI_APPEAR ? `aucun run CI pour ce sha après ${CI_APPEAR / 60_000} min (le commit poussé est-il celui que la CI construit ?)` : undefined;
52
+ }
53
+ const pending = runs.filter((r) => r.status !== 'completed');
54
+ const summary = `${runs.length} run(s), ${runs.length - pending.length} terminé(s)`;
55
+ if (summary !== state.summary)
56
+ ctx.out(` ${summary}`);
57
+ state.summary = summary;
58
+ if (pending.length > 0)
59
+ return undefined;
60
+ const bad = runs.filter((r) => !CI_OK.has(r.conclusion));
61
+ return bad.length ? `CI en échec : ${bad.map((r) => `${r.name} (${r.conclusion})`).join(', ')}` : null;
62
+ }
63
+ /** Message d'échec, ou null quand les runs GitHub du sha sont tous verts. */
64
+ export async function pollGithubCi(ctx, deps, sha) {
65
+ const { ciTimeout } = ctx.config;
66
+ ctx.out(stamped(deps, `→ CI : attente des runs GitHub de ${sha.slice(0, 7)}`));
67
+ const start = deps.now();
68
+ const state = { summary: '', failingSince: null };
69
+ for (;;) {
70
+ const verdict = pollOnce(ctx, deps, sha, start, state);
71
+ if (verdict !== undefined)
72
+ return verdict;
73
+ if (deps.now() - start >= ciTimeout * 1000)
74
+ return `CI non terminée après ${ciTimeout} s`;
75
+ await deps.sleep(POLL_CI);
76
+ }
77
+ }
78
+ /**
79
+ * Journal des étapes (L516) : `step(etape, debut, ok)` ajoute une ligne à `deliveries-steps.log`. Une mesure, jamais bloquante :
80
+ * une écriture qui échoue est signalée une seule fois, la livraison continue. `lots` n'est lu qu'à la première écriture.
81
+ */
82
+ export function stepJournal(ctx, deps, sha, lots) {
83
+ let known = null;
84
+ let warned = false;
85
+ return (etape, debut, ok) => {
86
+ try {
87
+ const fin = deps.now();
88
+ appendStep(ctx.state, {
89
+ lots: (known ??= lots()),
90
+ sha,
91
+ projet: ctx.plan?.project || basename(ctx.root),
92
+ etape,
93
+ debut: new Date(debut).toISOString(),
94
+ fin: new Date(fin).toISOString(),
95
+ duree_s: Math.round((fin - debut) / 1000),
96
+ resultat: ok ? 'ok' : 'echec',
97
+ });
98
+ }
99
+ catch (e) {
100
+ if (!warned)
101
+ ctx.err(`deliver : journal des étapes non écrit : ${e.message}`);
102
+ warned = true;
103
+ }
104
+ };
105
+ }
package/dist/deliver.js CHANGED
@@ -6,16 +6,14 @@ import { parse } from 'yaml';
6
6
  import { onTermination, readProcs, SIGNAL_GRACE_MS, SIGNAL_PENDING_MS, TreeTracker } from './proc.js';
7
7
  import { deliveryLots, isPlanOnly } from './audit.js';
8
8
  import { commitPaths, dirtyPaths, headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
9
+ import { planOnlySince, pollGithubCi, stamped, stepJournal } from './deliver-ci.js';
9
10
  import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
10
11
  import { citedRefs } from './link.js';
11
12
  import { describeArticle, openArticleLots } from './articles.js';
12
13
  import { RafError } from './plan.js';
13
14
  import { appendDelivery, appendFailedDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
14
- const POLL_CI = 15_000;
15
- const CI_APPEAR = 300_000;
16
15
  /** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
17
16
  const POLL_VERIFY = 10_000;
18
- const CI_OK = new Set(['success', 'skipped', 'neutral']);
19
17
  const GH_TIMEOUT = 60_000;
20
18
  export const TIMED_OUT = 124;
21
19
  /** Sortie gardée d'une commande : de quoi retrouver ses dernières lignes. */
@@ -236,6 +234,32 @@ export function describeCheck(c, sha) {
236
234
  return `GET ${substitute(c.url, sha)} → ${c.status ?? 200}${c.contains === undefined ? '' : `, contient « ${substitute(c.contains, sha)} »`}`;
237
235
  }
238
236
  const guardMessage = (sha, lots) => `${sha.slice(0, 7)} contient des commits de lots ni terminés ni prêts : ${lots.map((l) => `${l.id} (${l.state})`).join(', ')} ; livrer un sha antérieur, ou relire ces lots (raf review) avant`;
237
+ /** La simulation de `--dry-run` : ce que la livraison jouerait, sans rien exécuter. */
238
+ function describeSimulation(ctx, sha, env, branch, script) {
239
+ const { config, out } = ctx;
240
+ out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
241
+ if (script !== null) {
242
+ out(` Script du projet : ${script}`);
243
+ out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
244
+ }
245
+ else {
246
+ const skipped = config.ci === 'none' ? null : planOnlySince(ctx, sha);
247
+ if (skipped)
248
+ out(` CI : non attendue (aucun fichier hors plan depuis ${skipped.slice(0, 7)})`);
249
+ else
250
+ out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
251
+ out(' Déploiement :');
252
+ if (config.deploy.length === 0)
253
+ out(' (aucune commande)');
254
+ config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
255
+ }
256
+ if (ctx.articles?.length)
257
+ out(` Articles rafraîchis après une livraison verte (lot « Article <projet> à rafraîchir ») : ${ctx.articles.map(describeArticle).join(', ')}`);
258
+ if (config.verify.length)
259
+ out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
260
+ config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
261
+ out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
262
+ }
239
263
  /** Livre HEAD. 0 livré, 1 étape en échec, 2 refus avant d'agir. */
240
264
  export async function deliver(ctx, deps) {
241
265
  const { config, out, err } = ctx;
@@ -293,24 +317,7 @@ export async function deliver(ctx, deps) {
293
317
  const branch = atHead ? (repo.branch ?? 'HEAD détachée') : 'sha choisi, antérieur ou étranger à la tête';
294
318
  const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: atHead ? (repo.branch ?? '') : '' };
295
319
  if (ctx.dryRun) {
296
- out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
297
- if (script !== null) {
298
- out(` Script du projet : ${script}`);
299
- out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
300
- }
301
- else {
302
- out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
303
- out(' Déploiement :');
304
- if (config.deploy.length === 0)
305
- out(' (aucune commande)');
306
- config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
307
- }
308
- if (ctx.articles?.length)
309
- out(` Articles rafraîchis après une livraison verte (lot « Article <projet> à rafraîchir ») : ${ctx.articles.map(describeArticle).join(', ')}`);
310
- if (config.verify.length)
311
- out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
312
- config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
313
- out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
320
+ describeSimulation(ctx, sha, env, branch, script);
314
321
  if (undelivered.length)
315
322
  return refuse(guardMessage(sha, undelivered));
316
323
  return 0;
@@ -328,6 +335,7 @@ export async function deliver(ctx, deps) {
328
335
  }
329
336
  if (!taken)
330
337
  return refuse('une autre livraison vient de démarrer');
338
+ const startedAt = deps.now();
331
339
  try {
332
340
  const fail = (msg, cls, tail = []) => {
333
341
  err(`deliver : ${msg}`);
@@ -341,9 +349,10 @@ export async function deliver(ctx, deps) {
341
349
  return 1;
342
350
  };
343
351
  out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
352
+ const step = stepJournal(ctx, deps, sha, () => deliveredIds(ctx, lastDelivery(ctx.state), sha));
344
353
  let declared = null;
345
354
  if (script !== null) {
346
- out(`→ script du projet : ${script}`);
355
+ out(stamped(deps, `→ script du projet : ${script}`));
347
356
  // Le script qui déploie un autre commit que CADENCE_SHA y écrit son sha : cadence ne le devine pas.
348
357
  const file = join(ctx.state, `deployed-${process.pid}`);
349
358
  try {
@@ -368,18 +377,28 @@ export async function deliver(ctx, deps) {
368
377
  }
369
378
  }
370
379
  else {
371
- const ci = await waitCi(ctx, deps, sha, env);
372
- // Seule une commande de CI a une sortie : pour `ci: github`, lastOutput serait celle d'une commande antérieure.
373
- if (ci)
374
- return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
380
+ const skipped = config.ci === 'none' ? null : planOnlySince(ctx, sha);
381
+ if (skipped)
382
+ out(stamped(deps, `→ CI : non attendue (aucun fichier hors plan depuis ${skipped.slice(0, 7)})`));
383
+ else {
384
+ const ciStart = deps.now();
385
+ const ci = await waitCi(ctx, deps, sha, env);
386
+ if (config.ci !== 'none')
387
+ step('ci', ciStart, ci === null);
388
+ // Seule une commande de CI a une sortie : pour `ci: github`, lastOutput serait celle d'une commande antérieure.
389
+ if (ci)
390
+ return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
391
+ }
375
392
  for (const [i, cmd] of config.deploy.entries()) {
376
- out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
393
+ out(stamped(deps, `→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`));
394
+ const deployStart = deps.now();
377
395
  const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
396
+ step(`deploiement ${i + 1}/${config.deploy.length}`, deployStart, code === 0);
378
397
  if (code !== 0)
379
398
  return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`, 'deploiement', deps.lastOutput?.());
380
399
  }
381
400
  }
382
- const failed = await verifyAll(ctx, deps, sha, env);
401
+ const failed = await verifyAll(ctx, deps, sha, env, step);
383
402
  if (failed)
384
403
  return fail(failed.message, 'verification', failed.tail);
385
404
  // Ce qui est journalisé : le sha que le script déclare (CADENCE_DEPLOYED_FILE) l'emporte. Sans déclaration, hors vague, un script qui a commité
@@ -406,12 +425,13 @@ export async function deliver(ctx, deps) {
406
425
  const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
407
426
  const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
408
427
  appendDelivery(ctx.state, ctx.today, final);
428
+ const took = `en ${Math.round((deps.now() - startedAt) / 1000)} s`;
409
429
  if (how === 'declared')
410
- out(`✓ livré : ${final.slice(0, 7)} (sha déclaré par le script)`);
430
+ out(stamped(deps, `✓ livré : ${final.slice(0, 7)} (sha déclaré par le script) ${took}`));
411
431
  else if (how === 'moved')
412
- out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
432
+ out(stamped(deps, `✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT}) ${took}`));
413
433
  else
414
- out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
434
+ out(stamped(deps, `✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT} ${took}`));
415
435
  if (delivered)
416
436
  out(delivered);
417
437
  refreshArticles(ctx, ids, final);
@@ -427,51 +447,11 @@ async function waitCi(ctx, deps, sha, env) {
427
447
  if (ci === 'none')
428
448
  return null;
429
449
  if (typeof ci === 'object') {
430
- ctx.out(`→ CI : ${ci.command}`);
450
+ ctx.out(stamped(deps, `→ CI : ${ci.command}`));
431
451
  const code = await deps.exec(ci.command, env, ciTimeout * 1000);
432
452
  return code === 0 ? null : `CI en échec (${codeText(code)}) : ${ci.command}`;
433
453
  }
434
- ctx.out(`→ CI : attente des runs GitHub de ${sha.slice(0, 7)}`);
435
- const start = deps.now();
436
- let last = '';
437
- let failingSince = null;
438
- for (;;) {
439
- let runs = [];
440
- let error = null;
441
- try {
442
- runs = deps.gh(sha);
443
- failingSince = null;
444
- }
445
- catch (e) {
446
- // Erreur passagère (réseau, quota d'API) : on réessaie ; persistante, on abandonne avec sa cause.
447
- error = e.message;
448
- failingSince ??= deps.now();
449
- }
450
- const elapsed = deps.now() - start;
451
- if (error !== null) {
452
- if (deps.now() - failingSince >= CI_APPEAR || elapsed >= ciTimeout * 1000)
453
- return `gh run list en échec : ${error}`;
454
- ctx.err(` gh run list en échec, nouvel essai : ${error}`);
455
- }
456
- else if (runs.length === 0) {
457
- if (elapsed >= CI_APPEAR)
458
- return `aucun run CI pour ce sha après ${CI_APPEAR / 60_000} min (le commit poussé est-il celui que la CI construit ?)`;
459
- }
460
- else {
461
- const pending = runs.filter((r) => r.status !== 'completed');
462
- const summary = `${runs.length} run(s), ${runs.length - pending.length} terminé(s)`;
463
- if (summary !== last)
464
- ctx.out(` ${summary}`);
465
- last = summary;
466
- if (pending.length === 0) {
467
- const bad = runs.filter((r) => !CI_OK.has(r.conclusion));
468
- return bad.length ? `CI en échec : ${bad.map((r) => `${r.name} (${r.conclusion})`).join(', ')}` : null;
469
- }
470
- }
471
- if (elapsed >= ciTimeout * 1000)
472
- return `CI non terminée après ${ciTimeout} s`;
473
- await deps.sleep(POLL_CI);
474
- }
454
+ return pollGithubCi(ctx, deps, sha);
475
455
  }
476
456
  /** Un essai d'une vérification : cause de l'échec, ou null. Partagé par deliver et `cadence verify`. */
477
457
  async function tryCheck(c, deps, sha, env, budgetMs) {
@@ -510,12 +490,14 @@ export async function retryCheck(c, deps, sha, env, until, attemptMs) {
510
490
  }
511
491
  }
512
492
  /** Chaque vérification est réessayée jusqu'au délai commun ; message d'échec ou null. */
513
- async function verifyAll(ctx, deps, sha, env) {
493
+ async function verifyAll(ctx, deps, sha, env, step) {
514
494
  const deadline = deps.now() + ctx.config.verifyTimeout * 1000;
515
495
  for (const [i, c] of ctx.config.verify.entries()) {
516
496
  const label = describeCheck(c, sha);
517
- ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
497
+ ctx.out(stamped(deps, `→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`));
498
+ const checkStart = deps.now();
518
499
  const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
500
+ step(`verification ${i + 1}/${ctx.config.verify.length}`, checkStart, reason === null);
519
501
  if (reason !== null)
520
502
  return { message: `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`, tail: c.command !== undefined ? (deps.lastOutput?.() ?? []) : [] };
521
503
  }
@@ -0,0 +1,186 @@
1
+ import { basename, join, relative, resolve } from 'node:path';
2
+ import { lotFinished, lotKey, lotRepoPaths } from './state.js';
3
+ import { cadenceHome, liveWaves } from './registry.js';
4
+ import { REPO_LOCK, takeLock } from './lock.js';
5
+ import { installPrePush } from './guard.js';
6
+ import { sharedStateDir } from '../state.js';
7
+ import { waveReadsAdds } from './snapshot.js';
8
+ const ALONE = ['lots', 'resume', 'status', 'drop', 'stopAfterCurrent', 'budget', 'lotBudgets', 'dryRun', 'continue', 'answers', 'integrate', 'removeWorktree', 'waits', 'goal', 'maxSessions'];
9
+ /** `--add` ne se combine avec rien d'autre que `--wave` (L421) : vrai quand une autre action est donnée. */
10
+ export const addNotAlone = (args) => ALONE.some((k) => (Array.isArray(args[k]) ? args[k].length > 0 : !!args[k] || args[k] === 0));
11
+ /**
12
+ * L'identifiant d'un projet dans la vague, le même quelle que soit la façon de le taper : le chemin donné résolu depuis le dossier de lancement (`./b`, `b/../b` → `b`),
13
+ * et le nom du dossier de lancement quand le projet est ce dossier (`.`, ou rien). Sans cela une vague lancée dans un dépôt avec `L1` rangeait son lot sous `a:L1` et
14
+ * `--add .:L2` cherchait ses lots et ses doublons sous `.` : dépendance non reconnue, doublon admis.
15
+ */
16
+ export function projectName(cwd, project) {
17
+ if (!project)
18
+ return basename(cwd);
19
+ return relative(cwd, resolve(cwd, project)) || basename(cwd);
20
+ }
21
+ /** La clé d'un lot tel que la vague le range : le projet résolu (`projectName`) et le lot. */
22
+ export const addKey = (launch, t) => lotKey(projectName(launch, t.project), t.lot);
23
+ /** La commande qui ajoute le lot à la vague vivante qui tient le dépôt, quand cette vague a été lancée depuis ce dossier (`--add` ne voit que celles-là) ; sinon rien. */
24
+ export function addHint(t, wave, launch) {
25
+ if (!liveWaves(cadenceHome()).some((w) => w.wave === wave && w.cwd === launch))
26
+ return '';
27
+ return ` ; pour y ajouter ce lot : cadence orchestrate --add ${relative(launch, t.projectDir) || '.'}:${t.lot} --wave ${wave}`;
28
+ }
29
+ /** Refus d'un ajout (code 2) ou rien : les lots déjà dans la vague ou en file ne sont pas rejoués, le budget restant doit tenir celui de chaque lot ajouté. */
30
+ function budgetRefusals(lots, wave) {
31
+ let remaining = wave ? wave.budget - wave.consumed : Infinity;
32
+ const out = [];
33
+ for (const l of lots) {
34
+ if (l.budget > remaining)
35
+ out.push(`${lotKey(l.project, l.lot)} : le budget restant de la vague (${Math.max(0, Math.round(remaining))} tokens) ne tient pas le budget du lot (${l.budget})`);
36
+ remaining -= l.budget;
37
+ }
38
+ return out;
39
+ }
40
+ /**
41
+ * `--add` sur une vague VIVANTE (L421) : chaque lot passe le contrôle préalable d'un lot donné au lancement (dépôt tenu par CETTE vague admis, dépendances comptées dans la vague), puis est déposé
42
+ * dans la file d'ajouts que la vague prend entre deux étapes. Tout est refusé avant d'agir (code 2) dès qu'un lot l'est, comme `--answer`.
43
+ */
44
+ export async function addToLive(args, run, store, checks) {
45
+ const { io, launch } = run;
46
+ const refusals = [];
47
+ const targets = checks.resolveTargets({ lots: args.add }, io, refusals);
48
+ if (!waveReadsAdds(store.dir))
49
+ refusals.push(`la vague ${store.id} ne lit pas la file d'ajouts (lancée avec une version de cadence d'avant L421) : attendre sa fin, puis lancer le lot dans une vague`);
50
+ const queued = store.addQueue().map((e) => ({ project: projectName(launch, e.project), lot: e.lot, queued: true }));
51
+ const pre = refusals.length ? { refusals: [], lots: [] } : await checks.preflight(targets, run, { wave: store.id, lots: [...store.lots(), ...queued] });
52
+ refusals.push(...pre.refusals, ...budgetRefusals(pre.lots, store.readWave()));
53
+ if (refusals.length) {
54
+ for (const r of refusals)
55
+ io.err(`orchestrate : ${r}`);
56
+ return 2;
57
+ }
58
+ for (const a of args.add) {
59
+ const key = addKey(launch, a);
60
+ const id = store.requestAdd({ project: a.project, lot: a.lot, model: a.model });
61
+ // La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la demande est retirée.
62
+ if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
63
+ store.markAddTaken(id);
64
+ io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, lot non ajouté : le lancer dans une nouvelle vague`);
65
+ return 2;
66
+ }
67
+ store.journal(`demande : ajouter ${key} à la vague`);
68
+ io.out(`${key} : ajout déposé — la vague ${store.id} le prend entre deux étapes et le joue dès qu'un créneau est libre`);
69
+ }
70
+ return 0;
71
+ }
72
+ /**
73
+ * Les lots déposés par `--add` (L421), pris par la vague entre deux étapes comme les réponses du lead. Un lot dont le dépôt est occupé par un lot qui joue attend dans `waiting`
74
+ * (son contexte n'est pas reconstruit à chaque tour, mais ce qui l'écarterait est relu au moment de le prendre) ; un lot qui ne tient plus à cet instant est écarté et dit au journal.
75
+ */
76
+ export class AddFeed {
77
+ e;
78
+ waiting = new Map();
79
+ constructor(e) {
80
+ this.e = e;
81
+ }
82
+ discard(entry, why) {
83
+ this.e.wctx.log(`${addKey(this.e.launch, entry)} : ajout écarté (${why})`);
84
+ this.e.store.markAddTaken(entry.id);
85
+ this.waiting.delete(entry.id);
86
+ return null;
87
+ }
88
+ /** Le contexte du lot demandé, contrôlé comme au lancement ; null quand il est écarté (dit au journal). */
89
+ async prepare(entry) {
90
+ const e = this.e;
91
+ const key = addKey(e.launch, entry);
92
+ const stop = e.stopCause();
93
+ if (stop)
94
+ return this.discard(entry, stop);
95
+ const refusals = [];
96
+ const targets = e.checks.resolveTargets({ lots: [{ project: entry.project, lot: entry.lot, model: entry.model }] }, e.io, refusals);
97
+ const known = [...e.ctxs, ...this.waiting.values()].map((c) => ({ project: c.lot.project, lot: c.lot.lot }));
98
+ const built = refusals.length ? { refusals } : await e.build(targets[0], known);
99
+ if ('refusals' in built)
100
+ return this.discard(entry, built.refusals.map((r) => (r.startsWith(`${key} : `) ? r.slice(key.length + 3) : r)).join(' ; '));
101
+ if (built.ctx.lot.budget > e.budgetLeft())
102
+ return this.discard(entry, `le budget restant de la vague ne tient pas le budget du lot (${built.ctx.lot.budget})`);
103
+ return built.ctx;
104
+ }
105
+ /** Un ajout resté en attente est recontrôlé quand la vague le prend : arrêt demandé, budget épuisé ou lot fini entre-temps l'écartent comme à la lecture de la file. */
106
+ recheck(entry, c) {
107
+ const e = this.e;
108
+ const why = e.stopCause() ?? (c.lot.budget > e.budgetLeft() ? `le budget restant de la vague ne tient pas le budget du lot (${c.lot.budget})` : e.planState(c));
109
+ return why ? this.discard(entry, why) : c;
110
+ }
111
+ /** Prend le verrou et la garde de push d'un dépôt que la vague ne tient pas (ou plus) ; rend la cause du refus. */
112
+ hold(repo, fresh) {
113
+ const e = this.e;
114
+ const file = join(sharedStateDir(repo), REPO_LOCK);
115
+ if (e.held.includes(file))
116
+ return null;
117
+ const l = takeLock(file, { pid: process.pid, wave: e.wave.id, started: new Date().toISOString() });
118
+ if (!l.ok && l.held.pid !== process.pid)
119
+ return `${basename(repo)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`;
120
+ e.held.push(file);
121
+ fresh.push(repo);
122
+ if (l.ok && l.stale)
123
+ e.wctx.log(`orchestrate : verrou de dépôt périmé retiré (${basename(repo)}, pid ${l.stale.pid} mort)`);
124
+ const hook = installPrePush(repo, e.wave.id);
125
+ if (!hook.ok)
126
+ return `${basename(repo)} : ${hook.reason}`;
127
+ e.freed.delete(repo);
128
+ if (!e.repos.includes(repo))
129
+ e.repos.push(repo);
130
+ return null;
131
+ }
132
+ /** Les verrous de tous les dépôts du lot ; en cas de refus, ceux pris pour lui sont rendus. */
133
+ holdAll(c) {
134
+ const fresh = [];
135
+ const refused = lotRepoPaths(c.lot).map((r) => this.hold(r, fresh)).find((x) => x) ?? null;
136
+ if (refused)
137
+ for (const r of fresh)
138
+ this.e.freeRepo(r);
139
+ return refused;
140
+ }
141
+ admit(entry, c) {
142
+ const e = this.e;
143
+ e.ctxs.push(c);
144
+ e.all.push(c.lot);
145
+ if (e.lots !== e.all)
146
+ e.lots.push(c.lot);
147
+ e.wave.lots.push(lotKey(c.lot.project, c.lot.lot));
148
+ e.store.writeLot(c.lot);
149
+ e.saveWave();
150
+ e.store.markAddTaken(entry.id);
151
+ this.waiting.delete(entry.id);
152
+ e.wctx.log(`${lotKey(c.lot.project, c.lot.lot)} : ajouté à la vague ${e.wave.id}`);
153
+ }
154
+ /** Les lots ajoutés à ce tour, prêts à être joués ; `canRun` dit si le dépôt du lot est libre (jamais deux sessions dans un même dépôt hors worktree). */
155
+ async take(canRun) {
156
+ const added = [];
157
+ for (const entry of this.e.store.addQueue()) {
158
+ const kept = this.waiting.get(entry.id);
159
+ const c = kept ? this.recheck(entry, kept) : await this.prepare(entry);
160
+ if (!c)
161
+ continue;
162
+ if (!canRun(c)) {
163
+ this.waiting.set(entry.id, c);
164
+ continue;
165
+ }
166
+ const refused = this.holdAll(c);
167
+ if (refused)
168
+ this.discard(entry, refused);
169
+ else {
170
+ this.admit(entry, c);
171
+ added.push(c);
172
+ }
173
+ }
174
+ if (added.length)
175
+ this.e.announce();
176
+ return added;
177
+ }
178
+ /** Les demandes encore en file quand plus rien ne joue ne seront plus lues : écartées et dites. */
179
+ drain(why) {
180
+ this.waiting.clear();
181
+ for (const entry of this.e.store.addQueue())
182
+ this.discard(entry, why);
183
+ }
184
+ }
185
+ /** Un lot est-il encore à jouer dans ce dépôt (utilisé pour décider d'un worktree) ? */
186
+ export const openInRepo = (ctxs, c) => ctxs.filter((o) => !lotFinished(o.lot) && lotRepoPaths(o.lot).some((r) => lotRepoPaths(c.lot).includes(r)));