@sylad/cadence 1.8.0 → 1.10.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +75 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +7 -4
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
|
@@ -260,8 +260,8 @@ export async function snapshot(repo, opts = {}) {
|
|
|
260
260
|
opts.remote === false ? Promise.resolve(null) : out(repo, ['ls-remote', '--heads']),
|
|
261
261
|
]);
|
|
262
262
|
const lines = (status ?? '').split('\n').filter(Boolean);
|
|
263
|
-
// .cadence/ (état de l'outil) et .playwright-mcp/ (sorties du MCP Playwright, écrites dans le dossier courant, qu'un dépôt déjà initialisé n'ignore pas forcément) ne comptent jamais comme un dépôt sale.
|
|
264
|
-
const keep = (l) => !/^.. "?(\.cadence|\.playwright-mcp)
|
|
263
|
+
// .cadence/runs/ et .cadence/corrections.json (état et cache de l'outil ; le plan et les notes de .cadence/ sont versionnés) et .playwright-mcp/ (sorties du MCP Playwright, écrites dans le dossier courant, qu'un dépôt déjà initialisé n'ignore pas forcément) ne comptent jamais comme un dépôt sale.
|
|
264
|
+
const keep = (l) => !/^.. "?(\.cadence\/runs\/|\.cadence\/corrections\.json|\.playwright-mcp\/)/.test(l);
|
|
265
265
|
return {
|
|
266
266
|
head: head?.trim() || null,
|
|
267
267
|
tracked: lines.filter((l) => !l.startsWith('??') && keep(l)),
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { gitCommonDir } from '../git.js';
|
|
5
|
+
import { sharedStateDir } from '../state.js';
|
|
6
|
+
import { activeLock, releaseLock, takeLock } from './lock.js';
|
|
7
|
+
import { mergeUnreleased } from './changelog-merge.js';
|
|
8
|
+
import { mutexFor } from './mutex.js';
|
|
9
|
+
import { git, gitFailure, tryGit } from './worktree.js';
|
|
10
|
+
/** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
|
|
11
|
+
export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
|
|
12
|
+
/** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
|
|
13
|
+
export const INTEGRATE_WAIT_MS = 10 * 60_000;
|
|
14
|
+
export const INTEGRATE_POLL_MS = 1_000;
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
/**
|
|
17
|
+
* Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
|
|
18
|
+
* vague partagent un pid, `takeLock` rendrait chacun déjà porteur), puis le verrou fichier, qui ne départage que les autres processus. L'attente est bornée
|
|
19
|
+
* par `waitMs` (R25) ; au-delà, le porteur est rendu et rien n'est pris. Une attente ne retient aucun créneau de session : ce verrou n'a rien à voir avec eux.
|
|
20
|
+
*/
|
|
21
|
+
export async function takeRepoWrite(repo, who, o = {}) {
|
|
22
|
+
const waitMs = o.waitMs ?? INTEGRATE_WAIT_MS;
|
|
23
|
+
const pollMs = o.pollMs ?? INTEGRATE_POLL_MS;
|
|
24
|
+
const file = join(sharedStateDir(repo), INTEGRATION_LOCK);
|
|
25
|
+
const deadline = Date.now() + waitMs;
|
|
26
|
+
const turn = await mutexFor(`integrate:${gitCommonDir(repo)}`).acquire(waitMs);
|
|
27
|
+
if (!turn) {
|
|
28
|
+
// Un autre lot de ce processus tient la main : c'est lui le porteur (il a posé le verrou fichier).
|
|
29
|
+
const holder = activeLock(file);
|
|
30
|
+
return { ok: false, holder: { wave: holder?.wave ?? who.wave, pid: holder?.pid ?? process.pid } };
|
|
31
|
+
}
|
|
32
|
+
for (;;) {
|
|
33
|
+
const lock = { pid: process.pid, wave: who.wave, started: new Date().toISOString() };
|
|
34
|
+
const got = takeLock(file, lock);
|
|
35
|
+
if (got.ok) {
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
release: () => {
|
|
39
|
+
releaseLock(file, process.pid);
|
|
40
|
+
turn();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (Date.now() >= deadline) {
|
|
45
|
+
turn();
|
|
46
|
+
return { ok: false, holder: { wave: got.held.wave, pid: got.held.pid } };
|
|
47
|
+
}
|
|
48
|
+
await sleep(pollMs);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Branche courante du dépôt (`main`), null en HEAD détaché (R24). */
|
|
52
|
+
export const repoBranch = (repo) => tryGit(repo, 'symbolic-ref', '--short', '-q', 'HEAD') || null;
|
|
53
|
+
/** Point de départ de `rev` par rapport à la branche `base` : le dernier ancêtre commun. */
|
|
54
|
+
export const mergeBase = (cwd, rev, base) => git(cwd, 'merge-base', rev, `refs/heads/${base}`);
|
|
55
|
+
const CHANGELOG = 'CHANGELOG.md';
|
|
56
|
+
const REBASE_ENV = { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' };
|
|
57
|
+
const run = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: REBASE_ENV });
|
|
58
|
+
/**
|
|
59
|
+
* Résout le conflit en cours quand c'est le CHANGELOG seul et de l'ajout pur sous `[Unreleased]` (L314) : écrit l'union, l'ajoute à l'index. Faux, sans rien
|
|
60
|
+
* toucher, pour tout autre conflit (un autre fichier avec lui, une entrée de base modifiée, une étape de base absente). `start` : le CHANGELOG au point de
|
|
61
|
+
* départ de la branche (L323), pour qu'un commit puisse réécrire l'entrée qu'un commit précédent du lot a ajoutée.
|
|
62
|
+
*/
|
|
63
|
+
function resolveChangelog(work, start) {
|
|
64
|
+
const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
|
|
65
|
+
if (files.length !== 1 || files[0] !== CHANGELOG)
|
|
66
|
+
return false;
|
|
67
|
+
try {
|
|
68
|
+
// Pendant un rebase, l'étape 2 est la branche du dépôt (« ours »), l'étape 3 le commit du lot rejoué (« theirs »).
|
|
69
|
+
const merged = mergeUnreleased(run(work, 'show', `:1:${CHANGELOG}`), run(work, 'show', `:2:${CHANGELOG}`), run(work, 'show', `:3:${CHANGELOG}`), start);
|
|
70
|
+
if (merged === null)
|
|
71
|
+
return false;
|
|
72
|
+
writeFileSync(join(work, CHANGELOG), merged);
|
|
73
|
+
run(work, 'add', '--', CHANGELOG);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
|
|
82
|
+
* change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte). Tout autre conflit — ou
|
|
83
|
+
* tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la branche du dépôt n'a pas été
|
|
84
|
+
* touchée, personne ne résout le conflit à l'aveugle.
|
|
85
|
+
*/
|
|
86
|
+
export function rebaseOnto(work, base) {
|
|
87
|
+
const before = git(work, 'rev-parse', 'HEAD');
|
|
88
|
+
if (tryGit(work, 'merge-base', '--is-ancestor', `refs/heads/${base}`, 'HEAD') !== null)
|
|
89
|
+
return { kind: 'forwarded' };
|
|
90
|
+
let changelog = 0;
|
|
91
|
+
const start = tryGit(work, 'show', `${mergeBase(work, 'HEAD', base)}:${CHANGELOG}`) ?? undefined;
|
|
92
|
+
try {
|
|
93
|
+
run(work, 'rebase', `refs/heads/${base}`);
|
|
94
|
+
}
|
|
95
|
+
catch (first) {
|
|
96
|
+
let failure = first;
|
|
97
|
+
// Chaque tour règle le commit sur lequel le rebase s'est arrêté ; un lot ne porte pas des centaines de commits, la borne évite seulement une boucle sans fin.
|
|
98
|
+
for (let turn = 0;; turn++) {
|
|
99
|
+
if (turn >= 200 || !resolveChangelog(work, start)) {
|
|
100
|
+
const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
|
|
101
|
+
tryGit(work, 'rebase', '--abort');
|
|
102
|
+
return { kind: 'conflict', files, detail: gitFailure(failure) };
|
|
103
|
+
}
|
|
104
|
+
changelog++;
|
|
105
|
+
try {
|
|
106
|
+
run(work, 'rebase', '--continue');
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
failure = e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
|
|
118
|
+
* une ligne du lot change. Vide quand il n'y a pas de diff.
|
|
119
|
+
*/
|
|
120
|
+
export function patchId(cwd, from, to) {
|
|
121
|
+
const diff = execFileSync('git', ['diff', '--no-color', '--no-ext-diff', from, to], { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 512 });
|
|
122
|
+
if (!diff)
|
|
123
|
+
return '';
|
|
124
|
+
return execFileSync('git', ['patch-id', '--stable'], { cwd, input: diff, encoding: 'utf8' }).trim().split(/\s+/)[0] ?? '';
|
|
125
|
+
}
|
|
126
|
+
/** Avance la branche courante du dépôt du lead jusqu'à la branche du lot, en fast-forward seulement (R8, R24) : l'arbre du lead ne reçoit que les fichiers du lot. */
|
|
127
|
+
export function advance(repo, branch) {
|
|
128
|
+
git(repo, 'merge', '--ff-only', '--quiet', `refs/heads/${branch}`);
|
|
129
|
+
}
|
|
@@ -5,6 +5,7 @@ import { delimiter, join, resolve } from 'node:path';
|
|
|
5
5
|
import { parse } from 'yaml';
|
|
6
6
|
import { RafError } from '../plan.js';
|
|
7
7
|
import { sandboxEnv } from './sandbox.js';
|
|
8
|
+
import { SlotAbandoned } from './registry.js';
|
|
8
9
|
import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
|
|
9
10
|
import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
|
|
10
11
|
/**
|
|
@@ -34,8 +35,8 @@ export function writeMcpConfig(lotDir, kind, visible, small = false, screen = tr
|
|
|
34
35
|
writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
|
|
35
36
|
return file;
|
|
36
37
|
}
|
|
37
|
-
/** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison, de verdict ni de rescellé d'un test d'acceptation (le chemin du lead, L200). */
|
|
38
|
-
export const DISALLOWED = ['Agent', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)', 'Bash(cadence spec reseal:*)', 'Bash(raf spec reseal:*)'];
|
|
38
|
+
/** Outils interdits à toute session : pas de sous-agent, pas de push, de livraison, de verdict ni de rescellé d'un test d'acceptation (le chemin du lead, L200) ; pas de `Monitor` (tâche de fond que `-p` attend, L285). */
|
|
39
|
+
export const DISALLOWED = ['Agent', 'Monitor', 'Bash(git push:*)', 'Bash(cadence deliver:*)', 'Bash(raf done:*)', 'Bash(raf review:*)', 'Bash(raf ux:*)', 'Bash(cadence spec reseal:*)', 'Bash(raf spec reseal:*)'];
|
|
39
40
|
/** Outils MCP du serveur Playwright, par le préfixe du serveur (sonde réelle du 07-10 : `mcp__playwright` expose tous les `browser_*`). */
|
|
40
41
|
export const PLAYWRIGHT_TOOLS = 'mcp__playwright';
|
|
41
42
|
/** Agents passés à `--agents` : l'agent d'une étape qui charge Playwright, s'il a une liste d'outils, y gagne les outils du serveur (sinon il ne peut pas l'appeler). */
|
|
@@ -48,6 +49,8 @@ function agentsFor(spec, agents) {
|
|
|
48
49
|
function effortArgs(spec) {
|
|
49
50
|
return spec.effort && spec.effort !== 'default' ? ['--effort', spec.effort] : [];
|
|
50
51
|
}
|
|
52
|
+
/** Retire `run_in_background` de Bash et des sous-agents : `claude -p` attend toute tâche de fond avant de sortir (jusqu'à 10 min d'inactivité), la session finie ne rendait pas la main (L285). */
|
|
53
|
+
export const NO_BACKGROUND_ENV = { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' };
|
|
51
54
|
export function buildArgs(spec, agents) {
|
|
52
55
|
const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
|
|
53
56
|
if (spec.agent) {
|
|
@@ -103,6 +106,10 @@ export function readAgents(dir) {
|
|
|
103
106
|
}
|
|
104
107
|
return out;
|
|
105
108
|
}
|
|
109
|
+
/** Étapes relancées à neuf après une coupure : celles en lecture seule, et `ref`, qui n'écrit que sa lecture (`lectures/a.json` ou `b.json`, le troisième regard n'écrit rien), que le programme valide ensuite (une étape d'écriture ordinaire a pu laisser un travail à moitié fait). */
|
|
110
|
+
const READ_ONLY = new Set(['review', 'review-small', 'ux', 'precheck', 'maquette', 'fiche', 'ref']);
|
|
111
|
+
/** Session tuée par un signal (code 128 + n : 143 SIGTERM, 137 SIGKILL…) sans rien écrire : la coupure, ni un quota (un message) ni un échec (une sortie). */
|
|
112
|
+
const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() === '';
|
|
106
113
|
/** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
|
|
107
114
|
export const FORMAT_RETRY_PROMPT = 'Return your report now in the required format (the JSON structure of the schema), exactly as you concluded it. Do not do any further work, do not add anything else.';
|
|
108
115
|
/** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
|
|
@@ -126,19 +133,44 @@ export function buildRetryArgs(spec, sessionId, agents) {
|
|
|
126
133
|
* exception près : une session réussie qui finit en texte sans `structured_output` est relancée UNE fois, par
|
|
127
134
|
* `--resume` de la même session, pour qu'elle rende son rapport au format demandé. Les jetons de la relance
|
|
128
135
|
* s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
|
|
136
|
+
* Seconde exception (L256) : une étape en lecture coupée par un signal sans rien écrire (code 143, sortie vide) est
|
|
137
|
+
* relancée UNE fois à l'identique en session NEUVE (autre `--session-id`, jamais `--resume` : la coupée n'a rien à reprendre) ;
|
|
138
|
+
* coupée de nouveau, elle rend un échec marqué `cut`. `onRelaunch` reçoit l'identifiant de la session neuve juste avant son lancement. Le quota, le délai et un code ordinaire ne sont jamais relancés.
|
|
129
139
|
*/
|
|
130
140
|
export async function runSession(spec, deps) {
|
|
131
141
|
const sandbox = sandboxEnv(!!spec.prodAccess);
|
|
132
|
-
const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...sandbox.set, ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, onSpawn: deps.onSpawn, lot: spec.lot });
|
|
142
|
+
const launch = (args) => deps.claude(args, { local: spec.local, cwd: spec.cwd, unset: sandbox.unset, env: { CADENCE_ORCHESTRATED: spec.wave, ...NO_BACKGROUND_ENV, ...sandbox.set, ...(spec.tmpDir ? { TMPDIR: spec.tmpDir } : {}), ...(spec.nodeBin || spec.toolBin ? { PATH: [spec.toolBin, spec.nodeBin, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {}) }, timeoutMs: spec.timeoutMs, extend: spec.extend, onSpawn: deps.onSpawn, lot: spec.lot });
|
|
143
|
+
const epoch = killEpoch;
|
|
133
144
|
const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
|
|
134
145
|
const first = classify(out);
|
|
135
146
|
if (spec.local)
|
|
136
147
|
return first; // pas de relance de mise en forme : l'appelant se replie sur Sonnet
|
|
148
|
+
// Une session tuée par `killSessions` (arrêt de la vague) n'est pas une coupure à relancer : la neuve survivrait à l'orchestrateur.
|
|
149
|
+
if (first.kind === 'failed' && READ_ONLY.has(spec.kind) && isCut(out) && killEpoch === epoch) {
|
|
150
|
+
const relaunched = { sessionId: spec.sessionId, cause: `code ${out.code}, sortie vide`, stderr: out.stderr };
|
|
151
|
+
const fresh = randomUUID();
|
|
152
|
+
deps.onRelaunch?.(fresh); // l'appelant l'enregistre AVANT le lancement : un signal pendant la relance retrouve la session neuve
|
|
153
|
+
// Relance qui doit attendre un créneau et voit une demande d'arrêt (L332) : la session coupée ne se perd pas avec l'abandon.
|
|
154
|
+
const again = await launch(buildArgs({ ...spec, sessionId: fresh }, deps.agents)).catch((e) => {
|
|
155
|
+
if (e instanceof SlotAbandoned)
|
|
156
|
+
e.after = { relaunched };
|
|
157
|
+
throw e;
|
|
158
|
+
});
|
|
159
|
+
const next = classify(again);
|
|
160
|
+
if (next.kind === 'ok')
|
|
161
|
+
return { ...next, relaunched };
|
|
162
|
+
const tail = { ...next, sessionId: next.sessionId ?? fresh, relaunched };
|
|
163
|
+
return tail.kind === 'failed' && isCut(again) ? { ...tail, cut: true, cause: `coupée (${relaunched.cause}), relancée une fois en session neuve, coupée de nouveau` } : tail;
|
|
164
|
+
}
|
|
137
165
|
const missing = first.kind === 'failed' && !out.timedOut && out.code === 0 ? lacksStructuredOutput(out.stdout) : null;
|
|
138
166
|
if (!missing)
|
|
139
167
|
return first;
|
|
140
168
|
const spentFirst = first.kind === 'failed' ? first.tokens : undefined;
|
|
141
|
-
const second = classify(await launch(buildRetryArgs(spec, missing.sessionId, deps.agents)))
|
|
169
|
+
const second = classify(await launch(buildRetryArgs(spec, missing.sessionId, deps.agents)).catch((e) => {
|
|
170
|
+
if (e instanceof SlotAbandoned)
|
|
171
|
+
e.after = { formatting: { stdout: out.stdout, stderr: out.stderr, ...(spentFirst ? { tokens: spentFirst } : {}), sessionId: missing.sessionId } };
|
|
172
|
+
throw e;
|
|
173
|
+
}));
|
|
142
174
|
const total = (t) => (spentFirst && t ? sumTokens(spentFirst, t) : (t ?? spentFirst));
|
|
143
175
|
if (second.kind === 'ok')
|
|
144
176
|
return { kind: 'ok', result: { ...second.result, tokens: total(second.result.tokens), formattingRetry: true }, firstStdout: out.stdout };
|
|
@@ -175,6 +207,8 @@ function classify(out) {
|
|
|
175
207
|
const live = new Set();
|
|
176
208
|
const trees = new Set();
|
|
177
209
|
const marks = new Set();
|
|
210
|
+
/** Compte les arrêts de la vague (`killSessions`) : une session qui finit après un arrêt n'est pas relancée. */
|
|
211
|
+
let killEpoch = 0;
|
|
178
212
|
/** Suit un groupe de processus lancé par l'orchestrateur (commande du projet) : tué avec les sessions au signal. */
|
|
179
213
|
export function trackGroup(pid) {
|
|
180
214
|
live.add(pid);
|
|
@@ -182,6 +216,7 @@ export function trackGroup(pid) {
|
|
|
182
216
|
}
|
|
183
217
|
/** Tue les groupes de processus des sessions en cours (Ctrl-C, SIGTERM de l'orchestrateur). */
|
|
184
218
|
export function killSessions() {
|
|
219
|
+
killEpoch++;
|
|
185
220
|
for (const pid of live) {
|
|
186
221
|
try {
|
|
187
222
|
process.kill(-pid, 'SIGKILL');
|
|
@@ -227,7 +262,13 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
|
|
|
227
262
|
child.stdout.on('data', (d) => out.push(d));
|
|
228
263
|
child.stderr.on('data', (d) => err.push(d));
|
|
229
264
|
let timedOut = false;
|
|
230
|
-
|
|
265
|
+
let extended = 0;
|
|
266
|
+
const deadline = () => {
|
|
267
|
+
if (opts.extend && extended < opts.extend.max && opts.extend.progressed()) {
|
|
268
|
+
extended++;
|
|
269
|
+
timer = setTimeout(deadline, Math.max(1_000, opts.extend.ms));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
231
272
|
timedOut = true;
|
|
232
273
|
tree.kill();
|
|
233
274
|
killMarked(mark);
|
|
@@ -237,7 +278,8 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
|
|
|
237
278
|
catch {
|
|
238
279
|
// déjà mort
|
|
239
280
|
}
|
|
240
|
-
}
|
|
281
|
+
};
|
|
282
|
+
let timer = setTimeout(deadline, Math.max(1_000, opts.timeoutMs));
|
|
241
283
|
// Aucun descendant ne survit à la session, dans son groupe ou hors de lui. Dès que la racine sort (`exit`), pas
|
|
242
284
|
// à `close` : un descendant qui tient stdout/stderr ouverts retarderait `close` jusqu'au délai (« délai dépassé »
|
|
243
285
|
// pour une session pourtant sortie en code 0).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** File d'attente en mémoire : un seul porteur à la fois, dans l'ordre des demandes (les lots d'une vague partagent un processus, donc un pid : un verrou fichier ne les départagerait pas, L279). */
|
|
2
|
+
export class Mutex {
|
|
3
|
+
tail = Promise.resolve();
|
|
4
|
+
/** Rend la libération quand c'est le tour du demandeur, ou null si `timeoutMs` s'écoule avant. Un demandeur qui renonce laisse passer son tour. */
|
|
5
|
+
async acquire(timeoutMs = Infinity) {
|
|
6
|
+
const prev = this.tail;
|
|
7
|
+
let release;
|
|
8
|
+
const mine = new Promise((r) => (release = r));
|
|
9
|
+
this.tail = prev.then(() => mine);
|
|
10
|
+
let timer;
|
|
11
|
+
const late = new Promise((r) => {
|
|
12
|
+
if (timeoutMs !== Infinity)
|
|
13
|
+
timer = setTimeout(() => r(false), timeoutMs);
|
|
14
|
+
});
|
|
15
|
+
const turn = await Promise.race([prev.then(() => true), late]);
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
if (turn)
|
|
18
|
+
return release;
|
|
19
|
+
void prev.then(release);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const mutexes = new Map();
|
|
24
|
+
/** Le mutex nommé (un par dépôt et par usage) : même objet pour la même clé, pendant toute la vie du processus. */
|
|
25
|
+
export function mutexFor(key) {
|
|
26
|
+
let m = mutexes.get(key);
|
|
27
|
+
if (!m)
|
|
28
|
+
mutexes.set(key, (m = new Mutex()));
|
|
29
|
+
return m;
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readOrchestrateConfig } from '../config.js';
|
|
4
|
+
/** Le script que `npm init` écrit quand on ne dit rien : il échoue toujours, ce n'est pas une suite. */
|
|
5
|
+
const NPM_PLACEHOLDER = /no test specified/i;
|
|
6
|
+
/** La commande de test que le dépôt laisse deviner : `scripts.test` de package.json, pytest de pyproject.toml, pom.xml ; null sinon. */
|
|
7
|
+
export function probableTestCommand(repo) {
|
|
8
|
+
const pkg = join(repo, 'package.json');
|
|
9
|
+
if (existsSync(pkg)) {
|
|
10
|
+
try {
|
|
11
|
+
const test = JSON.parse(readFileSync(pkg, 'utf8')).scripts?.test;
|
|
12
|
+
if (typeof test === 'string' && test.trim() && !NPM_PLACEHOLDER.test(test))
|
|
13
|
+
return 'npm test';
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// un package.json illisible ne donne aucune commande
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const py = join(repo, 'pyproject.toml');
|
|
20
|
+
if (existsSync(py) && /pytest/i.test(readFileSync(py, 'utf8')))
|
|
21
|
+
return 'pytest';
|
|
22
|
+
if (existsSync(join(repo, 'pom.xml')))
|
|
23
|
+
return 'mvn test';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** Le projet n'a pas de `orchestrate.test` : ce qu'on en dit, ou null quand il en a un. Un cadence.yaml illisible ne se signale pas ici (il a déjà sa propre erreur). */
|
|
27
|
+
export function noTestGate(repo) {
|
|
28
|
+
let test;
|
|
29
|
+
try {
|
|
30
|
+
test = readOrchestrateConfig(join(repo, 'cadence.yaml')).test;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return test ? null : { probable: probableTestCommand(repo) };
|
|
36
|
+
}
|
|
37
|
+
/** La phrase du signalement, sans le nom du projet. */
|
|
38
|
+
export function noTestText(g) {
|
|
39
|
+
const probable = g.probable ? ` · commande probable : ${g.probable} (à déclarer en orchestrate.test)` : '';
|
|
40
|
+
return `porte de tests absente : orchestrate.test n'est pas déclaré, la vague ne lancera pas la suite (seule la revue le fera si elle le décide)${probable}`;
|
|
41
|
+
}
|
package/dist/orchestrate/pool.js
CHANGED
|
@@ -3,12 +3,10 @@ import { lotKey, lotRepoPaths } 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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* même file : jamais deux sessions dans un même dépôt (le plafond global des sessions de toutes
|
|
9
|
-
* les vagues est tenu par command.ts). `onSettled` est appelé quand un lot a fini de jouer (L132 : command.ts libère alors les dépôts que plus aucun lot ne tient). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
|
|
6
|
+
* Les groupes de lots qui partagent un dépôt (le leur, ou un dépôt voisin déclaré par `repos:`, L62), chacun dans l'ordre donné : deux lots qui partagent un dépôt sont dans
|
|
7
|
+
* le même groupe. Un groupe se joue à la suite, ou — avec des worktrees (L279) — ses lots en parallèle.
|
|
10
8
|
*/
|
|
11
|
-
export
|
|
9
|
+
export function groupLots(ctxs) {
|
|
12
10
|
const groups = [];
|
|
13
11
|
for (const c of ctxs) {
|
|
14
12
|
const mine = lotRepoPaths(c.lot);
|
|
@@ -25,44 +23,84 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
|
|
|
25
23
|
group.lots.push(c);
|
|
26
24
|
group.lots.sort((x, y) => ctxs.indexOf(x) - ctxs.indexOf(y));
|
|
27
25
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
26
|
+
return groups.map((g) => g.lots);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 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` files au plus en parallèle. Deux lots qui partagent un dépôt
|
|
30
|
+
* sont dans la même file : jamais deux sessions dans un même dépôt (le plafond global des sessions de toutes les vagues est tenu par command.ts).
|
|
31
|
+
* `parallel(groupe)` (L279) : les lots de ce groupe sont chacun leur propre file — ils jouent ensemble, chacun dans son worktree, dans la limite de `slots` ;
|
|
32
|
+
* un lot qui dépend d'un lot du groupe attend qu'il soit fini (ready après son intégration) avant de partir, et le lot en attente d'une place n'est pas commencé
|
|
33
|
+
* (son worktree n'existe pas encore, R3). `onSettled` est appelé quand un lot a fini de jouer (L132 : command.ts libère alors les dépôts que plus aucun lot ne tient). Le budget, le quota et les incidents sont vérifiés par le cycle avant chaque session.
|
|
34
|
+
*/
|
|
35
|
+
export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () => { }, parallel = () => false) {
|
|
36
|
+
const pending = groupLots(ctxs).flatMap((g) => (parallel(g) ? g.map((c) => [c]) : [g]));
|
|
37
|
+
// Les lots pas encore finis de jouer (en file ou en cours) : un lot ne part pas tant que l'un d'eux est sa dépendance.
|
|
38
|
+
const live = new Set(ctxs);
|
|
39
|
+
const runQueue = async (queue) => {
|
|
40
|
+
// Un lot en attente est revisité tant qu'un tour de la file en fait avancer un autre (sa dépendance peut venir après lui).
|
|
41
|
+
for (let todo = queue, progress = true; todo.length && progress;) {
|
|
42
|
+
const waiting = [];
|
|
43
|
+
progress = false;
|
|
44
|
+
for (const c of todo) {
|
|
45
|
+
const state = (id) => [...ctxs.map((o) => o.lot), ...known].find((o) => o.project === c.lot.project && o.lot === id)?.status;
|
|
46
|
+
const missing = (c.lot.dependsOn ?? []).filter((id) => state(id) !== 'ready');
|
|
47
|
+
if (missing.length && c.lot.steps.length === 0 && (c.lot.status === 'queued' || c.lot.status === 'suspended')) {
|
|
48
|
+
// Une dépendance suspendue, en question ou pas encore jouée (reprise) : le lot attend, il reste reprenable.
|
|
49
|
+
// Seule une dépendance rendue au lead ou en échec le rend aussi.
|
|
50
|
+
const dead = missing.filter((id) => ['handed-back', 'failed'].includes(state(id) ?? 'failed'));
|
|
51
|
+
if (dead.length === 0) {
|
|
52
|
+
c.lot.outcome = `en attente de : ${missing.join(', ')}`;
|
|
51
53
|
c.wave.store.writeLot(c.lot);
|
|
52
|
-
c.wave.log(`${lotKey(c.lot.project, c.lot.lot)}
|
|
53
|
-
|
|
54
|
-
onSettled(c);
|
|
54
|
+
c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} — ${c.lot.outcome}`);
|
|
55
|
+
waiting.push(c);
|
|
55
56
|
continue;
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
c.lot.status = 'handed-back';
|
|
59
|
+
c.lot.outcome = `dépendance non prête dans la vague : ${missing.join(', ')}`;
|
|
60
|
+
c.wave.store.writeLot(c.lot);
|
|
61
|
+
c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} → handed-back — ${c.lot.outcome}`);
|
|
59
62
|
progress = true;
|
|
60
|
-
|
|
63
|
+
live.delete(c);
|
|
61
64
|
onSettled(c);
|
|
65
|
+
continue;
|
|
62
66
|
}
|
|
63
|
-
|
|
67
|
+
if (c.lot.outcome?.startsWith('en attente de : '))
|
|
68
|
+
c.lot.outcome = null;
|
|
69
|
+
progress = true;
|
|
70
|
+
await runLot(c);
|
|
71
|
+
live.delete(c);
|
|
72
|
+
onSettled(c);
|
|
64
73
|
}
|
|
74
|
+
todo = waiting;
|
|
65
75
|
}
|
|
76
|
+
for (const c of queue)
|
|
77
|
+
live.delete(c);
|
|
78
|
+
};
|
|
79
|
+
/** La file d'un lot seul (groupe en parallèle) dont une dépendance joue encore : elle ne part pas. */
|
|
80
|
+
const blocked = (queue) => queue.length === 1 &&
|
|
81
|
+
(queue[0].lot.dependsOn ?? []).some((id) => {
|
|
82
|
+
const dep = ctxs.find((o) => o.lot.project === queue[0].lot.project && o.lot.lot === id);
|
|
83
|
+
return !!dep && live.has(dep) && dep.lot.status !== 'ready';
|
|
84
|
+
});
|
|
85
|
+
const running = new Set();
|
|
86
|
+
const start = (queue) => {
|
|
87
|
+
const p = runQueue(queue).finally(() => running.delete(p));
|
|
88
|
+
running.add(p);
|
|
66
89
|
};
|
|
67
|
-
|
|
90
|
+
for (;;) {
|
|
91
|
+
while (running.size < slots) {
|
|
92
|
+
const next = pending.findIndex((q) => !blocked(q));
|
|
93
|
+
if (next < 0)
|
|
94
|
+
break;
|
|
95
|
+
start(pending.splice(next, 1)[0]);
|
|
96
|
+
}
|
|
97
|
+
if (running.size === 0) {
|
|
98
|
+
// Plus rien ne joue et chaque file restante attend une autre : le départ forcé laisse `runQueue` rendre ou garder en attente, comme avant.
|
|
99
|
+
if (!pending.length)
|
|
100
|
+
break;
|
|
101
|
+
start(pending.shift());
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
await Promise.race(running);
|
|
105
|
+
}
|
|
68
106
|
}
|