@sylad/cadence 1.8.0 → 1.9.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 +23 -7
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/qa-reviewer.md +2 -2
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +14 -4
- package/dist/check.js +6 -0
- package/dist/cli.js +235 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +23 -2
- package/dist/deliver.js +78 -21
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +37 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +28 -6
- package/dist/orchestrate/command.js +303 -34
- package/dist/orchestrate/cycle.js +720 -273
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +85 -0
- package/dist/orchestrate/launch.js +37 -5
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +123 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +25 -0
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +42 -6
- package/dist/orchestrate/table.js +61 -2
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +80 -19
- package/dist/plan.js +92 -6
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +116 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +78 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +179 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +104 -0
- package/dist/ref/png.js +125 -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 +60 -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 +58 -3
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +5 -5
- package/docs/lead.md +35 -12
- package/docs/news.md +24 -1
- package/docs/orchestrate.md +230 -19
- package/docs/perf.md +13 -8
- package/docs/raf.md +89 -11
- package/docs/ref.md +316 -0
- package/docs/session.md +2 -2
- package/docs/skills.md +9 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +2 -0
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +24 -6
- 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 +6 -3
- 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,85 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { gitCommonDir } from '../git.js';
|
|
4
|
+
import { sharedStateDir } from '../state.js';
|
|
5
|
+
import { activeLock, releaseLock, takeLock } from './lock.js';
|
|
6
|
+
import { mutexFor } from './mutex.js';
|
|
7
|
+
import { git, gitFailure, tryGit } from './worktree.js';
|
|
8
|
+
/** 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. */
|
|
9
|
+
export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
|
|
10
|
+
/** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
|
|
11
|
+
export const INTEGRATE_WAIT_MS = 10 * 60_000;
|
|
12
|
+
export const INTEGRATE_POLL_MS = 1_000;
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
/**
|
|
15
|
+
* 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
|
|
16
|
+
* 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
|
|
17
|
+
* 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.
|
|
18
|
+
*/
|
|
19
|
+
export async function takeRepoWrite(repo, who, o = {}) {
|
|
20
|
+
const waitMs = o.waitMs ?? INTEGRATE_WAIT_MS;
|
|
21
|
+
const pollMs = o.pollMs ?? INTEGRATE_POLL_MS;
|
|
22
|
+
const file = join(sharedStateDir(repo), INTEGRATION_LOCK);
|
|
23
|
+
const deadline = Date.now() + waitMs;
|
|
24
|
+
const turn = await mutexFor(`integrate:${gitCommonDir(repo)}`).acquire(waitMs);
|
|
25
|
+
if (!turn) {
|
|
26
|
+
// Un autre lot de ce processus tient la main : c'est lui le porteur (il a posé le verrou fichier).
|
|
27
|
+
const holder = activeLock(file);
|
|
28
|
+
return { ok: false, holder: { wave: holder?.wave ?? who.wave, pid: holder?.pid ?? process.pid } };
|
|
29
|
+
}
|
|
30
|
+
for (;;) {
|
|
31
|
+
const lock = { pid: process.pid, wave: who.wave, started: new Date().toISOString() };
|
|
32
|
+
const got = takeLock(file, lock);
|
|
33
|
+
if (got.ok) {
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
release: () => {
|
|
37
|
+
releaseLock(file, process.pid);
|
|
38
|
+
turn();
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (Date.now() >= deadline) {
|
|
43
|
+
turn();
|
|
44
|
+
return { ok: false, holder: { wave: got.held.wave, pid: got.held.pid } };
|
|
45
|
+
}
|
|
46
|
+
await sleep(pollMs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Branche courante du dépôt (`main`), null en HEAD détaché (R24). */
|
|
50
|
+
export const repoBranch = (repo) => tryGit(repo, 'symbolic-ref', '--short', '-q', 'HEAD') || null;
|
|
51
|
+
/** Point de départ de `rev` par rapport à la branche `base` : le dernier ancêtre commun. */
|
|
52
|
+
export const mergeBase = (cwd, rev, base) => git(cwd, 'merge-base', rev, `refs/heads/${base}`);
|
|
53
|
+
/**
|
|
54
|
+
* 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
|
|
55
|
+
* change). Un conflit — ou tout autre échec du rebase — est abandonné (`rebase --abort`) : la branche du lot et son arbre restent tels qu'avant, la
|
|
56
|
+
* branche du dépôt n'a pas été touchée, personne ne résout le conflit à l'aveugle.
|
|
57
|
+
*/
|
|
58
|
+
export function rebaseOnto(work, base) {
|
|
59
|
+
const before = git(work, 'rev-parse', 'HEAD');
|
|
60
|
+
if (tryGit(work, 'merge-base', '--is-ancestor', `refs/heads/${base}`, 'HEAD') !== null)
|
|
61
|
+
return { kind: 'forwarded' };
|
|
62
|
+
try {
|
|
63
|
+
execFileSync('git', ['rebase', `refs/heads/${base}`], { cwd: work, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_EDITOR: 'true', GIT_SEQUENCE_EDITOR: 'true' } });
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const files = (tryGit(work, 'diff', '--name-only', '--diff-filter=U') ?? '').split('\n').filter(Boolean);
|
|
67
|
+
tryGit(work, 'rebase', '--abort');
|
|
68
|
+
return { kind: 'conflict', files, detail: gitFailure(e) };
|
|
69
|
+
}
|
|
70
|
+
return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD') };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 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
|
|
74
|
+
* une ligne du lot change. Vide quand il n'y a pas de diff.
|
|
75
|
+
*/
|
|
76
|
+
export function patchId(cwd, from, to) {
|
|
77
|
+
const diff = execFileSync('git', ['diff', '--no-color', '--no-ext-diff', from, to], { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 512 });
|
|
78
|
+
if (!diff)
|
|
79
|
+
return '';
|
|
80
|
+
return execFileSync('git', ['patch-id', '--stable'], { cwd, input: diff, encoding: 'utf8' }).trim().split(/\s+/)[0] ?? '';
|
|
81
|
+
}
|
|
82
|
+
/** 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. */
|
|
83
|
+
export function advance(repo, branch) {
|
|
84
|
+
git(repo, 'merge', '--ff-only', '--quiet', `refs/heads/${branch}`);
|
|
85
|
+
}
|
|
@@ -34,8 +34,8 @@ export function writeMcpConfig(lotDir, kind, visible, small = false, screen = tr
|
|
|
34
34
|
writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
|
|
35
35
|
return file;
|
|
36
36
|
}
|
|
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:*)'];
|
|
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) ; pas de `Monitor` (tâche de fond que `-p` attend, L285). */
|
|
38
|
+
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
39
|
/** Outils MCP du serveur Playwright, par le préfixe du serveur (sonde réelle du 07-10 : `mcp__playwright` expose tous les `browser_*`). */
|
|
40
40
|
export const PLAYWRIGHT_TOOLS = 'mcp__playwright';
|
|
41
41
|
/** 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 +48,8 @@ function agentsFor(spec, agents) {
|
|
|
48
48
|
function effortArgs(spec) {
|
|
49
49
|
return spec.effort && spec.effort !== 'default' ? ['--effort', spec.effort] : [];
|
|
50
50
|
}
|
|
51
|
+
/** 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). */
|
|
52
|
+
export const NO_BACKGROUND_ENV = { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: '1' };
|
|
51
53
|
export function buildArgs(spec, agents) {
|
|
52
54
|
const args = ['-p', spec.brief, '--output-format', 'json', '--json-schema', JSON.stringify(spec.schema), '--model', spec.model, ...effortArgs(spec)];
|
|
53
55
|
if (spec.agent) {
|
|
@@ -103,6 +105,10 @@ export function readAgents(dir) {
|
|
|
103
105
|
}
|
|
104
106
|
return out;
|
|
105
107
|
}
|
|
108
|
+
/** Étapes en lecture seule : seules relancées à neuf après une coupure (une étape d'écriture a pu laisser un travail à moitié fait). */
|
|
109
|
+
const READ_ONLY = new Set(['review', 'review-small', 'ux', 'precheck', 'maquette', 'fiche']);
|
|
110
|
+
/** 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). */
|
|
111
|
+
const isCut = (out) => !out.timedOut && out.code > 128 && out.stdout.trim() === '';
|
|
106
112
|
/** Consigne de la relance de mise en forme : rendre le rapport déjà établi au format demandé, rien d'autre. */
|
|
107
113
|
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
114
|
/** Arguments de la relance : même session (--resume), mêmes schéma, modèle, agent (outils restreints), mode de permission, dossiers et interdits. */
|
|
@@ -126,14 +132,30 @@ export function buildRetryArgs(spec, sessionId, agents) {
|
|
|
126
132
|
* exception près : une session réussie qui finit en texte sans `structured_output` est relancée UNE fois, par
|
|
127
133
|
* `--resume` de la même session, pour qu'elle rende son rapport au format demandé. Les jetons de la relance
|
|
128
134
|
* s'ajoutent à ceux de la session ; toujours rien après elle, c'est l'échec habituel.
|
|
135
|
+
* Seconde exception (L256) : une étape en lecture coupée par un signal sans rien écrire (code 143, sortie vide) est
|
|
136
|
+
* relancée UNE fois à l'identique en session NEUVE (autre `--session-id`, jamais `--resume` : la coupée n'a rien à reprendre) ;
|
|
137
|
+
* 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
138
|
*/
|
|
130
139
|
export async function runSession(spec, deps) {
|
|
131
140
|
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 });
|
|
141
|
+
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 });
|
|
142
|
+
const epoch = killEpoch;
|
|
133
143
|
const out = await launch(spec.local ? buildLocalArgs(spec, deps.agents) : buildArgs(spec, deps.agents));
|
|
134
144
|
const first = classify(out);
|
|
135
145
|
if (spec.local)
|
|
136
146
|
return first; // pas de relance de mise en forme : l'appelant se replie sur Sonnet
|
|
147
|
+
// Une session tuée par `killSessions` (arrêt de la vague) n'est pas une coupure à relancer : la neuve survivrait à l'orchestrateur.
|
|
148
|
+
if (first.kind === 'failed' && READ_ONLY.has(spec.kind) && isCut(out) && killEpoch === epoch) {
|
|
149
|
+
const relaunched = { sessionId: spec.sessionId, cause: `code ${out.code}, sortie vide`, stderr: out.stderr };
|
|
150
|
+
const fresh = randomUUID();
|
|
151
|
+
deps.onRelaunch?.(fresh); // l'appelant l'enregistre AVANT le lancement : un signal pendant la relance retrouve la session neuve
|
|
152
|
+
const again = await launch(buildArgs({ ...spec, sessionId: fresh }, deps.agents));
|
|
153
|
+
const next = classify(again);
|
|
154
|
+
if (next.kind === 'ok')
|
|
155
|
+
return { ...next, relaunched };
|
|
156
|
+
const tail = { ...next, sessionId: next.sessionId ?? fresh, relaunched };
|
|
157
|
+
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;
|
|
158
|
+
}
|
|
137
159
|
const missing = first.kind === 'failed' && !out.timedOut && out.code === 0 ? lacksStructuredOutput(out.stdout) : null;
|
|
138
160
|
if (!missing)
|
|
139
161
|
return first;
|
|
@@ -175,6 +197,8 @@ function classify(out) {
|
|
|
175
197
|
const live = new Set();
|
|
176
198
|
const trees = new Set();
|
|
177
199
|
const marks = new Set();
|
|
200
|
+
/** Compte les arrêts de la vague (`killSessions`) : une session qui finit après un arrêt n'est pas relancée. */
|
|
201
|
+
let killEpoch = 0;
|
|
178
202
|
/** Suit un groupe de processus lancé par l'orchestrateur (commande du projet) : tué avec les sessions au signal. */
|
|
179
203
|
export function trackGroup(pid) {
|
|
180
204
|
live.add(pid);
|
|
@@ -182,6 +206,7 @@ export function trackGroup(pid) {
|
|
|
182
206
|
}
|
|
183
207
|
/** Tue les groupes de processus des sessions en cours (Ctrl-C, SIGTERM de l'orchestrateur). */
|
|
184
208
|
export function killSessions() {
|
|
209
|
+
killEpoch++;
|
|
185
210
|
for (const pid of live) {
|
|
186
211
|
try {
|
|
187
212
|
process.kill(-pid, 'SIGKILL');
|
|
@@ -227,7 +252,13 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
|
|
|
227
252
|
child.stdout.on('data', (d) => out.push(d));
|
|
228
253
|
child.stderr.on('data', (d) => err.push(d));
|
|
229
254
|
let timedOut = false;
|
|
230
|
-
|
|
255
|
+
let extended = 0;
|
|
256
|
+
const deadline = () => {
|
|
257
|
+
if (opts.extend && extended < opts.extend.max && opts.extend.progressed()) {
|
|
258
|
+
extended++;
|
|
259
|
+
timer = setTimeout(deadline, Math.max(1_000, opts.extend.ms));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
231
262
|
timedOut = true;
|
|
232
263
|
tree.kill();
|
|
233
264
|
killMarked(mark);
|
|
@@ -237,7 +268,8 @@ export function realClaude(bin, base = process.env, localBin = 'claude-local') {
|
|
|
237
268
|
catch {
|
|
238
269
|
// déjà mort
|
|
239
270
|
}
|
|
240
|
-
}
|
|
271
|
+
};
|
|
272
|
+
let timer = setTimeout(deadline, Math.max(1_000, opts.timeoutMs));
|
|
241
273
|
// Aucun descendant ne survit à la session, dans son groupe ou hors de lui. Dès que la racine sort (`exit`), pas
|
|
242
274
|
// à `close` : un descendant qui tient stdout/stderr ouverts retarderait `close` jusqu'au délai (« délai dépassé »
|
|
243
275
|
// 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
|
+
}
|
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
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { processStart } from '../proc.js';
|
|
@@ -7,7 +7,8 @@ import { lotFinished, lotKey, lotRepoPaths, RunStore } from './state.js';
|
|
|
7
7
|
import { activeLock, holderAlive, releaseLock, takeLock } from './lock.js';
|
|
8
8
|
/**
|
|
9
9
|
* État commun à toutes les vagues de l'utilisateur, quel que soit le dossier d'où elles partent : le registre
|
|
10
|
-
* des vagues vivantes (`waves/<pid>.json`)
|
|
10
|
+
* des vagues vivantes (`waves/<pid>.json`), les créneaux de sessions simultanées (`slots/slot-<n>.lock`) et les
|
|
11
|
+
* sessions lancées à la main par le lead (`holds/<étiquette>.json`, L255).
|
|
11
12
|
* Les verrous de DÉPÔT restent dans le dépôt (`.git/cadence/orchestrate.lock`).
|
|
12
13
|
*/
|
|
13
14
|
export function cadenceHome(env = process.env) {
|
|
@@ -21,6 +22,8 @@ function readWave(file) {
|
|
|
21
22
|
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) : [] };
|
|
22
23
|
if (raw.cap !== undefined)
|
|
23
24
|
w.cap = Number(raw.cap);
|
|
25
|
+
if (raw.lanes !== undefined)
|
|
26
|
+
w.lanes = Number(raw.lanes);
|
|
24
27
|
if (raw.start !== undefined)
|
|
25
28
|
w.start = String(raw.start);
|
|
26
29
|
return w;
|
|
@@ -48,13 +51,82 @@ export function liveWaves(home) {
|
|
|
48
51
|
}
|
|
49
52
|
/**
|
|
50
53
|
* Les créneaux que le lead peut encore donner à des sous-agents : `total` moins, pour chaque vague vivante, le
|
|
51
|
-
* plus petit de son plafond et du nombre de
|
|
54
|
+
* plus petit de son plafond et du nombre de lots qu'elle joue à la fois — un par dépôt qu'elle tient encore, un par lot pour des lots en worktrees (ses pas prennent un créneau et le
|
|
52
55
|
* rendent entre deux étapes : les sessions vivantes à l'instant ne disent pas ce que la vague va reprendre).
|
|
53
56
|
* Une vague sans plafond connu compte pour `total`.
|
|
54
57
|
*/
|
|
55
|
-
export function freeSlots(waves, total) {
|
|
56
|
-
const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.repos.length), 0);
|
|
57
|
-
return Math.max(0, total - held);
|
|
58
|
+
export function freeSlots(waves, total, leadSessions = 0) {
|
|
59
|
+
const held = waves.reduce((n, w) => n + Math.min(w.cap ?? total, w.lanes ?? w.repos.length), 0);
|
|
60
|
+
return Math.max(0, total - held - leadSessions);
|
|
61
|
+
}
|
|
62
|
+
const holdsDir = (home) => join(home, 'holds');
|
|
63
|
+
/** Le libellé est libre (espaces, « / »), le nom de fichier l'encode. */
|
|
64
|
+
const holdFile = (home, label) => join(holdsDir(home), `${encodeURIComponent(label)}.json`);
|
|
65
|
+
/** Inscrit la session `label` pour `ttlMinutes` minutes à partir de `now` ; la poser de nouveau la renouvelle. */
|
|
66
|
+
export function addHold(home, label, ttlMinutes, now) {
|
|
67
|
+
mkdirSync(holdsDir(home), { recursive: true });
|
|
68
|
+
const file = holdFile(home, label);
|
|
69
|
+
const hold = { label, since: now.toISOString(), expires: new Date(now.getTime() + ttlMinutes * 60_000).toISOString() };
|
|
70
|
+
writeFileSync(`${file}.tmp`, JSON.stringify(hold));
|
|
71
|
+
renameSync(`${file}.tmp`, file);
|
|
72
|
+
}
|
|
73
|
+
/** Retire la session `label` ; faux quand elle n'était pas inscrite. */
|
|
74
|
+
export function releaseHold(home, label) {
|
|
75
|
+
const file = holdFile(home, label);
|
|
76
|
+
if (!existsSync(file))
|
|
77
|
+
return false;
|
|
78
|
+
rmSync(file, { force: true });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/** Une session échue reste lisible (et signalée en ⚠) jusqu'à son `release` ou, oubliée, ce délai après son échéance. */
|
|
82
|
+
const EXPIRED_HOLD_KEEP_MS = 24 * 3600_000;
|
|
83
|
+
/** Lit les sessions du lead : celles dont l'échéance n'est pas passée à `now`, et celles échues depuis moins de 24 h ; le reste, et les fichiers illisibles, sont écartés au passage. */
|
|
84
|
+
function scanHolds(home, now) {
|
|
85
|
+
let names;
|
|
86
|
+
try {
|
|
87
|
+
names = readdirSync(holdsDir(home)).filter((n) => n.endsWith('.json'));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { live: [], expired: [] };
|
|
91
|
+
}
|
|
92
|
+
const live = [];
|
|
93
|
+
const expired = [];
|
|
94
|
+
for (const n of names) {
|
|
95
|
+
const file = join(holdsDir(home), n);
|
|
96
|
+
let hold = null;
|
|
97
|
+
let overdue = false;
|
|
98
|
+
try {
|
|
99
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
100
|
+
const end = Date.parse(raw.expires);
|
|
101
|
+
if (typeof raw.label === 'string' && Number.isFinite(end)) {
|
|
102
|
+
hold = { label: raw.label, since: String(raw.since), expires: String(raw.expires) };
|
|
103
|
+
overdue = end <= now.getTime();
|
|
104
|
+
if (now.getTime() - end >= EXPIRED_HOLD_KEEP_MS)
|
|
105
|
+
hold = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// illisible : écarté
|
|
110
|
+
}
|
|
111
|
+
if (!hold)
|
|
112
|
+
rmSync(file, { force: true });
|
|
113
|
+
else
|
|
114
|
+
(overdue ? expired : live).push(hold);
|
|
115
|
+
}
|
|
116
|
+
const byAge = (a, b) => a.since.localeCompare(b.since) || a.label.localeCompare(b.label);
|
|
117
|
+
return { live: live.sort(byAge), expired: expired.sort(byAge) };
|
|
118
|
+
}
|
|
119
|
+
/** Les sessions du lead dont l'échéance n'est pas passée à `now` : seules elles comptent dans les créneaux. */
|
|
120
|
+
export function liveHolds(home, now) {
|
|
121
|
+
return scanHolds(home, now).live;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Les sessions du lead dont le TTL est échu à `now` (L278) et que personne n'a rendues : plus de créneau pris, mais la
|
|
125
|
+
* session tourne peut-être encore. `lead tour`, `orchestrate --status` et la bande les signalent en ⚠ : le lead la
|
|
126
|
+
* reprend (`lead hold`) ou l'arrête (`lead release`). Rien ne l'arrête ici.
|
|
127
|
+
*/
|
|
128
|
+
export function expiredHolds(home, now) {
|
|
129
|
+
return scanHolds(home, now).expired;
|
|
58
130
|
}
|
|
59
131
|
/** Inscrit la vague ; écarte au passage les entrées de processus morts (les `.tmp` d'une inscription en cours ne sont pas touchés). */
|
|
60
132
|
export function registerWave(home, given) {
|
|
@@ -71,13 +143,13 @@ export function registerWave(home, given) {
|
|
|
71
143
|
writeFileSync(`${file}.tmp`, JSON.stringify(w));
|
|
72
144
|
renameSync(`${file}.tmp`, file);
|
|
73
145
|
}
|
|
74
|
-
/** Met à jour la liste des dépôts tenus par la vague (un dépôt libéré en cours de vague n'y figure plus). */
|
|
75
|
-
export function updateWaveRepos(home, pid, repos) {
|
|
146
|
+
/** Met à jour la liste des dépôts tenus par la vague (un dépôt libéré en cours de vague n'y figure plus) et, quand elle la donne, le nombre de lots qu'elle joue à la fois. */
|
|
147
|
+
export function updateWaveRepos(home, pid, repos, lanes) {
|
|
76
148
|
const file = join(wavesDir(home), `${pid}.json`);
|
|
77
149
|
const w = readWave(file);
|
|
78
150
|
if (!w)
|
|
79
151
|
return;
|
|
80
|
-
writeFileSync(`${file}.tmp`, JSON.stringify({ ...w, repos }));
|
|
152
|
+
writeFileSync(`${file}.tmp`, JSON.stringify({ ...w, repos, ...(lanes === undefined ? {} : { lanes }) }));
|
|
81
153
|
renameSync(`${file}.tmp`, file);
|
|
82
154
|
}
|
|
83
155
|
export function unregisterWave(home, pid) {
|
|
@@ -139,26 +211,63 @@ export async function acquireSlot(home, cap, opts) {
|
|
|
139
211
|
await new Promise((r) => setTimeout(r, pollMs + Math.random() * pollMs * 0.25));
|
|
140
212
|
}
|
|
141
213
|
}
|
|
142
|
-
/** .git commun du dépôt `dir` (partagé par ses worktrees) ; null si `dir` n'est pas un dépôt. */
|
|
214
|
+
/** .git commun du dépôt `dir` (partagé par ses worktrees), chemin réel (un lien symbolique vers le dépôt désigne le même) ; null si `dir` n'est pas un dépôt. */
|
|
143
215
|
const commonDir = (dir) => {
|
|
144
216
|
try {
|
|
145
|
-
return gitCommonDir(dir);
|
|
217
|
+
return realpathSync(gitCommonDir(dir));
|
|
146
218
|
}
|
|
147
219
|
catch {
|
|
148
220
|
return null;
|
|
149
221
|
}
|
|
150
222
|
};
|
|
151
|
-
/** Lots de la vague `pid` qui travaillent
|
|
152
|
-
export function
|
|
223
|
+
/** Lots de la vague `pid` qui travaillent dans `repo` (ou l'un de ses worktrees), finis ou non ; null si l'état de la vague est illisible. */
|
|
224
|
+
export function waveLotsIn(home, pid, repo) {
|
|
153
225
|
try {
|
|
154
226
|
const w = liveWaves(home).find((x) => x.pid === pid);
|
|
155
227
|
const store = w && RunStore.find(w.cwd, w.wave);
|
|
156
228
|
if (!store)
|
|
157
229
|
return null;
|
|
158
230
|
const mine = commonDir(repo);
|
|
159
|
-
return store.lots().filter((l) =>
|
|
231
|
+
return store.lots().filter((l) => lotRepoPaths(l).some((r) => r === repo || (mine !== null && commonDir(r) === mine)));
|
|
160
232
|
}
|
|
161
233
|
catch {
|
|
162
234
|
return null;
|
|
163
235
|
}
|
|
164
236
|
}
|
|
237
|
+
/** Lots de la vague `pid` qui travaillent encore dans `repo` (en cours, en file ou suspendus) ; null si l'état de la vague est illisible. */
|
|
238
|
+
export function openLotsIn(home, pid, repo) {
|
|
239
|
+
return waveLotsIn(home, pid, repo)?.filter((l) => !lotFinished(l)).map((l) => lotKey(l.project, l.lot)) ?? null;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Vagues non terminées (`done` exclu : interrompues, suspendues, ou vivantes) lancées depuis `launchDir` qui gardent ouvert (état non terminal : question, suspendu, en file, en cours...)
|
|
243
|
+
* au moins un lot DU MÊME DÉPÔT que `repo` pour lequel `doing(lot)` est vrai (L238, `doing` : le lot est encore en cours au plan de ce dépôt). Un `--resume` rejouera ces lots sur ce dépôt :
|
|
244
|
+
* un rescellé commité entre-temps leur serait rendu comme « premier commit fautif ». Un lot livré au plan (vieille vague abandonnée) ou absent du plan ne compte jamais.
|
|
245
|
+
* Le dépôt d'un lot est celui de son projet (`lot.repo`), pas ses dépôts voisins : l'id d'un lot est propre au plan de son projet.
|
|
246
|
+
*/
|
|
247
|
+
export function openWavesIn(launchDir, repo, doing, live = []) {
|
|
248
|
+
const base = RunStore.runsDir(launchDir);
|
|
249
|
+
if (!existsSync(base))
|
|
250
|
+
return [];
|
|
251
|
+
const mine = commonDir(repo);
|
|
252
|
+
const out = [];
|
|
253
|
+
for (const id of readdirSync(base).sort()) {
|
|
254
|
+
try {
|
|
255
|
+
const store = RunStore.find(launchDir, id);
|
|
256
|
+
if (!store || store.readWave()?.status === 'done')
|
|
257
|
+
continue;
|
|
258
|
+
const open = store
|
|
259
|
+
.lots()
|
|
260
|
+
.filter((l) => !lotFinished(l) && (l.repo === repo || (mine !== null && commonDir(l.repo) === mine)))
|
|
261
|
+
.map((l) => ({ in: doing(l), key: lotKey(l.project, l.lot), status: l.status }));
|
|
262
|
+
const lots = open.filter((l) => l.in).map(({ key, status }) => ({ key, status }));
|
|
263
|
+
const others = open.filter((l) => !l.in).map(({ key, status }) => ({ key, status }));
|
|
264
|
+
const alive = live.find((w) => w.wave === id && w.cwd === launchDir);
|
|
265
|
+
if (lots.length)
|
|
266
|
+
out.push({ wave: id, launchDir, live: !!alive, ...(alive ? { pid: alive.pid } : {}), lots, others });
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// état illisible : une vague qu'on ne sait pas lire ne bloque pas un rescellé du lead
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
@@ -51,7 +51,7 @@ export function parseSession(stdout, opts) {
|
|
|
51
51
|
}
|
|
52
52
|
/** Le message d'une session arrêtée par la limite d'usage (quota) ; l'heure de remise suit « | » quand elle est donnée. */
|
|
53
53
|
export function isQuotaMessage(text) {
|
|
54
|
-
return /usage limit|limit reached|rate limit|quota/i.test(text);
|
|
54
|
+
return /usage limit|limit reached|hit your .{0,20}limit|rate limit|quota/i.test(text);
|
|
55
55
|
}
|
|
56
56
|
/** Heure de remise (epoch s) lue dans « …limit reached|1759600000 », sinon null. */
|
|
57
57
|
export function quotaReset(text) {
|
|
@@ -103,6 +103,20 @@ export function waveReadsLotBudget(waveDir) {
|
|
|
103
103
|
return false;
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* L'instantané de la vague connaît-il `--resume --integrate` (L279) ? Un instantané plus ancien refuserait l'option comme inconnue.
|
|
108
|
+
* Sans instantané : rien à relire, on suppose que oui.
|
|
109
|
+
*/
|
|
110
|
+
export function waveReadsIntegrate(waveDir) {
|
|
111
|
+
if (!snapshotExists(waveDir))
|
|
112
|
+
return true;
|
|
113
|
+
try {
|
|
114
|
+
return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'command.js'), 'utf8').includes('abandonCommits');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
106
120
|
/** Relance réelle : un processus node sur la copie, signaux transmis, code de sortie rendu. */
|
|
107
121
|
export function spawnReexec(toolDir, argv, env, sink) {
|
|
108
122
|
return new Promise((resolve, reject) => {
|
|
@@ -125,3 +139,14 @@ export function spawnReexec(toolDir, argv, env, sink) {
|
|
|
125
139
|
});
|
|
126
140
|
});
|
|
127
141
|
}
|
|
142
|
+
/** L'instantané de la vague connaît-il `--resume --replay` (L279, reprise d'un lot rendu sur une écriture de plan refusée) ? Sans instantané : on suppose que oui. */
|
|
143
|
+
export function waveReadsReplay(waveDir) {
|
|
144
|
+
if (!snapshotExists(waveDir))
|
|
145
|
+
return true;
|
|
146
|
+
try {
|
|
147
|
+
return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'command.js'), 'utf8').includes("'--replay'");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|