@sylad/cadence 1.23.0 → 1.25.1
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/agents/code-reviewer.md +17 -0
- package/agents/qa-reviewer.md +17 -0
- package/dist/cli.js +69 -19
- package/dist/gate-record.js +34 -8
- package/dist/gate-verdict.js +46 -0
- package/dist/git.js +31 -1
- package/dist/lead.js +4 -1
- package/dist/orchestrate/command.js +224 -131
- package/dist/orchestrate/guard.js +34 -2
- package/dist/orchestrate/integrate.js +6 -9
- package/dist/orchestrate/registry.js +6 -4
- package/dist/orchestrate/table.js +60 -28
- package/dist/perf.js +24 -2
- package/dist/plan.js +25 -2
- package/dist/push-guard.js +50 -1
- package/dist/release.js +11 -0
- package/dist/verify-gate.js +32 -5
- package/docs/cadence-hud.md +8 -0
- package/docs/corrections.md +1 -0
- package/docs/deliver.md +12 -3
- package/docs/lead.md +11 -4
- package/docs/orchestrate.md +10 -8
- package/docs/perf.md +3 -1
- package/docs/raf.md +4 -2
- package/docs/releasing.md +9 -3
- package/docs/skills.md +6 -0
- package/docs/whats-new.md +9 -0
- package/package.json +1 -1
- package/skills/deliver/SKILL.md +8 -0
- package/skills/lead/SKILL.md +26 -1
- package/templates/orchestrate/implement.md +6 -1
|
@@ -27,7 +27,7 @@ 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 { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, longDuration, removePrePush, snapshot } from './guard.js';
|
|
30
|
+
import { canInstallPrePush, gitProcessRunning, indexLockRefusal, installPrePush, keepGateGuard, longDuration, removePrePush, snapshot } 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';
|
|
@@ -37,8 +37,10 @@ import { schemaFor } from './schemas.js';
|
|
|
37
37
|
import { resolveLotRepos } from '../repos.js';
|
|
38
38
|
import { excludeState, lotFinished, lotKey, lotRepoPaths, lotSlug, neighbourDirs, newLot, RunStore } from './state.js';
|
|
39
39
|
import { linkNodeBin, nvmVersionsDir, resolveNode } from './node-env.js';
|
|
40
|
-
import { quotaText, renderTable, resumeCommand } from './table.js';
|
|
40
|
+
import { quotaText, renderDetail, renderTable, resumeCommand } from './table.js';
|
|
41
41
|
import { PACKAGE_ROOT, RESERVED_ENV, SnapshotRefusal, isSnapshotChild, resolveModulesDir, repairSnapshotModules, snapshotExists, waveReadsControl, waveReadsIntegrate, waveReadsLotBudget, waveReadsReplay, withoutLaunchVars, spawnReexec, takeSnapshot, toolDirOf } from './snapshot.js';
|
|
42
|
+
const REPO_WAIT_MS = 3 * 60 * 60_000;
|
|
43
|
+
const REPO_WAIT_POLL_MS = 5_000;
|
|
42
44
|
export const DEFAULT_BUDGET = 2_000_000;
|
|
43
45
|
/**
|
|
44
46
|
* Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
|
|
@@ -159,6 +161,8 @@ export function parseOrchestrateArgs(argv) {
|
|
|
159
161
|
if (!isDay(a.since))
|
|
160
162
|
throw new RafError(`--since invalide : ${a.since} (AAAA-MM-JJ)`);
|
|
161
163
|
}
|
|
164
|
+
else if (t === '--detail')
|
|
165
|
+
a.detail = value(t);
|
|
162
166
|
else if (t === '--watch')
|
|
163
167
|
a.watch = true;
|
|
164
168
|
else if (t === '--interval')
|
|
@@ -643,7 +647,15 @@ function statusView(args, launch, cap, now) {
|
|
|
643
647
|
// `running` : la vague suivie tourne — pour un <id> explicite, elle seule compte, pas les autres vagues de la machine
|
|
644
648
|
const running = id !== undefined ? liveWaves(cadenceHome()).some((w) => w.wave === store.readWave().id) : waveLive;
|
|
645
649
|
const lots = store.lots();
|
|
646
|
-
|
|
650
|
+
if (args.detail !== undefined) {
|
|
651
|
+
try {
|
|
652
|
+
return { live: [], table: renderDetail(lots, args.detail), running };
|
|
653
|
+
}
|
|
654
|
+
catch (e) {
|
|
655
|
+
throw new RafError(e.message);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return { live, table: [...renderTable(store.readWave(), lots, { compact: true }), ...keptLines(lots.map((l) => l.repo), now.getTime())], running };
|
|
647
659
|
}
|
|
648
660
|
/** La vague que `--status` suit (L395) : `--status <id>` ou `--status --wave <id>` ; aucune : la dernière du dossier. Deux ids différents : refus. */
|
|
649
661
|
function statusWave(args) {
|
|
@@ -711,6 +723,10 @@ export async function orchestrate(argv, io, deps) {
|
|
|
711
723
|
priorityOf(args, launch); // une clé priority: illisible du cadence.yaml : refus avant d'agir, lots donnés ou non
|
|
712
724
|
if (args.watch && args.status === undefined)
|
|
713
725
|
throw new RafError('--watch s\'utilise avec --status');
|
|
726
|
+
if (args.detail !== undefined && args.status === undefined)
|
|
727
|
+
throw new RafError('--detail s\'utilise avec --status');
|
|
728
|
+
if (args.detail !== undefined && args.watch)
|
|
729
|
+
throw new RafError('--detail ne se combine pas avec --watch (il donne le détail d\'un lot, pas le tableau)');
|
|
714
730
|
if (args.interval !== undefined && !args.watch)
|
|
715
731
|
throw new RafError('--interval s\'utilise avec --status --watch');
|
|
716
732
|
// --drop / --stop-after-current (L79) pilotent une vague VIVANTE : ni avec --status (rien à piloter), ni --stop-after-current avec --resume (il le contredit). --resume --drop retire le lot avant de rejouer la vague arrêtée.
|
|
@@ -790,7 +806,7 @@ export async function orchestrate(argv, io, deps) {
|
|
|
790
806
|
if (args.drop.length || args.stopAfterCurrent)
|
|
791
807
|
return control(args, io);
|
|
792
808
|
if (args.lots.length === 0 && !args.continue)
|
|
793
|
-
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] [--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]');
|
|
809
|
+
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]');
|
|
794
810
|
const refusals = [];
|
|
795
811
|
const targets = resolveTargets(args, io, refusals);
|
|
796
812
|
const pre = await preflight(args, targets, io, deps, launch);
|
|
@@ -1093,14 +1109,17 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1093
1109
|
const repos = [...new Set(lots.flatMap(lotRepoPaths))];
|
|
1094
1110
|
/** Libère un dépôt : son verrou et sa garde de push (L132 : dès que ses lots sont finis, pas à la fin de la vague). */
|
|
1095
1111
|
const freed = new Set();
|
|
1096
|
-
/** Lots
|
|
1097
|
-
const
|
|
1112
|
+
/** Lots dont la vague a fini de jouer (prêts, rendus, en échec ou suspendus) : ils ne tiennent plus de créneau. */
|
|
1113
|
+
const settled = new Set();
|
|
1114
|
+
/** Lots que la vague a encore à jouer, en cours ou en attente, sur les dépôts qu'elle tient encore (un par dépôt, un par lot en worktree) : ce que le registre compte pour les créneaux libres (L433 : un lot fini ne garde plus de créneau, 0 quand plus rien n'est à jouer). Absent (undefined) tant qu'aucun lot ne joue dans un worktree : le calcul reste celui des dépôts tenus. */
|
|
1115
|
+
const lanes = () => ctxs.some((c) => c.tree) ? groupLots(ctxs).filter((g) => g.every((c) => lotRepoPaths(c.lot).every((r) => !freed.has(r)))).reduce((n, g) => { const todo = g.filter((c) => !settled.has(c)).length; return n + (g.every((c) => c.tree) ? todo : Math.min(1, todo)); }, 0) : undefined; // hors mode worktree, rien n'est écrit : le registre compte les dépôts tenus, comme avant L279
|
|
1098
1116
|
const freeRepo = (r) => {
|
|
1099
1117
|
freed.add(r);
|
|
1100
1118
|
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
1101
1119
|
releaseLock(file, process.pid);
|
|
1102
1120
|
held.splice(0, held.length, ...held.filter((f) => f !== file));
|
|
1103
1121
|
removePrePush(r, wave.id);
|
|
1122
|
+
keepGateGuard(r);
|
|
1104
1123
|
try {
|
|
1105
1124
|
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
|
|
1106
1125
|
}
|
|
@@ -1112,9 +1131,12 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1112
1131
|
for (const f of held)
|
|
1113
1132
|
releaseLock(f, process.pid);
|
|
1114
1133
|
held.length = 0;
|
|
1115
|
-
for (const r of repos)
|
|
1116
|
-
if (
|
|
1117
|
-
|
|
1134
|
+
for (const r of repos) {
|
|
1135
|
+
if (freed.has(r))
|
|
1136
|
+
continue;
|
|
1137
|
+
removePrePush(r, wave.id);
|
|
1138
|
+
keepGateGuard(r);
|
|
1139
|
+
}
|
|
1118
1140
|
try {
|
|
1119
1141
|
unregisterWave(home, process.pid);
|
|
1120
1142
|
}
|
|
@@ -1125,13 +1147,14 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1125
1147
|
for (const r of repos) {
|
|
1126
1148
|
const file = join(sharedStateDir(r), REPO_LOCK);
|
|
1127
1149
|
const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
|
|
1128
|
-
|
|
1150
|
+
// Le verrou déjà à ce pid : pris par la reprise qui a attendu l'autre vague (L443).
|
|
1151
|
+
if (!l.ok && l.held.pid !== process.pid) {
|
|
1129
1152
|
io.err(`orchestrate : ${basename(r)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`);
|
|
1130
1153
|
release();
|
|
1131
1154
|
return 2;
|
|
1132
1155
|
}
|
|
1133
1156
|
held.push(file);
|
|
1134
|
-
if (l.stale)
|
|
1157
|
+
if (l.ok && l.stale)
|
|
1135
1158
|
io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(r)}, pid ${l.stale.pid} mort)`);
|
|
1136
1159
|
const hook = installPrePush(r, wave.id);
|
|
1137
1160
|
if (!hook.ok) {
|
|
@@ -1159,7 +1182,10 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1159
1182
|
return DROPPED;
|
|
1160
1183
|
return ctl.stopAfterCurrent ? STOP_REQUESTED : null;
|
|
1161
1184
|
},
|
|
1162
|
-
onWait: (h, ms, limit) =>
|
|
1185
|
+
onWait: (h, ms, limit, lead) => {
|
|
1186
|
+
const who = [...new Set(h.map((x) => x.wave)), ...lead.map((x) => `session du lead ${x.label}`)];
|
|
1187
|
+
wctx.log(`${o.lot ? `${o.lot} · ` : ''}en attente d'un créneau de session depuis ${duration(ms)} (${h.length + lead.length}/${limit} en cours : ${who.join(', ')})`);
|
|
1188
|
+
},
|
|
1163
1189
|
onGot: (ms) => wctx.log(`${o.lot ? `${o.lot} · ` : ''}créneau de session obtenu après ${duration(ms)} d'attente`),
|
|
1164
1190
|
});
|
|
1165
1191
|
try {
|
|
@@ -1247,6 +1273,13 @@ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
|
|
|
1247
1273
|
if (c.tree?.active && c.lot.status === 'handed-back' && c.lot.next === 'integrate')
|
|
1248
1274
|
wctx.log(`orchestrate : ${lotKey(c.lot.project, c.lot.lot)} : rendu à l'intégration : reprise par ${resumeCommand(wctx.id, '--integrate', lotKey(c.lot.project, c.lot.lot))}`);
|
|
1249
1275
|
// Un dépôt dont tous les lots de la vague (reprise comprise) sont prêts ou rendus peut être livré pendant que la vague continue ailleurs.
|
|
1276
|
+
settled.add(c);
|
|
1277
|
+
try {
|
|
1278
|
+
updateWaveRepos(home, process.pid, repos.filter((x) => !freed.has(x)), lanes());
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
// registre illisible : le décompte reste celui du dernier écrit
|
|
1282
|
+
}
|
|
1250
1283
|
if (wctx.incident)
|
|
1251
1284
|
return;
|
|
1252
1285
|
for (const r of lotRepoPaths(c.lot)) {
|
|
@@ -1510,133 +1543,193 @@ async function resume(args, argv, io, deps, launch, today) {
|
|
|
1510
1543
|
(drops.includes(lotKey(l.project, l.lot)) ? removed : live).push(l);
|
|
1511
1544
|
}
|
|
1512
1545
|
// Comme au départ : un dépôt tenu par une autre orchestration vivante est refusé avant toute écriture.
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1546
|
+
// L443 : une reprise d'intégration (--integrate, --replay) attend la fin de l'autre vague au lieu de sortir en 2 ; toute autre reprise refuse comme avant.
|
|
1547
|
+
// Un refus déjà connu (vague qui tourne encore, claude introuvable, session en vie…) sort en 2 tout de suite : attendre 3 h ne le lèverait pas.
|
|
1548
|
+
const waitFor = integ.length && !refusals.length ? { waitMs: deps.repoWait?.waitMs ?? REPO_WAIT_MS, pollMs: deps.repoWait?.pollMs ?? REPO_WAIT_POLL_MS } : null;
|
|
1549
|
+
const deadline = Date.now() + (waitFor?.waitMs ?? 0);
|
|
1550
|
+
// Pendant l'attente (jusqu'à 3 h) la reprise est inscrite parmi les vagues vivantes : le garde « la vague tourne encore » d'une seconde reprise la voit, et ne vient pas attendre à son tour.
|
|
1551
|
+
let waiting = false;
|
|
1552
|
+
// Verrous pris à la fin de l'attente : tenus jusqu'à `execute` (qui les retrouve à son pid), pour qu'une autre reprise qui attendait le même dépôt ne le voie pas libre pendant le .nvmrc, l'arbre et l'écriture des lots.
|
|
1553
|
+
const taken = [];
|
|
1554
|
+
const stopBefore = store.control().stopAfterCurrent;
|
|
1555
|
+
try {
|
|
1556
|
+
for (const repo of new Set(live.flatMap(lotRepoPaths))) {
|
|
1557
|
+
const file = join(sharedStateDir(repo), REPO_LOCK);
|
|
1558
|
+
let announced = null;
|
|
1559
|
+
for (;;) {
|
|
1560
|
+
let busy = activeLock(file);
|
|
1561
|
+
if (waitFor && (!busy || busy.pid === process.pid)) {
|
|
1562
|
+
const t = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
|
|
1563
|
+
if (t.ok) {
|
|
1564
|
+
taken.push(file);
|
|
1565
|
+
if (t.stale)
|
|
1566
|
+
io.err(`orchestrate : verrou de dépôt périmé retiré (${basename(repo)}, pid ${t.stale.pid} mort)`);
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
busy = t.held;
|
|
1570
|
+
}
|
|
1571
|
+
if (!busy || busy.pid === process.pid)
|
|
1572
|
+
break;
|
|
1573
|
+
// Le verrou porte l'id de la vague : tenu par un processus vivant de CETTE vague, c'est une autre reprise de la même vague,
|
|
1574
|
+
// entre son verrou et son inscription par `execute` (le garde « tourne encore » ci-dessus ne la voit pas encore) : refus, pas d'attente.
|
|
1575
|
+
if (busy.wave === wave.id) {
|
|
1576
|
+
refusals.push(`la vague ${wave.id} tourne encore (pid ${busy.pid}, verrou de ${basename(repo)})`);
|
|
1577
|
+
break;
|
|
1578
|
+
}
|
|
1579
|
+
// Un refus déjà posé sur un dépôt précédent (même vague, attente échue) : les suivants ne font plus attendre, la reprise sort en 2.
|
|
1580
|
+
if (!waitFor || refusals.length || Date.now() >= deadline) {
|
|
1581
|
+
refusals.push(`${basename(repo)} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})`);
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1584
|
+
if (!waiting) {
|
|
1585
|
+
registerWave(cadenceHome(), { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos: [] });
|
|
1586
|
+
waiting = true;
|
|
1587
|
+
}
|
|
1588
|
+
if (announced !== busy.wave)
|
|
1589
|
+
io.out(`orchestrate : ${basename(repo)} : tenu par la vague ${busy.wave} (pid ${busy.pid}) : attente de sa fin avant de reprendre l'intégration`);
|
|
1590
|
+
announced = busy.wave;
|
|
1591
|
+
await new Promise((res) => setTimeout(res, Math.min(waitFor.pollMs, Math.max(1, deadline - Date.now()))));
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1517
1594
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
for (const l of live) {
|
|
1522
|
-
const node = resolveNode(l.repo, nvmVersionsDir(io.env));
|
|
1523
|
-
if (node.kind === 'missing')
|
|
1524
|
-
refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
|
|
1525
|
-
else
|
|
1526
|
-
nodes.set(l, node);
|
|
1595
|
+
finally {
|
|
1596
|
+
if (waiting)
|
|
1597
|
+
unregisterWave(cadenceHome(), process.pid);
|
|
1527
1598
|
}
|
|
1528
|
-
//
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1599
|
+
// Avant `execute`, tout refus ou échec rend les verrous pris ; `execute` les garde ensuite et les rend à sa fin.
|
|
1600
|
+
let played = false;
|
|
1601
|
+
try {
|
|
1602
|
+
// Comme au départ : le .nvmrc est relu à chaque reprise (version désinstallée, .nvmrc changé, vague d'avant L80),
|
|
1603
|
+
// et un Node introuvable refuse le lot — jamais de repli silencieux sur le Node par défaut.
|
|
1604
|
+
const nodes = new Map();
|
|
1605
|
+
for (const l of live) {
|
|
1606
|
+
const node = resolveNode(l.repo, nvmVersionsDir(io.env));
|
|
1607
|
+
if (node.kind === 'missing')
|
|
1608
|
+
refusals.push(`${lotKey(l.project, l.lot)} : ${node.message}`);
|
|
1609
|
+
else
|
|
1610
|
+
nodes.set(l, node);
|
|
1611
|
+
}
|
|
1612
|
+
// Tous les refus avant la moindre écriture (l'arbre sale compris) : une reprise refusée ne change pas l'état de la vague.
|
|
1613
|
+
const dirty = new Set();
|
|
1614
|
+
for (const repo of live.flatMap(lotRepoPaths))
|
|
1615
|
+
if (!dirty.has(repo)) {
|
|
1616
|
+
dirty.add(repo);
|
|
1617
|
+
const d = await trackedDirty(repo);
|
|
1618
|
+
if (d.length)
|
|
1619
|
+
refusals.push(`${basename(repo)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1620
|
+
}
|
|
1621
|
+
// Les worktrees des lots repris suivent les mêmes gardes qu'un dépôt (R11) : un fichier suivi modifié refuse la reprise.
|
|
1622
|
+
for (const l of live) {
|
|
1623
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1624
|
+
if (!existsSync(join(dir, '.git')))
|
|
1625
|
+
continue;
|
|
1626
|
+
const d = await trackedDirty(dir);
|
|
1534
1627
|
if (d.length)
|
|
1535
|
-
refusals.push(`${basename(
|
|
1628
|
+
refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1536
1629
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
continue;
|
|
1542
|
-
const d = await trackedDirty(dir);
|
|
1543
|
-
if (d.length)
|
|
1544
|
-
refusals.push(`${basename(dir)} : arbre sale, ${d.length} fichier(s) suivi(s) modifié(s) : ${d.join(', ')}`);
|
|
1545
|
-
}
|
|
1546
|
-
if (refusals.length) {
|
|
1547
|
-
for (const r of refusals)
|
|
1548
|
-
io.err(`orchestrate : ${r}`);
|
|
1549
|
-
return 2;
|
|
1550
|
-
}
|
|
1551
|
-
for (const [l, node] of nodes) {
|
|
1552
|
-
if (node.kind === 'ok')
|
|
1553
|
-
l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
|
|
1554
|
-
else
|
|
1555
|
-
delete l.node;
|
|
1556
|
-
}
|
|
1557
|
-
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
1558
|
-
const recovered = new Budget(0);
|
|
1559
|
-
// La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
|
|
1560
|
-
const workdirOf = (l) => {
|
|
1561
|
-
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1562
|
-
return existsSync(join(dir, '.git')) ? dir : l.repo;
|
|
1563
|
-
};
|
|
1564
|
-
for (const l of [...live, ...removed])
|
|
1565
|
-
for (const s of l.steps)
|
|
1566
|
-
if (s.status === 'interrupted')
|
|
1567
|
-
countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
|
|
1568
|
-
wave.consumed += recovered.consumed;
|
|
1569
|
-
wave.cacheRead += recovered.cacheRead;
|
|
1570
|
-
if (recovered.consumed || recovered.cacheRead)
|
|
1571
|
-
store.writeWave(wave);
|
|
1572
|
-
for (const [l, r] of raised) {
|
|
1573
|
-
const k = lotKey(l.project, l.lot);
|
|
1574
|
-
const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
|
|
1575
|
-
l.budget = r.to;
|
|
1576
|
-
l.status = 'suspended';
|
|
1577
|
-
l.next = r.step;
|
|
1578
|
-
l.outcome = null;
|
|
1579
|
-
l.warnings.push(text);
|
|
1580
|
-
store.journal(`${k} → suspended — ${text}`);
|
|
1581
|
-
io.out(`${k} → suspended — ${text}`);
|
|
1582
|
-
}
|
|
1583
|
-
for (const l of integ) {
|
|
1584
|
-
const k = lotKey(l.project, l.lot);
|
|
1585
|
-
const lead = leadReviewed.get(l);
|
|
1586
|
-
if (lead) {
|
|
1587
|
-
// La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé.
|
|
1588
|
-
l.next = 'integrate';
|
|
1589
|
-
l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
|
|
1590
|
-
l.verdict = lead.verdict;
|
|
1591
|
-
l.constats = [];
|
|
1592
|
-
}
|
|
1593
|
-
const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
|
|
1594
|
-
if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
|
|
1595
|
-
l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
|
|
1596
|
-
l.status = 'suspended';
|
|
1597
|
-
l.outcome = null;
|
|
1598
|
-
const text = lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
|
|
1599
|
-
store.journal(`${k} → suspended — ${text}`);
|
|
1600
|
-
io.out(`${k} → suspended — ${text}`);
|
|
1601
|
-
}
|
|
1602
|
-
for (const l of live) {
|
|
1603
|
-
if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
|
|
1604
|
-
l.status = 'suspended';
|
|
1605
|
-
store.writeLot(l);
|
|
1606
|
-
}
|
|
1607
|
-
if (args.budget !== undefined)
|
|
1608
|
-
wave.budget = wave.consumed + args.budget;
|
|
1609
|
-
store.clearStopRequest(); // un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci
|
|
1610
|
-
for (const l of removed) {
|
|
1611
|
-
const k = lotKey(l.project, l.lot);
|
|
1612
|
-
store.requestDrop(k);
|
|
1613
|
-
store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
|
|
1614
|
-
io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
|
|
1615
|
-
l.next = null;
|
|
1616
|
-
l.status = 'handed-back';
|
|
1617
|
-
l.outcome = DROPPED;
|
|
1618
|
-
store.writeLot(l);
|
|
1619
|
-
store.journal(`${k} → handed-back — ${DROPPED}`);
|
|
1620
|
-
// Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1621
|
-
const branch = lotBranch(wave.id, l.project, l.lot);
|
|
1622
|
-
if (baseBranchOf(l.repo, branch)) {
|
|
1623
|
-
const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
|
|
1624
|
-
io.out(line);
|
|
1625
|
-
store.journal(line);
|
|
1630
|
+
if (refusals.length) {
|
|
1631
|
+
for (const r of refusals)
|
|
1632
|
+
io.err(`orchestrate : ${r}`);
|
|
1633
|
+
return 2;
|
|
1626
1634
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1635
|
+
for (const [l, node] of nodes) {
|
|
1636
|
+
if (node.kind === 'ok')
|
|
1637
|
+
l.node = { version: node.version, wanted: node.wanted, bin: node.bin, link: (deps.linkNode ?? linkNodeBin)(store.dir, node.version, node.bin) };
|
|
1638
|
+
else
|
|
1639
|
+
delete l.node;
|
|
1640
|
+
}
|
|
1641
|
+
// Une étape interrompue (signal, crash) dont les tokens n'ont pas été comptés : relue dans le journal de sa session.
|
|
1642
|
+
const recovered = new Budget(0);
|
|
1643
|
+
// La session d'un lot en worktree a travaillé dans son worktree : c'est là que son journal se range.
|
|
1644
|
+
const workdirOf = (l) => {
|
|
1645
|
+
const dir = lotTreeDir(store.dir, l.project, l.lot, l.repo);
|
|
1646
|
+
return existsSync(join(dir, '.git')) ? dir : l.repo;
|
|
1647
|
+
};
|
|
1648
|
+
for (const l of [...live, ...removed])
|
|
1649
|
+
for (const s of l.steps)
|
|
1650
|
+
if (s.status === 'interrupted')
|
|
1651
|
+
countInterrupted(deps.claudeHome, workdirOf(l), s, recovered);
|
|
1652
|
+
wave.consumed += recovered.consumed;
|
|
1653
|
+
wave.cacheRead += recovered.cacheRead;
|
|
1654
|
+
if (recovered.consumed || recovered.cacheRead)
|
|
1631
1655
|
store.writeWave(wave);
|
|
1656
|
+
for (const [l, r] of raised) {
|
|
1657
|
+
const k = lotKey(l.project, l.lot);
|
|
1658
|
+
const text = `budget relevé de ${r.from} à ${r.to} (--resume --budget)`;
|
|
1659
|
+
l.budget = r.to;
|
|
1660
|
+
l.status = 'suspended';
|
|
1661
|
+
l.next = r.step;
|
|
1662
|
+
l.outcome = null;
|
|
1663
|
+
l.warnings.push(text);
|
|
1664
|
+
store.journal(`${k} → suspended — ${text}`);
|
|
1665
|
+
io.out(`${k} → suspended — ${text}`);
|
|
1632
1666
|
}
|
|
1633
|
-
for (const
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1667
|
+
for (const l of integ) {
|
|
1668
|
+
const k = lotKey(l.project, l.lot);
|
|
1669
|
+
const lead = leadReviewed.get(l);
|
|
1670
|
+
if (lead) {
|
|
1671
|
+
// La revue du lead tient lieu de revue du lot : le rebase reporte son verdict (même patch-id) ou joue la revue courte du diff rebasé.
|
|
1672
|
+
l.next = 'integrate';
|
|
1673
|
+
l.code = { conforme: true, bloquants: 0, majeurs: 0, mineurs: 0, verdict: lead.verdict, sousTaches: [], nonVerifie: [], head: lead.head };
|
|
1674
|
+
l.verdict = lead.verdict;
|
|
1675
|
+
l.constats = [];
|
|
1676
|
+
}
|
|
1677
|
+
const planNote = l.next !== 'integrate'; // rendu sur une écriture de plan refusée (--replay) : le lot repart de l'étape gardée
|
|
1678
|
+
if (planNote && l.outcome?.startsWith(HYPOTHESES_HANDBACK))
|
|
1679
|
+
l.hypotheses = undefined; // les notes d'hypothèses se posent de nouveau (sans doublon) à l'entrée de l'étape
|
|
1680
|
+
l.status = 'suspended';
|
|
1681
|
+
l.outcome = null;
|
|
1682
|
+
const text = lead ? `intégration reprise (--resume --integrate) : revue conforme du lead hors vague, ${lead.head.slice(0, 7)}` : planNote ? `écriture du plan reprise à l'étape ${l.next} (--resume --replay)` : 'intégration reprise (--resume --integrate)';
|
|
1683
|
+
store.journal(`${k} → suspended — ${text}`);
|
|
1684
|
+
io.out(`${k} → suspended — ${text}`);
|
|
1685
|
+
}
|
|
1686
|
+
for (const l of live) {
|
|
1687
|
+
if (l.status === 'implementing' || l.status === 'reviewing' || l.status === 'fixing')
|
|
1688
|
+
l.status = 'suspended';
|
|
1689
|
+
store.writeLot(l);
|
|
1690
|
+
}
|
|
1691
|
+
if (args.budget !== undefined)
|
|
1692
|
+
wave.budget = wave.consumed + args.budget;
|
|
1693
|
+
// Un arrêt demandé à la vague d'avant ne doit pas arrêter celle-ci ; celui demandé pendant l'attente (la reprise est alors visible de --stop-after-current) est gardé.
|
|
1694
|
+
if (!(waiting && !stopBefore && store.control().stopAfterCurrent))
|
|
1695
|
+
store.clearStopRequest();
|
|
1696
|
+
for (const l of removed) {
|
|
1697
|
+
const k = lotKey(l.project, l.lot);
|
|
1698
|
+
store.requestDrop(k);
|
|
1699
|
+
store.journal(`demande : retirer ${k} de la vague (--resume --drop)`);
|
|
1700
|
+
io.out(`${k} : retrait demandé — le lot est rendu au lead sans être rejoué`);
|
|
1701
|
+
l.next = null;
|
|
1702
|
+
l.status = 'handed-back';
|
|
1703
|
+
l.outcome = DROPPED;
|
|
1704
|
+
store.writeLot(l);
|
|
1705
|
+
store.journal(`${k} → handed-back — ${DROPPED}`);
|
|
1706
|
+
// Un lot retiré garde son worktree et sa branche (R12) : le journal en donne le chemin.
|
|
1707
|
+
const branch = lotBranch(wave.id, l.project, l.lot);
|
|
1708
|
+
if (baseBranchOf(l.repo, branch)) {
|
|
1709
|
+
const line = `orchestrate : ${k} : worktree conservé : ${lotTreeDir(store.dir, l.project, l.lot, l.repo)} (branche ${branch})`;
|
|
1710
|
+
io.out(line);
|
|
1711
|
+
store.journal(line);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
if (live.length === 0) {
|
|
1715
|
+
if (lots.every(lotFinished)) {
|
|
1716
|
+
wave.status = 'done';
|
|
1717
|
+
store.writeWave(wave);
|
|
1718
|
+
}
|
|
1719
|
+
for (const line of renderTable(wave, lots))
|
|
1720
|
+
io.out(line);
|
|
1721
|
+
for (const line of keptLines(lots.map((l) => l.repo)))
|
|
1722
|
+
io.out(line);
|
|
1723
|
+
return removed.length && !lots.every((l) => l.status === 'ready') ? 1 : 0;
|
|
1724
|
+
}
|
|
1725
|
+
played = true;
|
|
1726
|
+
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|
|
1727
|
+
}
|
|
1728
|
+
finally {
|
|
1729
|
+
if (!played)
|
|
1730
|
+
for (const f of taken)
|
|
1731
|
+
releaseLock(f, process.pid);
|
|
1638
1732
|
}
|
|
1639
|
-
return continueRounds(await execute(wave, live, store, io, deps, today, maxSessions(args, io), lots), wave, lots, store, io, deps, today, args, launch);
|
|
1640
1733
|
}
|
|
1641
1734
|
/** `loggedIn` de la sortie JSON de `claude auth status` ; null si elle n'est pas lisible (versions sans cette commande : pas de refus). */
|
|
1642
1735
|
export function parseAuthStatus(text) {
|
|
@@ -2,8 +2,18 @@ import { chmodSync, existsSync, lstatSync, readdirSync, statSync, readFileSync,
|
|
|
2
2
|
import { execFile, execFileSync } from 'node:child_process';
|
|
3
3
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
5
|
+
import { readOrchestrateConfig } from '../config.js';
|
|
5
6
|
import { gitCommonDir, gitRoot, hooksDir, indexLockPath } from '../git.js';
|
|
6
7
|
const MARK = '# cadence orchestrate — hook pre-push temporaire (retiré à la fin de la vague)';
|
|
8
|
+
/** Le hook que la vague laisse derrière elle quand le projet déclare une porte (L426) : il ne garde que le verdict de la porte au push. */
|
|
9
|
+
const GATE_MARK = '# cadence — hook pre-push de la porte (L426) : refuse un push sans verdict vert de `cadence verify --gate`';
|
|
10
|
+
const gateHook = `#!/bin/sh
|
|
11
|
+
${GATE_MARK}
|
|
12
|
+
if command -v raf >/dev/null 2>&1; then raf hook pre-push "$@"; [ $? -ne 3 ] || exit 1; fi
|
|
13
|
+
exit 0
|
|
14
|
+
`;
|
|
15
|
+
/** Un hook de cadence (de la vague ou de la porte) : le remplacer ne touche à aucune règle du projet. */
|
|
16
|
+
const ours = (text) => text.includes(MARK) || text.includes(GATE_MARK);
|
|
7
17
|
const waveLine = (wave) => `# wave: ${wave}`;
|
|
8
18
|
const hook = (wave) => `#!/bin/sh
|
|
9
19
|
${MARK}
|
|
@@ -204,7 +214,7 @@ export function cleanPlaywrightOutput(repo) {
|
|
|
204
214
|
*/
|
|
205
215
|
export function installPrePush(repo, wave = '?') {
|
|
206
216
|
const file = hookPath(repo);
|
|
207
|
-
if (existsSync(file) && !readFileSync(file, 'utf8')
|
|
217
|
+
if (existsSync(file) && !ours(readFileSync(file, 'utf8'))) {
|
|
208
218
|
return { ok: false, reason: `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` };
|
|
209
219
|
}
|
|
210
220
|
mkdirSync(dirname(file), { recursive: true });
|
|
@@ -234,10 +244,32 @@ export function removePrePush(repo, wave) {
|
|
|
234
244
|
removePlaywrightExclude(repo);
|
|
235
245
|
restoreExclude(repo);
|
|
236
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Fin de la garde de la vague sur un dépôt (L426) : quand le projet déclare une porte (`orchestrate.test` ou `orchestrate.build`), le hook de la vague
|
|
249
|
+
* est remplacé par un hook qui ne garde que la porte, sinon le push de livraison, qui vient après la fin des lots du dépôt, ne serait plus contrôlé.
|
|
250
|
+
* Rien n'est laissé quand aucune porte n'est déclarée (ou la config illisible), quand un autre hook pre-push est en place (celui du projet, ou celui d'une autre
|
|
251
|
+
* vague vivante : il reste), ni quand le dossier des hooks est dans l'arbre suivi (le fichier y serait un fichier non suivi du dépôt).
|
|
252
|
+
*/
|
|
253
|
+
export function keepGateGuard(repo) {
|
|
254
|
+
try {
|
|
255
|
+
const config = readOrchestrateConfig(join(gitRoot(repo) ?? repo, 'cadence.yaml'));
|
|
256
|
+
if (!config.test && !config.build)
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const file = hookPath(repo);
|
|
263
|
+
if (existsSync(file) || excludeLine(repo) !== null)
|
|
264
|
+
return;
|
|
265
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
266
|
+
writeFileSync(file, gateHook);
|
|
267
|
+
chmodSync(file, 0o755);
|
|
268
|
+
}
|
|
237
269
|
/** Pourrait-on poser le hook ? (préconditions, sans écrire) */
|
|
238
270
|
export function canInstallPrePush(repo) {
|
|
239
271
|
const file = hookPath(repo);
|
|
240
|
-
return existsSync(file) && !readFileSync(file, 'utf8')
|
|
272
|
+
return existsSync(file) && !ours(readFileSync(file, 'utf8')) ? `un hook pre-push existe déjà (${file}) : l'orchestrateur ne le remplace pas` : null;
|
|
241
273
|
}
|
|
242
274
|
/** git sans bloquer la boucle d'événements (un `ls-remote` peut durer 20 s) ; null en cas d'échec ou de délai. */
|
|
243
275
|
function out(repo, args, timeout = 20_000) {
|
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { gitCommonDir } from '../git.js';
|
|
5
5
|
import { sharedStateDir } from '../state.js';
|
|
6
|
-
import {
|
|
6
|
+
import { releaseLock, takeLock } from './lock.js';
|
|
7
7
|
import { mergeUnreleased, moveToUnreleased, settleLotEntries, unreleasedSections } from './changelog-merge.js';
|
|
8
8
|
import { mutexFor } from './mutex.js';
|
|
9
9
|
import { Plan } from '../plan.js';
|
|
@@ -19,20 +19,17 @@ export const MAX_REPLAYS = 2;
|
|
|
19
19
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
20
|
/**
|
|
21
21
|
* Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
|
|
22
|
-
* vague partagent un pid, `takeLock` rendrait chacun déjà porteur), puis le verrou fichier, qui ne départage que les autres processus.
|
|
23
|
-
*
|
|
22
|
+
* vague partagent un pid, `takeLock` rendrait chacun déjà porteur), puis le verrou fichier, qui ne départage que les autres processus. La file en mémoire
|
|
23
|
+
* s'attend sans borne (L425) : son porteur est un lot de ce processus, qui libère toujours, et une intégration (rebase rejoué + porte) dure plus que la borne.
|
|
24
|
+
* L'attente du verrou fichier d'un autre processus est bornée par `waitMs` (R25), comptée à partir du tour ; au-delà, le porteur est rendu et rien n'est pris.
|
|
25
|
+
* Une attente ne retient aucun créneau de session : ce verrou n'a rien à voir avec eux.
|
|
24
26
|
*/
|
|
25
27
|
export async function takeRepoWrite(repo, who, o = {}) {
|
|
26
28
|
const waitMs = o.waitMs ?? INTEGRATE_WAIT_MS;
|
|
27
29
|
const pollMs = o.pollMs ?? INTEGRATE_POLL_MS;
|
|
28
30
|
const file = join(sharedStateDir(repo), INTEGRATION_LOCK);
|
|
31
|
+
const turn = (await mutexFor(`integrate:${gitCommonDir(repo)}`).acquire());
|
|
29
32
|
const deadline = Date.now() + waitMs;
|
|
30
|
-
const turn = await mutexFor(`integrate:${gitCommonDir(repo)}`).acquire(waitMs);
|
|
31
|
-
if (!turn) {
|
|
32
|
-
// Un autre lot de ce processus tient la main : c'est lui le porteur (il a posé le verrou fichier).
|
|
33
|
-
const holder = activeLock(file);
|
|
34
|
-
return { ok: false, holder: { wave: holder?.wave ?? who.wave, pid: holder?.pid ?? process.pid } };
|
|
35
|
-
}
|
|
36
33
|
for (;;) {
|
|
37
34
|
const lock = { pid: process.pid, wave: who.wave, started: new Date().toISOString() };
|
|
38
35
|
const got = takeLock(file, lock);
|
|
@@ -192,7 +192,7 @@ export class SlotAbandoned extends Error {
|
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
194
|
* Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
|
|
195
|
-
* soit sous le plafond commun — `cap` (le plafond de cette vague) ou, s'il est plus bas, celui d'une autre vague vivante
|
|
195
|
+
* — plus les sessions du lead inscrites par `lead hold` (L424), qu'aucune vague ne porte — soit sous le plafond commun — `cap` (le plafond de cette vague) ou, s'il est plus bas, celui d'une autre vague vivante
|
|
196
196
|
* (`commonCap`, relu à chaque tour, L395) —, puis prend le premier créneau libre (un pid mort est repris) et
|
|
197
197
|
* rend sa libération. Toutes les vagues appliquent le même plafond : jamais « 4/3 en cours ».
|
|
198
198
|
* Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
|
|
@@ -221,13 +221,15 @@ export async function acquireSlot(home, cap, opts) {
|
|
|
221
221
|
const limit = commonCap(liveWaves(home), cap); // relu à chaque tour : une vague au plafond bas qui s'arrête le relève
|
|
222
222
|
if (reportedAt !== null)
|
|
223
223
|
checkAbandon(); // pas à la première tentative : un créneau libre est pris même si une demande est posée
|
|
224
|
-
|
|
224
|
+
// Les sessions du lead (`lead hold`) tournent hors de toute vague mais prennent un créneau : relues à chaque comptage (L424).
|
|
225
|
+
const taken = () => live(home).length + liveHolds(home, new Date()).length;
|
|
226
|
+
if (taken() < limit) {
|
|
225
227
|
for (let k = 0; k < MAX_SLOT_INDEX; k++) {
|
|
226
228
|
const file = join(slotsDir(home), `slot-${k}.lock`);
|
|
227
229
|
const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
|
|
228
230
|
if (!got.ok)
|
|
229
231
|
continue;
|
|
230
|
-
if (
|
|
232
|
+
if (taken() <= limit) {
|
|
231
233
|
if (reportedAt !== null)
|
|
232
234
|
opts.onGot?.(now() - t0);
|
|
233
235
|
return () => releaseLock(file, process.pid);
|
|
@@ -240,7 +242,7 @@ export async function acquireSlot(home, cap, opts) {
|
|
|
240
242
|
checkAbandon(); // la première tentative a échoué : l'attente commence, la demande déjà posée l'interrompt sans attendre un tour
|
|
241
243
|
if (reportedAt === null || now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
|
|
242
244
|
reportedAt = now();
|
|
243
|
-
opts.onWait?.(live(home), reportedAt - t0, limit);
|
|
245
|
+
opts.onWait?.(live(home), reportedAt - t0, limit, liveHolds(home, new Date()));
|
|
244
246
|
}
|
|
245
247
|
await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
|
|
246
248
|
}
|