@sylad/cadence 0.10.1 → 0.11.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.
@@ -6,7 +6,7 @@
6
6
  {
7
7
  "name": "cadence",
8
8
  "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).",
9
- "version": "0.10.1",
9
+ "version": "0.11.0",
10
10
  "source": "./",
11
11
  "author": { "name": "Sylvain Ladoire" }
12
12
  }
@@ -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": "0.10.1",
4
+ "version": "0.11.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
@@ -19,6 +19,17 @@ several projects through subagents — plus three reviewer agents: `ux-reviewer`
19
19
  gate, and `qa-reviewer`, which walks the delivered app in a real browser and
20
20
  reports a page left empty or in error.
21
21
 
22
+ ## What's new
23
+
24
+ **0.11.0**: waves on different repositories run side by side under a shared cap of
25
+ simultaneous sessions (`--max-sessions`, 2 by default), and every release now has its
26
+ changelog section and GitHub release. **0.10.1**: the QA expectations file (`docs/qa/expectations.md`) is kept with the plan —
27
+ a commit that only touches it no longer has to cite a lot. **0.10.0**: `cadence orchestrate`
28
+ sends an already-committed lot straight to review, and sessions only ask questions that
29
+ name what they would change.
30
+
31
+ Every version, with what it brings and since when: [CHANGELOG.md](CHANGELOG.md).
32
+
22
33
  ## raf
23
34
 
24
35
  - The plan is a YAML file in the repo (`docs/plan/raf.yaml`), edited by the CLI.
@@ -632,13 +643,14 @@ exception is a **formatting retry** (below).
632
643
  cadence orchestrate finance-tracker:L41 ol-companion:L22 cadence:L18@haiku
633
644
  cadence orchestrate L18 # from inside a project
634
645
  cadence orchestrate … --budget 1.5M # 1500000, 1.5M, 800k; default 2M
646
+ cadence orchestrate … --max-sessions 3 # sessions running at the same moment, all waves together; default 2
635
647
  cadence orchestrate … --dry-run # preconditions + the plan of the wave; nothing is started
636
- cadence orchestrate --status [<wave>] # the table, read back from the state (default: the last wave)
648
+ cadence orchestrate --status [<wave>] # the live waves and the repositories they hold, then the table (default: the last wave of this folder)
637
649
  cadence orchestrate --resume [<wave>] [--budget 1M] [--answer ol-companion:L22 "reply"]
638
650
  ```
639
651
 
640
- You choose the lots; the order is the order given (one queue per repository, two repositories at most
641
- at the same time). `@haiku|@sonnet|@opus` sets the model of the implementation and corrections of that
652
+ You choose the lots; the order is the order given (one queue per repository, as many repositories at the
653
+ same time as the session cap allows — 2 by default). `@haiku|@sonnet|@opus` sets the model of the implementation and corrections of that
642
654
  lot (default Sonnet; reviews are always Opus; Haiku only when you write it, for a mechanical lot). Run it
643
655
  in the background and read `--status`: it prints one line per transition and the final table.
644
656
 
@@ -678,9 +690,24 @@ review with minors: it concludes on that review instead of staying suspended.
678
690
  after the wave (`git log`, tests, `raf check`), `raf done`, **`raf ux`** (the orchestrator reports the UX
679
691
  verdict and screenshots, it does not record it), the push and the deliveries, one project at a time.
680
692
 
681
- **Guards, imposed by the code**: at most two sessions, one per repository (a lock in the repository's
682
- shared state, `orchestrate.lock`, which also makes `cadence deliver` refuse that repository) and one wave
683
- per folder; `Agent`, `git push`, `cadence deliver`, `raf done|review|ux` are denied to the sessions; a
693
+ **Several waves at once**: the lock is per **repository** (a lock in the repository's shared state,
694
+ `orchestrate.lock`, which also makes `cadence deliver` refuse that repository), not per folder. A wave is
695
+ refused only when one of its repositories is held by a live wave (`<repo> : une orchestration y est déjà en
696
+ cours`; the lock of a dead process is detected and cleared); two waves on different repositories run side by
697
+ side, even when started from the same parent folder. They share a **cap on simultaneous sessions**, counted
698
+ across all live waves: 2 by default, `--max-sessions N` (or `CADENCE_MAX_SESSIONS=N`) to change it — give
699
+ every wave the same value: each wave counts ALL live sessions, whatever their slot, and waits while that
700
+ count has reached ITS OWN cap, so with different caps the highest one can push the total past the lowest
701
+ (which then waits). A session that finds no free slot **waits** (the wave is not refused; the journal says
702
+ `en attente d'un créneau de session depuis …`, repeated every minute, then `créneau de session obtenu après …`).
703
+ A slot or a registry entry is owned by a pid **and** its start time: a reused pid is a dead owner. Wave
704
+ identifiers are reserved atomically (`-2`, `-3` suffix when two waves start in the same minute; an existing
705
+ `--wave` is refused). The registry of live waves and the slots live under `~/.cadence/orchestrate/` (`CADENCE_HOME`
706
+ to move it): `cadence orchestrate --status` lists, from any folder, the live waves, the repositories each
707
+ holds, the cap of each wave and the slots in use.
708
+
709
+ **Guards, imposed by the code**: a global cap of simultaneous sessions, one wave per repository at
710
+ a time (above); `Agent`, `git push`, `cadence deliver`, `raf done|review|ux` are denied to the sessions; a
684
711
  temporary `pre-push` hook, installed for the duration of the wave and removed at its end, refuses any push
685
712
  from a session (`CADENCE_ORCHESTRATED` is in their environment; a repository that already has another
686
713
  `pre-push` hook is refused before anything starts — `pushurl` is never touched); after every session the
@@ -802,13 +829,18 @@ A version exists in three places and is published in two; a release does all of
802
829
 
803
830
  1. Bump `version` in `package.json` (then `npm install` to refresh `package-lock.json`),
804
831
  `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`, in the commit that closes the lot.
832
+ Write the version's section of `CHANGELOG.md` (`## [x.y.z] - date`, what changes for the user, lots cited)
833
+ and refresh the « What's new » summary of this README in that same commit.
805
834
  2. `git tag v<version> && git push origin main v<version>` — the tag starts `.github/workflows/publish.yml`,
806
835
  which publishes to npm through Trusted Publishing (OIDC, no token stored anywhere): it checks the tag
807
- matches `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json`, then `npm publish --provenance`, where `prepublishOnly` runs the type-check and the
808
- tests and `prepare` builds `dist/`; a red suite stops the publication. The trusted publisher is declared
836
+ matches `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` and that `CHANGELOG.md` has a `## [x.y.z]` section for it (no section, no publication), then `npm publish --provenance`, where `prepublishOnly` runs the type-check and the
837
+ tests and `prepare` builds `dist/`; a red suite stops the publication; then it creates the GitHub release with that CHANGELOG section as its text. The trusted publisher is declared
809
838
  once on npmjs.com (package settings → Trusted Publisher → GitHub Actions, `Sylad/cadence`, `publish.yml`).
810
839
  3. Watch the run: `gh run watch` (or `gh run list --workflow publish.yml`).
811
840
  4. Check the effect: `npm view @sylad/cadence version` answers the new version.
841
+ If the package is on npm but the GitHub release is missing (the release step failed after `npm publish`),
842
+ re-running the job fails at `npm publish`: create the release by hand with
843
+ `gh release create v<version> --title v<version> --notes-file <the section of CHANGELOG.md> --verify-tag`.
812
844
 
813
845
  The Claude Code plugin is read from the repository, so pushing `main` is what updates it; npm is what
814
846
  `npx @sylad/cadence` and a global install read, and only the tag publishes there. A missing tag, or a red
package/bin/cadence.js CHANGED
@@ -34,8 +34,9 @@ if (tool === 'raf') {
34
34
  cadence verify [--retry secondes] [--sha rév]
35
35
  rejoue les vérifications d'effet (deliver.verify) hors livraison, une passe, en parallèle ;
36
36
  code 0 tout vert, 1 un effet rouge, 2 rien à vérifier ; « session start » la lance aussi
37
- cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run]
38
- une session claude neuve par étape (implémentation, revues, corrections) ; --status, --resume
37
+ cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--max-sessions 2] [--wave id] [--dry-run]
38
+ une session claude neuve par étape (implémentation, revues, corrections) ; --status, --resume ;
39
+ --max-sessions : sessions simultanées, toutes vagues confondues (CADENCE_MAX_SESSIONS)
39
40
  cadence skills install [--dir .claude] [--force]
40
41
  installe les skills Claude Code session-start, session-close, deliver et l'agent ux-reviewer`);
41
42
  process.exitCode = !tool || ['help', '--help', '-h'].includes(tool) ? 0 : 2;
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
5
  import { readOrchestrateConfig, readPlanConfig } from '../config.js';
@@ -9,6 +9,7 @@ import { onTermination } from '../proc.js';
9
9
  import { Plan, RafError, isOpen } from '../plan.js';
10
10
  import { AGENTS_DIR } from '../skills.js';
11
11
  import { pidAlive, sharedStateDir } from '../state.js';
12
+ import { acquireSlot, cadenceHome, liveSlots, liveWaves, registerWave, unregisterWave } from './registry.js';
12
13
  import { loadTemplates, objective, renderBrief } from './briefs.js';
13
14
  import { Budget, MAX_PASSES, countInterrupted } from './cycle.js';
14
15
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
@@ -19,6 +20,8 @@ import { schemaFor } from './schemas.js';
19
20
  import { excludeState, lotKey, newLot, RunStore } from './state.js';
20
21
  import { quotaText, renderTable } from './table.js';
21
22
  export const DEFAULT_BUDGET = 2_000_000;
23
+ /** Sessions simultanées, toutes vagues confondues (`--max-sessions`, ou CADENCE_MAX_SESSIONS). */
24
+ export const DEFAULT_MAX_SESSIONS = 2;
22
25
  /** `1500000`, `1.5M`, `800k`. */
23
26
  export function parseBudget(text) {
24
27
  const m = /^(\d+(?:[.,]\d+)?)\s*([kKmM]?)$/.exec(text.trim());
@@ -29,6 +32,11 @@ export function parseBudget(text) {
29
32
  throw new RafError(`--budget invalide : ${text}`);
30
33
  return Math.round(n);
31
34
  }
35
+ export function parseMaxSessions(text, what = '--max-sessions') {
36
+ if (!/^\d+$/.test(text.trim()) || Number(text) < 1)
37
+ throw new RafError(`${what} invalide : ${text} (entier ≥ 1)`);
38
+ return Number(text);
39
+ }
32
40
  const LOT_LIKE = /^(?:[\w.-]+:)?[A-Za-z]+\d+(?:@\w+)?$/;
33
41
  const MODELS = ['sonnet', 'opus', 'haiku'];
34
42
  function lotArg(text) {
@@ -59,6 +67,8 @@ export function parseOrchestrateArgs(argv) {
59
67
  }
60
68
  else if (t === '--budget')
61
69
  a.budget = parseBudget(value(t));
70
+ else if (t === '--max-sessions')
71
+ a.maxSessions = parseMaxSessions(value(t));
62
72
  else if (t === '--wave')
63
73
  a.wave = value(t);
64
74
  else if (t === '--answer') {
@@ -122,9 +132,6 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
122
132
  refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
123
133
  else if (!info.jsonSchema)
124
134
  refusals.push(`claude ${info.version} n'a pas --json-schema : mettre claude à jour`);
125
- const held = activeLock(join(launch, '.cadence', 'orchestrate.lock'));
126
- if (held && !opts.resume)
127
- refusals.push(`une vague est déjà en cours dans ${launch} (${held.wave}, pid ${held.pid})`);
128
135
  const lots = [];
129
136
  const repoChecked = new Set();
130
137
  const byProject = new Map();
@@ -185,14 +192,17 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
185
192
  function agentsOf(deps) {
186
193
  return readAgents(deps.agentsDir);
187
194
  }
195
+ const WAVE_ID_RE = /^[\w.-]+$/;
196
+ /** Identifiant par défaut : jour et minute de lancement ; le suffixe qui départage deux vagues vient de `RunStore.reserve`. */
197
+ function defaultWaveBase(io) {
198
+ const n = io.now();
199
+ return `${toDay(n)}-${String(n.getHours()).padStart(2, '0')}${String(n.getMinutes()).padStart(2, '0')}`;
200
+ }
201
+ /** Identifiant d'une simulation : le premier libre, sans rien réserver ni écrire. */
188
202
  function waveId(io, requested, launch) {
189
- if (requested) {
190
- if (!/^[\w.-]+$/.test(requested))
191
- throw new RafError(`--wave invalide : ${requested}`);
203
+ if (requested)
192
204
  return requested;
193
- }
194
- const n = io.now();
195
- const base = `${toDay(n)}-${String(n.getHours()).padStart(2, '0')}${String(n.getMinutes()).padStart(2, '0')}`;
205
+ const base = defaultWaveBase(io);
196
206
  let id = base;
197
207
  for (let i = 2; existsSync(join(RunStore.runsDir(launch), id)); i++)
198
208
  id = `${base}-${i}`;
@@ -231,6 +241,33 @@ function dryRun(lots, io, deps, budget, id) {
231
241
  }
232
242
  }
233
243
  }
244
+ function maxSessions(args, io) {
245
+ if (args.maxSessions !== undefined)
246
+ return args.maxSessions;
247
+ const env = io.env.CADENCE_MAX_SESSIONS;
248
+ return env ? parseMaxSessions(env, 'CADENCE_MAX_SESSIONS') : DEFAULT_MAX_SESSIONS;
249
+ }
250
+ function duration(ms) {
251
+ const s = Math.round(ms / 1000);
252
+ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
253
+ }
254
+ /** Vagues vivantes (toutes, quel que soit leur dossier de départ) et dépôts qu'elles tiennent. */
255
+ function liveLines() {
256
+ const home = cadenceHome();
257
+ const waves = liveWaves(home);
258
+ if (waves.length === 0)
259
+ return [];
260
+ const slots = liveSlots(home).length;
261
+ const lines = [`vagues en cours : ${waves.length} · sessions en cours : ${slots}`];
262
+ for (const w of waves)
263
+ lines.push(` ${w.wave} (pid ${w.pid}, depuis ${w.started}) lancée depuis ${w.cwd} · plafond ${w.cap ?? '?'} · dépôts : ${w.repos.join(', ')}`);
264
+ return lines;
265
+ }
266
+ /** Refus d'une `--wave` déjà existante, identique en simulation et au vrai lancement. */
267
+ function waveExists(wave, io) {
268
+ io.err(`orchestrate : --wave ${wave} : cette vague existe déjà`);
269
+ return 2;
270
+ }
234
271
  /** Point d'entrée de `cadence orchestrate`. 0 prêts · 1 rendus au lead · 2 refus avant d'agir · 3 suspendue (budget, quota). */
235
272
  export async function orchestrate(argv, io, deps) {
236
273
  const args = parseOrchestrateArgs(argv);
@@ -238,8 +275,16 @@ export async function orchestrate(argv, io, deps) {
238
275
  const today = io.env.RAF_TODAY && isDay(io.env.RAF_TODAY) ? io.env.RAF_TODAY : toDay(io.now());
239
276
  if (args.status !== undefined) {
240
277
  const store = typeof args.status === 'string' ? RunStore.find(launch, args.status) : RunStore.last(launch);
278
+ maxSessions(args, io); // une valeur invalide est refusée ici aussi
279
+ const live = liveLines();
280
+ for (const line of live)
281
+ io.out(line);
282
+ if (!store && live.length && args.status === true)
283
+ return 0; // sans identifiant : les vagues vivantes suffisent
241
284
  if (!store)
242
285
  throw new RafError(typeof args.status === 'string' ? `vague inconnue : ${args.status}` : 'aucune vague dans ce dossier');
286
+ if (live.length)
287
+ io.out('');
243
288
  for (const line of renderTable(store.readWave(), store.lots()))
244
289
  io.out(line);
245
290
  return 0;
@@ -247,7 +292,7 @@ export async function orchestrate(argv, io, deps) {
247
292
  if (args.resume !== undefined)
248
293
  return resume(args, io, deps, launch, today);
249
294
  if (args.lots.length === 0)
250
- throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
295
+ throw new RafError('usage : cadence orchestrate <projet>:<lot>… [--budget 2M] [--max-sessions 2] [--dry-run] | --status [vague] | --resume [vague] [--answer projet:lot "réponse"]');
251
296
  const refusals = [];
252
297
  const targets = resolveTargets(args, io, refusals);
253
298
  const pre = await preflight(args, targets, io, deps, launch);
@@ -258,22 +303,43 @@ export async function orchestrate(argv, io, deps) {
258
303
  io.err(`orchestrate : ${r}`);
259
304
  return 2;
260
305
  }
261
- const id = waveId(io, args.wave, launch);
306
+ if (args.wave !== undefined && !WAVE_ID_RE.test(args.wave))
307
+ throw new RafError(`--wave invalide : ${args.wave}`);
262
308
  if (args.dryRun) {
309
+ const id = waveId(io, args.wave, launch);
310
+ if (args.wave && existsSync(join(RunStore.runsDir(launch), id)))
311
+ return waveExists(args.wave, io);
263
312
  dryRun(pre.lots, io, deps, budget, id);
264
313
  return 0;
265
314
  }
266
- const store = new RunStore(launch, id);
315
+ const store = RunStore.reserve(launch, args.wave ?? defaultWaveBase(io), { exact: args.wave !== undefined });
316
+ if (!store)
317
+ return waveExists(args.wave, io);
318
+ const id = store.id;
267
319
  const wave = { id, created: io.now().toISOString(), cwd: launch, budget, consumed: 0, cacheRead: 0, status: 'running', pid: process.pid, lots: pre.lots.map((l) => lotKey(l.project, l.lot)) };
268
320
  for (const l of pre.lots)
269
321
  store.writeLot(l);
270
- return execute(wave, pre.lots, store, io, deps, today);
322
+ // Une vague qui n'a pas atteint `wave.json` (refus au verrou, démarrage en échec) ne laisse pas son dossier réservé :
323
+ // le même `--wave` se relance, et `reserve` ne le prend pas pour une vague existante.
324
+ const abandon = () => {
325
+ if (!existsSync(join(store.dir, 'wave.json')))
326
+ rmSync(store.dir, { recursive: true, force: true });
327
+ };
328
+ try {
329
+ const code = await execute(wave, pre.lots, store, io, deps, today, maxSessions(args, io));
330
+ abandon();
331
+ return code;
332
+ }
333
+ catch (e) {
334
+ abandon();
335
+ throw e;
336
+ }
271
337
  }
272
338
  function contexts(lots, envs, wctx) {
273
339
  return lots.map((lot, i) => ({ wave: wctx, lot, config: envs[i].config, loadPlan: envs[i].loadPlan }));
274
340
  }
275
341
  /** Pose les verrous et les hooks, joue la vague, range l'état, rend le tableau et le code de sortie. */
276
- async function execute(wave, lots, store, io, deps, today, all = lots) {
342
+ async function execute(wave, lots, store, io, deps, today, cap, all = lots) {
277
343
  const launch = store.launchDir;
278
344
  // La configuration des projets est lue avant de poser quoi que ce soit : un cadence.yaml illisible ne laisse ni verrou ni hook.
279
345
  const envs = lots.map((lot) => projectEnv(lot.repo));
@@ -281,7 +347,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
281
347
  const templates = loadTemplates(deps.templatesDir);
282
348
  mkdirSync(join(launch, '.cadence'), { recursive: true });
283
349
  excludeState(launch);
284
- const waveLock = join(launch, '.cadence', 'orchestrate.lock');
350
+ const home = cadenceHome();
285
351
  const held = [];
286
352
  const repos = [...new Set(lots.map((l) => l.repo))];
287
353
  const release = () => {
@@ -290,15 +356,13 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
290
356
  held.length = 0;
291
357
  for (const r of repos)
292
358
  removePrePush(r, wave.id);
359
+ try {
360
+ unregisterWave(home, process.pid);
361
+ }
362
+ catch {
363
+ // registre illisible : l'entrée d'un processus mort est écartée à la prochaine inscription
364
+ }
293
365
  };
294
- const got = takeLock(waveLock, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
295
- if (!got.ok) {
296
- io.err(`orchestrate : une vague est déjà en cours dans ${launch} (${got.held.wave}, pid ${got.held.pid})`);
297
- return 2;
298
- }
299
- held.push(waveLock);
300
- if (got.stale)
301
- io.err(`orchestrate : verrou de vague périmé retiré (${got.stale.wave}, pid ${got.stale.pid} mort)`);
302
366
  for (const r of repos) {
303
367
  const file = join(sharedStateDir(r), REPO_LOCK);
304
368
  const l = takeLock(file, { pid: process.pid, wave: wave.id, started: new Date().toISOString() });
@@ -317,6 +381,28 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
317
381
  return 2;
318
382
  }
319
383
  }
384
+ try {
385
+ registerWave(home, { pid: process.pid, wave: wave.id, started: new Date().toISOString(), cwd: launch, repos, cap });
386
+ }
387
+ catch (e) {
388
+ release(); // verrous et hooks ne restent pas posés si le démarrage échoue
389
+ throw e;
390
+ }
391
+ // Plafond de sessions simultanées, toutes vagues confondues : chaque session attend un créneau libre avant de partir.
392
+ const claude = async (args, o) => {
393
+ const free = await acquireSlot(home, cap, {
394
+ wave: wave.id,
395
+ pollMs: deps.slotPollMs,
396
+ onWait: (h, ms) => wctx.log(`en attente d'un créneau de session depuis ${duration(ms)} (${h.length}/${cap} en cours : ${[...new Set(h.map((x) => x.wave))].join(', ')})`),
397
+ onGot: (ms) => wctx.log(`créneau de session obtenu après ${duration(ms)} d'attente`),
398
+ });
399
+ try {
400
+ return await deps.claude(args, o);
401
+ }
402
+ finally {
403
+ free();
404
+ }
405
+ };
320
406
  const budget = new Budget(wave.budget);
321
407
  budget.consumed = wave.consumed;
322
408
  budget.cacheRead = wave.cacheRead;
@@ -330,7 +416,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
330
416
  id: wave.id,
331
417
  store,
332
418
  budget,
333
- claude: deps.claude,
419
+ claude,
334
420
  agents: agentsOf(deps),
335
421
  today,
336
422
  claudeHome: deps.claudeHome,
@@ -367,7 +453,7 @@ async function execute(wave, lots, store, io, deps, today, all = lots) {
367
453
  release();
368
454
  });
369
455
  try {
370
- await runPool(ctxs, undefined, all);
456
+ await runPool(ctxs, cap, all);
371
457
  }
372
458
  finally {
373
459
  forget();
@@ -396,8 +482,8 @@ async function resume(args, io, deps, launch, today) {
396
482
  const info = deps.claudeInfo();
397
483
  if (!info)
398
484
  refusals.push('claude introuvable (CADENCE_CLAUDE_BIN, ou claude dans le PATH)');
399
- const alive = activeLock(join(launch, '.cadence', 'orchestrate.lock'));
400
- if (alive && alive.pid !== process.pid)
485
+ const alive = liveWaves(cadenceHome()).find((w) => w.wave === wave.id && w.cwd === launch && w.pid !== process.pid);
486
+ if (alive)
401
487
  refusals.push(`la vague ${alive.wave} tourne encore (pid ${alive.pid})`);
402
488
  if (wave.status === 'done')
403
489
  refusals.push(`la vague ${wave.id} est terminée`);
@@ -475,7 +561,7 @@ async function resume(args, io, deps, launch, today) {
475
561
  io.out(line);
476
562
  return 0;
477
563
  }
478
- return execute(wave, live, store, io, deps, today, lots);
564
+ return execute(wave, live, store, io, deps, today, maxSessions(args, io), lots);
479
565
  }
480
566
  /** Dépendances réelles : `claude` (ou CADENCE_CLAUDE_BIN), agents et gabarits du paquet, journaux de ~/.claude. */
481
567
  export function realOrchestrateDeps(env) {
@@ -1,6 +1,19 @@
1
1
  import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
+ import { processStart } from '../proc.js';
3
4
  import { pidAlive } from '../state.js';
5
+ /**
6
+ * Le porteur vit encore : son pid existe ET, quand l'heure de démarrage est connue des deux côtés, c'est la même.
7
+ * Un pid repris par un autre processus est un porteur mort.
8
+ */
9
+ export function holderAlive(pid, start) {
10
+ if (!pidAlive(pid))
11
+ return false;
12
+ if (!start)
13
+ return true;
14
+ const now = processStart(pid);
15
+ return now === null || now === start;
16
+ }
4
17
  /** Nom du verrou d'orchestration d'un dépôt, dans l'état commun aux worktrees (celui de `deliver`). */
5
18
  export const REPO_LOCK = 'orchestrate.lock';
6
19
  function read(file) {
@@ -15,13 +28,13 @@ function read(file) {
15
28
  }
16
29
  try {
17
30
  const raw = JSON.parse(text);
18
- return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), ageMs };
31
+ return { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), start: raw.start === undefined ? undefined : String(raw.start), ageMs };
19
32
  }
20
33
  catch {
21
34
  return { pid: 0, wave: '?', started: '?', unreadable: true, ageMs };
22
35
  }
23
36
  }
24
- const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : pidAlive(l.pid));
37
+ const alive = (l) => (l.unreadable ? l.ageMs < 5_000 : holderAlive(l.pid, l.start));
25
38
  /** Le verrou s'il est tenu par un processus vivant, sinon null (absent ou périmé). */
26
39
  export function activeLock(file) {
27
40
  const l = read(file);
@@ -31,7 +44,8 @@ export function activeLock(file) {
31
44
  * Pose le verrou (lien vers un fichier complet : atomique). Un verrou de pid mort est retiré — `stale` le
32
45
  * dit pour avertir. `held` : le porteur vivant quand la pose échoue.
33
46
  */
34
- export function takeLock(file, lock) {
47
+ export function takeLock(file, given) {
48
+ const lock = { ...given, start: given.start ?? processStart(given.pid) ?? undefined };
35
49
  let stale;
36
50
  for (let attempt = 0; attempt < 3; attempt++) {
37
51
  const tmp = join(dirname(file), `.orchestrate.lock.${lock.pid}.${Date.now()}.${attempt}.tmp`);
@@ -60,8 +74,8 @@ export function takeLock(file, lock) {
60
74
  continue;
61
75
  }
62
76
  const now = read(aside);
63
- if (now && now.pid === seen.pid && now.wave === seen.wave && now.started === seen.started) {
64
- stale = { pid: seen.pid, wave: seen.wave, started: seen.started };
77
+ if (now && now.pid === seen.pid && now.wave === seen.wave && now.started === seen.started && now.start === seen.start) {
78
+ stale = { pid: seen.pid, wave: seen.wave, started: seen.started, start: seen.start };
65
79
  rmSync(aside, { force: true });
66
80
  }
67
81
  else {
@@ -1,10 +1,11 @@
1
1
  import { runLot } from './cycle.js';
2
2
  import { lotKey } from './state.js';
3
- /** Deux sessions au plus en même temps (imposé ici, pas de --parallel) : deux dépôts à la fois. */
3
+ /** Valeur de repli de `runPool` quand l'appelant ne donne pas de plafond ; le réglage réel est DEFAULT_MAX_SESSIONS, `--max-sessions` ou CADENCE_MAX_SESSIONS (command.ts). */
4
4
  export const SLOTS = 2;
5
5
  /**
6
- * Ordonnanceur : une file par dépôt, les lots d'un dépôt l'un après l'autre dans l'ordre donné, deux dépôts
7
- * au plus en parallèle. Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
6
+ * Ordonnanceur : une file par dépôt, les lots d'un dépôt l'un après l'autre dans l'ordre donné, `slots` dépôts
7
+ * au plus en parallèle (le plafond global des sessions de toutes
8
+ * les vagues est tenu par command.ts). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
8
9
  */
9
10
  export async function runPool(ctxs, slots = SLOTS, known = []) {
10
11
  const queues = new Map();
@@ -0,0 +1,119 @@
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { processStart } from '../proc.js';
5
+ import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
6
+ /**
7
+ * État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
8
+ * des vagues vivantes (`waves/<pid>.json`) et les créneaux de sessions simultanées (`slots/slot-<n>.lock`).
9
+ * Les verrous de DÉPÔT restent dans le dépôt (`.git/cadence/orchestrate.lock`).
10
+ */
11
+ export function cadenceHome(env = process.env) {
12
+ return env.CADENCE_HOME || join(env.HOME || homedir(), '.cadence', 'orchestrate');
13
+ }
14
+ const wavesDir = (home) => join(home, 'waves');
15
+ const slotsDir = (home) => join(home, 'slots');
16
+ function readWave(file) {
17
+ try {
18
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
19
+ const w = { pid: Number(raw.pid), wave: String(raw.wave), started: String(raw.started), cwd: String(raw.cwd), repos: Array.isArray(raw.repos) ? raw.repos.map(String) : [] };
20
+ if (raw.cap !== undefined)
21
+ w.cap = Number(raw.cap);
22
+ if (raw.start !== undefined)
23
+ w.start = String(raw.start);
24
+ return w;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** Les vagues dont le processus vit encore. */
31
+ export function liveWaves(home) {
32
+ let names;
33
+ try {
34
+ names = readdirSync(wavesDir(home)).filter((n) => n.endsWith('.json'));
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ const out = [];
40
+ for (const n of names) {
41
+ const w = readWave(join(wavesDir(home), n));
42
+ if (w && holderAlive(w.pid, w.start))
43
+ out.push(w);
44
+ }
45
+ return out.sort((a, b) => a.started.localeCompare(b.started));
46
+ }
47
+ /** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
48
+ export function registerWave(home, given) {
49
+ const w = { ...given, start: given.start ?? processStart(given.pid) ?? undefined };
50
+ mkdirSync(wavesDir(home), { recursive: true });
51
+ for (const n of readdirSync(wavesDir(home))) {
52
+ if (!n.endsWith('.json'))
53
+ continue;
54
+ const old = readWave(join(wavesDir(home), n));
55
+ if (!old || !holderAlive(old.pid, old.start))
56
+ rmSync(join(wavesDir(home), n), { force: true });
57
+ }
58
+ const file = join(wavesDir(home), `${w.pid}.json`);
59
+ writeFileSync(`${file}.tmp`, JSON.stringify(w));
60
+ renameSync(`${file}.tmp`, file);
61
+ }
62
+ export function unregisterWave(home, pid) {
63
+ rmSync(join(wavesDir(home), `${pid}.json`), { force: true });
64
+ }
65
+ /** Les sessions en cours en ce moment, toutes vagues confondues. */
66
+ export function liveSlots(home) {
67
+ let names;
68
+ try {
69
+ names = readdirSync(slotsDir(home)).filter((n) => /^slot-\d+\.lock$/.test(n));
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ return names.map((n) => activeLock(join(slotsDir(home), n))).filter((l) => l !== null);
75
+ }
76
+ /** Index de créneau le plus haut essayé : le plafond de chaque vague est le sien, les fichiers sont communs. */
77
+ const MAX_SLOT_INDEX = 64;
78
+ /** Intervalle des rappels d'attente dans le journal. */
79
+ const WAIT_REPORT_MS = 60_000;
80
+ /**
81
+ * Attend que le nombre de sessions vivantes, TOUTES vagues confondues et quel que soit l'index de leur créneau,
82
+ * soit sous `cap` (le plafond de cette vague), puis prend le premier créneau libre (un pid mort est repris) et
83
+ * rend sa libération. Chaque vague applique SON plafond au total commun : avec des plafonds différents, la
84
+ * vague au plafond le plus haut peut porter le total au-dessus du plafond de l'autre, qui attend alors.
85
+ * Deux vagues qui prennent en même temps se départagent au recomptage : si le total dépasse `cap` une fois
86
+ * le créneau pris, il est rendu et l'attente reprend.
87
+ *
88
+ * `onWait(holders, waitedMs)` : appelé quand on commence à attendre (0 ms), puis toutes les minutes.
89
+ * `onGot(waitedMs)` : appelé à l'obtention, seulement si on a attendu.
90
+ */
91
+ export async function acquireSlot(home, cap, opts) {
92
+ mkdirSync(slotsDir(home), { recursive: true });
93
+ const t0 = Date.now();
94
+ let reportedAt = null;
95
+ const pollMs = opts.pollMs ?? 2000;
96
+ const live = opts.slots ?? liveSlots; // injectable : les tests simulent une vague qui prend un créneau entre les deux comptées
97
+ for (;;) {
98
+ if (live(home).length < cap) {
99
+ for (let k = 0; k < MAX_SLOT_INDEX; k++) {
100
+ const file = join(slotsDir(home), `slot-${k}.lock`);
101
+ const got = takeLock(file, { pid: process.pid, wave: opts.wave, started: new Date().toISOString() });
102
+ if (!got.ok)
103
+ continue;
104
+ if (live(home).length <= cap) {
105
+ if (reportedAt !== null)
106
+ opts.onGot?.(Date.now() - t0);
107
+ return () => releaseLock(file, process.pid);
108
+ }
109
+ releaseLock(file, process.pid); // pris à plusieurs en même temps : on rend, on retente
110
+ break;
111
+ }
112
+ }
113
+ if (reportedAt === null || Date.now() - reportedAt >= (opts.reportMs ?? WAIT_REPORT_MS)) {
114
+ reportedAt = Date.now();
115
+ opts.onWait?.(live(home), reportedAt - t0);
116
+ }
117
+ await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
118
+ }
119
+ }
@@ -22,6 +22,28 @@ export class RunStore {
22
22
  this.dir = join(launchDir, '.cadence', 'runs', id);
23
23
  mkdirSync(this.dir, { recursive: true });
24
24
  }
25
+ /**
26
+ * Réserve l'identifiant de vague : `mkdir` sans `recursive` échoue (EEXIST) si une autre vague l'a pris, même à
27
+ * la même seconde. Sans identifiant demandé, on passe au suffixe suivant (`-2`, `-3`…) ; avec un identifiant
28
+ * demandé, null (refus : jamais deux vagues dans le même dossier d'état).
29
+ */
30
+ static reserve(launchDir, base, opts = {}) {
31
+ mkdirSync(RunStore.runsDir(launchDir), { recursive: true });
32
+ for (let i = 1;; i++) {
33
+ const id = i === 1 ? base : `${base}-${i}`;
34
+ try {
35
+ mkdirSync(join(RunStore.runsDir(launchDir), id));
36
+ }
37
+ catch (e) {
38
+ if (e.code !== 'EEXIST')
39
+ throw e;
40
+ if (opts.exact)
41
+ return null;
42
+ continue;
43
+ }
44
+ return new RunStore(launchDir, id);
45
+ }
46
+ }
25
47
  static runsDir(launchDir) {
26
48
  return join(launchDir, '.cadence', 'runs');
27
49
  }
package/dist/proc.js CHANGED
@@ -90,6 +90,25 @@ export function readProcs() {
90
90
  }
91
91
  return procs;
92
92
  }
93
+ /** Heure de démarrage d'un processus (son identité : un pid se réutilise) ; null s'il est mort ou illisible. */
94
+ export function processStart(pid) {
95
+ if (!Number.isInteger(pid) || pid <= 0)
96
+ return null;
97
+ try {
98
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
99
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
100
+ }
101
+ catch {
102
+ // pas de /proc (macOS) ou processus sorti : ps
103
+ }
104
+ try {
105
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
106
+ return out || null;
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
93
112
  /** Âge au-delà duquel un relevé de secours (le relevé suivant a échoué) n'est plus cru : un pid y a pu être repris. */
94
113
  export const MAX_SNAPSHOT_AGE_MS = 1_000;
95
114
  /** Intervalle du relevé des descendants d'une commande de deliver pendant qu'elle tourne. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
5
5
  "license": "MIT",
6
6
  "author": "Sylvain Ladoire",