@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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. 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)\//.test(l);
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
- const timer = setTimeout(() => {
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
- }, Math.max(1_000, opts.timeoutMs));
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
+ }
@@ -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
- * 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. Deux lots qui partagent un dépôt (le leur, ou un dépôt voisin déclaré par `repos:`, L62) sont dans la
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 async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () => { }) {
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
- const pending = groups.map((g) => g.lots);
29
- const worker = async () => {
30
- for (let queue = pending.shift(); queue; queue = pending.shift()) {
31
- // 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).
32
- for (let todo = queue, progress = true; todo.length && progress;) {
33
- const waiting = [];
34
- progress = false;
35
- for (const c of todo) {
36
- const state = (id) => [...ctxs.map((o) => o.lot), ...known].find((o) => o.project === c.lot.project && o.lot === id)?.status;
37
- const missing = (c.lot.dependsOn ?? []).filter((id) => state(id) !== 'ready');
38
- if (missing.length && c.lot.steps.length === 0 && (c.lot.status === 'queued' || c.lot.status === 'suspended')) {
39
- // Une dépendance suspendue, en question ou pas encore jouée (reprise) : le lot attend, il reste reprenable.
40
- // Seule une dépendance rendue au lead ou en échec le rend aussi.
41
- const dead = missing.filter((id) => ['handed-back', 'failed'].includes(state(id) ?? 'failed'));
42
- if (dead.length === 0) {
43
- c.lot.outcome = `en attente de : ${missing.join(', ')}`;
44
- c.wave.store.writeLot(c.lot);
45
- c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} — ${c.lot.outcome}`);
46
- waiting.push(c);
47
- continue;
48
- }
49
- c.lot.status = 'handed-back';
50
- c.lot.outcome = `dépendance non prête dans la vague : ${missing.join(', ')}`;
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)} → handed-back — ${c.lot.outcome}`);
53
- progress = true;
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
- if (c.lot.outcome?.startsWith('en attente de : '))
58
- c.lot.outcome = null;
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
- await runLot(c);
63
+ live.delete(c);
61
64
  onSettled(c);
65
+ continue;
62
66
  }
63
- todo = waiting;
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
- await Promise.all(Array.from({ length: Math.min(slots, pending.length) }, worker));
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
  }