@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +40 -8
- package/bin/cadence.js +3 -2
- package/dist/orchestrate/command.js +115 -29
- package/dist/orchestrate/lock.js +19 -5
- package/dist/orchestrate/pool.js +4 -3
- package/dist/orchestrate/registry.js +119 -0
- package/dist/orchestrate/state.js +22 -0
- package/dist/proc.js +19 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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
|
|
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,
|
|
641
|
-
|
|
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
|
-
**
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
400
|
-
if (alive
|
|
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) {
|
package/dist/orchestrate/lock.js
CHANGED
|
@@ -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 :
|
|
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,
|
|
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 {
|
package/dist/orchestrate/pool.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { runLot } from './cycle.js';
|
|
2
2
|
import { lotKey } from './state.js';
|
|
3
|
-
/**
|
|
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é,
|
|
7
|
-
* au plus en parallèle
|
|
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.
|
|
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",
|