@sylad/cadence 0.10.0 → 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 +46 -13
- package/bin/cadence.js +3 -2
- package/dist/audit.js +2 -2
- package/dist/cli.js +1 -1
- package/dist/config.js +31 -3
- package/dist/orchestrate/command.js +115 -29
- package/dist/orchestrate/cycle.js +1 -1
- 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/plan.js +8 -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.
|
|
@@ -36,7 +47,8 @@ reports a page left empty or in error.
|
|
|
36
47
|
touches is a plan file**: the plan itself, its Gantt page (only at its default place, `gantt.html` next to the plan — written elsewhere with `raf gantt -o`, declare it under `plan.files`), the `plan:` key of the config file in use (`cadence.yaml`, or
|
|
37
48
|
the `--config` file; a change to `deliver:`/`session:` is work), or a file the
|
|
38
49
|
project lists under `plan.files` in `cadence.yaml` (a page it generates from
|
|
39
|
-
the plan, a journal)
|
|
50
|
+
the plan, a journal), or the QA expectations file (`docs/qa/expectations.md`,
|
|
51
|
+
or the file named by `qa.expectations`; the lead commits the `qa-reviewer`'s draft of it). Such a commit is never a "commit without a lot", and it
|
|
40
52
|
does not count as work on the lots it cites: it is absent from `raf commits`,
|
|
41
53
|
does not start a `todo` lot, does not make a code review stale, and
|
|
42
54
|
`cadence deliver` does not announce the lots it cites as delivered. The files
|
|
@@ -274,8 +286,8 @@ The counterpart of the UX review, off by default. With the rule on, `raf done`
|
|
|
274
286
|
refuses a lot that has at least one commit citing it and no recorded verdict
|
|
275
287
|
(`--force` to override), and `raf check` reports such lots finished after the
|
|
276
288
|
`reviewSince` day. A lot with no commit has nothing to review; neither does a
|
|
277
|
-
lot whose only commits touch plan files alone (the plan,
|
|
278
|
-
`plan.files
|
|
289
|
+
lot whose only commits touch plan files alone (the plan, a file listed under
|
|
290
|
+
`plan.files`, or the QA expectations file), predate the plan's `since` or match an `ignore:` pattern — `raf commits <id>` prints exactly the counted set.
|
|
279
291
|
|
|
280
292
|
The verdict is tied to what was reviewed: `raf review` stores it on the lot with
|
|
281
293
|
the sha of the lot's latest counted commit (`review: { date, verdict, commit }`,
|
|
@@ -346,7 +358,7 @@ qa:
|
|
|
346
358
|
expectations: docs/quality/pages.md
|
|
347
359
|
```
|
|
348
360
|
|
|
349
|
-
|
|
361
|
+
The agent reads that key, and so does `raf check`: a commit touching only that file is plan upkeep (no lot to cite, see above). Without an expectations file the agent walks
|
|
350
362
|
the routes it discovers and still runs its universal checks: an error shown, a failed API call
|
|
351
363
|
whose content is missing on screen, a broken or missing content image are defects with or without a
|
|
352
364
|
file; an empty 2xx body, like whatever else would need an expectation to judge, is suspect at most
|
|
@@ -631,13 +643,14 @@ exception is a **formatting retry** (below).
|
|
|
631
643
|
cadence orchestrate finance-tracker:L41 ol-companion:L22 cadence:L18@haiku
|
|
632
644
|
cadence orchestrate L18 # from inside a project
|
|
633
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
|
|
634
647
|
cadence orchestrate … --dry-run # preconditions + the plan of the wave; nothing is started
|
|
635
|
-
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)
|
|
636
649
|
cadence orchestrate --resume [<wave>] [--budget 1M] [--answer ol-companion:L22 "reply"]
|
|
637
650
|
```
|
|
638
651
|
|
|
639
|
-
You choose the lots; the order is the order given (one queue per repository,
|
|
640
|
-
|
|
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
|
|
641
654
|
lot (default Sonnet; reviews are always Opus; Haiku only when you write it, for a mechanical lot). Run it
|
|
642
655
|
in the background and read `--status`: it prints one line per transition and the final table.
|
|
643
656
|
|
|
@@ -677,9 +690,24 @@ review with minors: it concludes on that review instead of staying suspended.
|
|
|
677
690
|
after the wave (`git log`, tests, `raf check`), `raf done`, **`raf ux`** (the orchestrator reports the UX
|
|
678
691
|
verdict and screenshots, it does not record it), the push and the deliveries, one project at a time.
|
|
679
692
|
|
|
680
|
-
**
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
683
711
|
temporary `pre-push` hook, installed for the duration of the wave and removed at its end, refuses any push
|
|
684
712
|
from a session (`CADENCE_ORCHESTRATED` is in their environment; a repository that already has another
|
|
685
713
|
`pre-push` hook is refused before anything starts — `pushurl` is never touched); after every session the
|
|
@@ -725,7 +753,7 @@ orchestrate:
|
|
|
725
753
|
|
|
726
754
|
Without `start`, a read-only plan's `todo` lot is refused (start it with the project's tool); without
|
|
727
755
|
`verdict`, the review verdict stays in the wave's state and you report it. Only the plan's files
|
|
728
|
-
(`plan.path`, `plan.files
|
|
756
|
+
(`plan.path`, `plan.files`, the QA expectations file) are committed from those commands; anything else dirty stops the lot.
|
|
729
757
|
|
|
730
758
|
## Claude Code skills
|
|
731
759
|
|
|
@@ -801,13 +829,18 @@ A version exists in three places and is published in two; a release does all of
|
|
|
801
829
|
|
|
802
830
|
1. Bump `version` in `package.json` (then `npm install` to refresh `package-lock.json`),
|
|
803
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.
|
|
804
834
|
2. `git tag v<version> && git push origin main v<version>` — the tag starts `.github/workflows/publish.yml`,
|
|
805
835
|
which publishes to npm through Trusted Publishing (OIDC, no token stored anywhere): it checks the tag
|
|
806
|
-
matches `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json
|
|
807
|
-
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
|
|
808
838
|
once on npmjs.com (package settings → Trusted Publisher → GitHub Actions, `Sylad/cadence`, `publish.yml`).
|
|
809
839
|
3. Watch the run: `gh run watch` (or `gh run list --workflow publish.yml`).
|
|
810
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`.
|
|
811
844
|
|
|
812
845
|
The Claude Code plugin is read from the repository, so pushing `main` is what updates it; npm is what
|
|
813
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;
|
package/dist/audit.js
CHANGED
|
@@ -12,7 +12,7 @@ function configRel(plan, root) {
|
|
|
12
12
|
}
|
|
13
13
|
/** Le plan, sa page Gantt, la configuration lue (cadence.yaml) et les fichiers tenus avec lui (plan.files). */
|
|
14
14
|
function ownFiles(plan, root) {
|
|
15
|
-
return new Set([relative(root, plan.path), relative(root, join(dirname(plan.path), 'gantt.html')), configRel(plan, root), ...plan.
|
|
15
|
+
return new Set([relative(root, plan.path), relative(root, join(dirname(plan.path), 'gantt.html')), configRel(plan, root), ...plan.maintained]);
|
|
16
16
|
}
|
|
17
17
|
function withoutPlanKey(text) {
|
|
18
18
|
if (text === null)
|
|
@@ -39,7 +39,7 @@ export function planCommits(plan, root, opts = {}) {
|
|
|
39
39
|
}
|
|
40
40
|
/**
|
|
41
41
|
* Commit d'entretien du plan : TOUS ses fichiers sont des fichiers du plan (le plan, sa page Gantt,
|
|
42
|
-
* plan.files — son plan publié par exemple
|
|
42
|
+
* plan.files — son plan publié par exemple —, les attendus QA et la configuration lue, mais celle-ci seulement quand la
|
|
43
43
|
* clé `plan:` est la seule à changer : deliver/session/… sont du travail). Les fichiers décident,
|
|
44
44
|
* jamais le sujet : « chore(plan): … » qui touche un fichier source est un commit comme un autre.
|
|
45
45
|
*/
|
package/dist/cli.js
CHANGED
|
@@ -47,7 +47,7 @@ Quand la portée du sujet cite des lots — « feat(L3): … », « chore(L31,L3
|
|
|
47
47
|
mention en passage (« page équipe (L27) »), une plage (« L28–L31 », « L45 à L48 ») ou le corps n'y comptent pas.
|
|
48
48
|
Sans portée citant un lot, tout le message est lu : « fix: L3 corrigé », « L3/t1 ».
|
|
49
49
|
Un commit qui ne touche que le plan, la clé plan: de cadence.yaml (deliver/session sont du travail) (et les fichiers déclarés sous plan.files, un plan
|
|
50
|
-
publié par exemple) n'a pas à en citer, et ne compte pas pour les lots qu'il cite ; le sujet n'y change rien.
|
|
50
|
+
publié par exemple, ainsi que le fichier d'attendus QA : docs/qa/expectations.md ou qa.expectations) n'a pas à en citer, et ne compte pas pour les lots qu'il cite ; le sujet n'y change rien.
|
|
51
51
|
Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
|
|
52
52
|
Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
|
|
53
53
|
Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, posix, relative } from 'node:path';
|
|
2
3
|
import { parse } from 'yaml';
|
|
3
4
|
import { isDay } from './dates.js';
|
|
4
5
|
import { FIELDS, RafError, STATUSES } from './plan.js';
|
|
@@ -6,6 +7,29 @@ const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', '
|
|
|
6
7
|
const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
|
|
7
8
|
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
8
9
|
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
10
|
+
/**
|
|
11
|
+
* Clé `qa:` : le fichier d'attendus QA, ramené à la forme que git rapporte (relatif à la racine du dépôt,
|
|
12
|
+
* séparateurs posix, sans `./`). Une valeur vide vaut absente ; un type faux ou un chemin hors du dépôt est refusé.
|
|
13
|
+
*/
|
|
14
|
+
function readQa(qa, file) {
|
|
15
|
+
if (qa == null)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (!isObject(qa))
|
|
18
|
+
throw new RafError(`${file} : qa doit être un objet`);
|
|
19
|
+
const v = qa.expectations;
|
|
20
|
+
if (v == null)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (typeof v !== 'string')
|
|
23
|
+
throw new RafError(`${file} : qa.expectations doit être un chemin`);
|
|
24
|
+
const raw = v.trim().replace(/\\/g, '/');
|
|
25
|
+
if (raw === '')
|
|
26
|
+
return undefined;
|
|
27
|
+
const root = dirname(file);
|
|
28
|
+
const rel = posix.normalize(isAbsolute(raw) ? relative(root, raw).replace(/\\/g, '/') : raw);
|
|
29
|
+
if (rel === '.' || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
|
|
30
|
+
throw new RafError(`${file} : qa.expectations « ${v.trim()} » est hors du dépôt`);
|
|
31
|
+
return rel;
|
|
32
|
+
}
|
|
9
33
|
/**
|
|
10
34
|
* Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
11
35
|
* Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
|
|
@@ -20,18 +44,22 @@ export function readPlanConfig(file) {
|
|
|
20
44
|
catch (e) {
|
|
21
45
|
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
22
46
|
}
|
|
23
|
-
const
|
|
47
|
+
const doc = raw;
|
|
48
|
+
const qa = readQa(doc?.qa, file);
|
|
49
|
+
const p = doc?.plan;
|
|
24
50
|
if (p == null)
|
|
25
|
-
return null;
|
|
51
|
+
return qa ? { settings: { qaExpectations: qa } } : null;
|
|
26
52
|
const bad = (what) => new RafError(`${file} : plan.${what}`);
|
|
27
53
|
if (typeof p === 'string' && p.trim() !== '')
|
|
28
|
-
return { path: p, settings: {} };
|
|
54
|
+
return { path: p, settings: { ...(qa ? { qaExpectations: qa } : {}) } };
|
|
29
55
|
if (!isObject(p))
|
|
30
56
|
throw new RafError(`${file} : plan doit être un chemin ou un objet`);
|
|
31
57
|
for (const k of Object.keys(p))
|
|
32
58
|
if (!KEYS.includes(k))
|
|
33
59
|
throw bad(`${k} inconnu (attendu : ${KEYS.join(', ')})`);
|
|
34
60
|
const settings = {};
|
|
61
|
+
if (qa)
|
|
62
|
+
settings.qaExpectations = qa;
|
|
35
63
|
if (p.project != null)
|
|
36
64
|
settings.project = String(p.project);
|
|
37
65
|
if (p.since != null) {
|
|
@@ -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) {
|
|
@@ -72,7 +72,7 @@ function trackedPaths(s) {
|
|
|
72
72
|
async function commitPlan(c, message) {
|
|
73
73
|
const repo = c.lot.repo;
|
|
74
74
|
const plan = c.loadPlan();
|
|
75
|
-
const own = new Set([relative(repo, plan.path), ...plan.
|
|
75
|
+
const own = new Set([relative(repo, plan.path), ...plan.maintained]);
|
|
76
76
|
const dirty = trackedPaths(await snapshot(repo, { remote: false }));
|
|
77
77
|
if (dirty.length === 0)
|
|
78
78
|
return null;
|
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/plan.js
CHANGED
|
@@ -130,6 +130,14 @@ export class Plan {
|
|
|
130
130
|
get files() {
|
|
131
131
|
return this.settings.files ?? [];
|
|
132
132
|
}
|
|
133
|
+
/** Fichier d'attendus QA du `qa-reviewer`, relatif à la racine du dépôt. */
|
|
134
|
+
get qaExpectations() {
|
|
135
|
+
return this.settings.qaExpectations ?? 'docs/qa/expectations.md';
|
|
136
|
+
}
|
|
137
|
+
/** Tout ce qui est tenu avec le plan sans être du travail : plan.files et le fichier d'attendus QA. Source unique de l'audit et de l'orchestrateur. */
|
|
138
|
+
get maintained() {
|
|
139
|
+
return [...this.files, this.qaExpectations];
|
|
140
|
+
}
|
|
133
141
|
/**
|
|
134
142
|
* Références citées par un texte. Format de raf : préfixe et numéro (`L3`, `L3/t1`). Autre format :
|
|
135
143
|
* les identifiants du plan eux-mêmes, quelle que soit leur forme (`E-A2`, `NC2.4`, `B33/t1-fusion`).
|
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",
|