@sylad/cadence 1.35.0 → 1.37.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/README.md +2 -2
- package/dist/deliver-ci.js +105 -0
- package/dist/deliver.js +57 -75
- package/dist/orchestrate/ajout.js +186 -0
- package/dist/orchestrate/briefs.js +23 -3
- package/dist/orchestrate/command.js +118 -140
- package/dist/orchestrate/cycle.js +23 -18
- package/dist/orchestrate/depot.js +33 -0
- package/dist/orchestrate/gate-notes.js +7 -1
- package/dist/orchestrate/lot-budget.js +50 -0
- package/dist/orchestrate/no-finding.js +19 -0
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/precheck.js +34 -0
- package/dist/orchestrate/retrait.js +37 -0
- package/dist/orchestrate/schemas.js +2 -2
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/state.js +47 -9
- package/dist/orchestrate/table.js +2 -2
- package/dist/orchestrate/tiers.js +9 -0
- package/dist/remote-gate.js +2 -2
- package/dist/state.js +4 -0
- package/docs/corrections.md +2 -0
- package/docs/deliver.md +18 -0
- package/docs/lead.md +3 -1
- package/docs/mesures/brief-implementation-enrichi-avant-apres.md +36 -0
- package/docs/orchestrate.md +11 -3
- package/docs/releasing.md +1 -1
- package/docs/whats-new.md +6 -0
- package/package.json +2 -1
- package/templates/orchestrate/fix-docs.md +1 -1
- package/templates/orchestrate/fix-minors.md +1 -1
- package/templates/orchestrate/fix.md +5 -4
- package/templates/orchestrate/implement.md +5 -4
- package/templates/orchestrate/precheck.md +1 -0
|
@@ -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.
|
|
11
|
+
"version": "1.37.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.
|
|
4
|
+
"version": "1.37.0",
|
|
5
5
|
"author": { "name": "Sylvain Ladoire" },
|
|
6
6
|
"homepage": "https://github.com/Sylad/cadence",
|
|
7
7
|
"repository": "https://github.com/Sylad/cadence",
|
package/README.md
CHANGED
|
@@ -49,12 +49,12 @@ Details: [docs/skills.md](docs/skills.md) and [docs/cadence-hud.md](docs/cadence
|
|
|
49
49
|
|
|
50
50
|
- [docs/raf.md](docs/raf.md): the plan, its commands and its file.
|
|
51
51
|
- [docs/orchestrate.md](docs/orchestrate.md): waves, budgets, and [the edge-case probe](docs/orchestrate.md#the-edge-case-probe-orchestrateprobe-l497).
|
|
52
|
+
- [Brief grid and pre-check reading](docs/mesures/brief-implementation-enrichi-avant-apres.md): grid of the code-reviewer in the `implement` brief (L523).
|
|
52
53
|
- [docs/deliver.md](docs/deliver.md): wait for the CI, deploy, verify the effect; [docs/remote-gate.md](docs/remote-gate.md): the gate on another machine.
|
|
53
54
|
- [docs/spec.md](docs/spec.md): screen and back-end sheets.
|
|
54
55
|
- [docs/ref.md](docs/ref.md): reference documents (specs, norms, mock-ups) and the coverage of their elements by the lots.
|
|
55
56
|
- [docs/lead.md](docs/lead.md): the lead workflow, and the `livreur` agent that delivers a ready lot for it.
|
|
56
|
-
- [docs/overview.md](docs/overview.md): the longer introduction and the map of every page.
|
|
57
|
-
- [docs/fonctionnement.md](docs/fonctionnement.md): how it all works, in four figures.
|
|
57
|
+
- [docs/overview.md](docs/overview.md): the longer introduction and the map of every page; [docs/fonctionnement.md](docs/fonctionnement.md): how it works.
|
|
58
58
|
- [docs/stabilite.md](docs/stabilite.md): what is guaranteed from 1.0.0; [docs/qualite.md](docs/qualite.md): the code-quality ratchet.
|
|
59
59
|
- [docs/whats-new.md](docs/whats-new.md) and [CHANGELOG.md](CHANGELOG.md): what changed.
|
|
60
60
|
|
|
@@ -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
|
-
|
|
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
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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)));
|
|
@@ -82,6 +82,23 @@ export function localReading(lot) {
|
|
|
82
82
|
'Keep every read small (`grep -n`, `git log --oneline -S`, a few dozen lines at a time, never a whole large file),',
|
|
83
83
|
].join('\n');
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Ce que le précheck apporte au brief de l'implémentation : son constat (« partiel ») puis, séparée par une ligne vide, ce qu'il a lu
|
|
87
|
+
* (L523), pour que la session parte de là au lieu de tout relire à froid. `lu` n'est passé qu'à la première passe : la lecture date du
|
|
88
|
+
* début du lot, une reprise après interruption a des commits à relire (l'appelant ne le passe pas). Sans constat ni lecture, `commits` est rendu tel quel.
|
|
89
|
+
*/
|
|
90
|
+
export function precheckBrief(commits, finding, lu) {
|
|
91
|
+
const withFinding = finding ? [commits, finding].filter(Boolean).join('\n') : commits;
|
|
92
|
+
if (!lu)
|
|
93
|
+
return withFinding;
|
|
94
|
+
const reading = [
|
|
95
|
+
'Pre-check reading (L523): the read-only pre-check of this lot, at the start of the lot, looked in the repository and read this.',
|
|
96
|
+
'Start from these files, the neighbouring code to imitate and the existing tests it names, without searching for them again;',
|
|
97
|
+
'reopen a file only to modify it or when this reading is not enough, and re-read the lines you edit (the reading dates from the start of the lot).',
|
|
98
|
+
lu,
|
|
99
|
+
].join('\n');
|
|
100
|
+
return [withFinding, reading].filter(Boolean).join('\n\n');
|
|
101
|
+
}
|
|
85
102
|
const FILES = {
|
|
86
103
|
precheck: 'precheck.md',
|
|
87
104
|
spec: 'spec.md',
|
|
@@ -129,9 +146,9 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
|
129
146
|
return `${out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
|
130
147
|
}
|
|
131
148
|
/**
|
|
132
|
-
* Consigne `{{gate}}` (L399) : le programme
|
|
149
|
+
* Consigne `{{gate}}` (L399, L518) : le programme joue une fois après l'écriture `orchestrate.test` et `orchestrate.build` ; la session ne joue que les tests ciblés du code touché (la suite entière rejouée en session coûtait 2,7 suites par lot, étude L517) et le build tel quel, pas la commande qu'elle juge équivalente.
|
|
133
150
|
* Du 22 au 28-10, des lots annoncés verts arrivaient rouges à la porte parce que la session avait joué `npm run build` (tsconfig.build.json : `src` seul)
|
|
134
|
-
* alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée.
|
|
151
|
+
* alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée ; sans `orchestrate.test`, la session joue elle-même la suite complète, une fois.
|
|
135
152
|
*/
|
|
136
153
|
export function gateText(gate) {
|
|
137
154
|
// Porte en parties (L512) : une ligne par partie, avec son dossier, jamais la liste sérialisée.
|
|
@@ -139,7 +156,10 @@ export function gateText(gate) {
|
|
|
139
156
|
const lines = [...tests, gate.build ? `- build: \`${gate.build}\`` : ''].filter(Boolean);
|
|
140
157
|
if (!lines.length)
|
|
141
158
|
return '';
|
|
142
|
-
|
|
159
|
+
// Sans `orchestrate.test`, le programme ne joue aucune suite (runChecks la saute) : la session est la seule à pouvoir la jouer.
|
|
160
|
+
if (!gate.test)
|
|
161
|
+
return `The program's gate, played once by the program after your writing session, is exactly:\n${lines.join('\n')}\nThe program plays no test suite for this project: play the full suite yourself, once, before committing, and the build command as written, not a narrower one (\`npm run build\` may cover less than the gate). A red at the gate sends the lot back to a correction pass even when your own run was green.`;
|
|
162
|
+
return `The program's gate, played once by the program after your writing session, is exactly:\n${lines.join('\n')}\nPlay only the tests of the code you touch (targeted: one file or filter, never the full suite), once, before committing: the full suite is not yours to replay (L518). Play the build command as written, not a narrower one (\`npm run build\` may cover less than the gate). A red at the gate sends the lot back to a correction pass even when your own run was green.`;
|
|
143
163
|
}
|
|
144
164
|
/**
|
|
145
165
|
* L'objectif d'un lot (L258) : celui gardé dans l'état du lot (`LotState.goal`, fixé au lancement : `--goal`, sinon la clé `goal:` du plan),
|