agentic-workflow-manager 6.2.1 → 6.4.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 (70) hide show
  1. package/README.md +17 -0
  2. package/dist/src/commands/job/gate.js +37 -13
  3. package/dist/src/commands/job/index.js +48 -8
  4. package/dist/src/commands/job/request.js +44 -6
  5. package/dist/src/commands/track/emit.js +26 -0
  6. package/dist/src/commands/track/index.js +194 -0
  7. package/dist/src/commands/track/status.js +63 -0
  8. package/dist/src/commands/track/supervisor-wrapper.js +89 -0
  9. package/dist/src/commands/watch/apply.js +201 -13
  10. package/dist/src/commands/watch/index.js +14 -0
  11. package/dist/src/commands/watch/runner.js +20 -1
  12. package/dist/src/commands/watch/supervisor.js +251 -27
  13. package/dist/src/commands/watch/teardown-driver.js +189 -0
  14. package/dist/src/commands/watch/tracks.js +1100 -0
  15. package/dist/src/core/journal/adapter.js +46 -6
  16. package/dist/src/core/journal/paths.js +9 -0
  17. package/dist/src/core/journal/process.js +101 -1
  18. package/dist/src/core/journal/requests.js +5 -1
  19. package/dist/src/core/journal/store.js +34 -2
  20. package/dist/src/core/journal/types.js +93 -4
  21. package/dist/src/core/paths.js +51 -0
  22. package/dist/src/core/tracks/concurrency.js +85 -0
  23. package/dist/src/core/tracks/context.js +89 -0
  24. package/dist/src/core/tracks/descriptor.js +40 -0
  25. package/dist/src/core/tracks/git.js +318 -0
  26. package/dist/src/core/tracks/join.js +185 -0
  27. package/dist/src/core/tracks/ownership.js +100 -0
  28. package/dist/src/core/tracks/plan-parser.js +108 -0
  29. package/dist/src/core/tracks/protocol.js +466 -0
  30. package/dist/src/core/tracks/teardown.js +34 -0
  31. package/dist/src/core/tracks/types.js +14 -0
  32. package/dist/src/index.js +2 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
  34. package/dist/tests/commands/track/fixtures.js +13 -0
  35. package/dist/tests/commands/track/status.test.js +157 -0
  36. package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
  37. package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
  38. package/dist/tests/commands/track/verbs.test.js +326 -0
  39. package/dist/tests/commands/watch/apply.test.js +116 -4
  40. package/dist/tests/commands/watch/runner.test.js +22 -0
  41. package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
  42. package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
  43. package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
  44. package/dist/tests/commands/watch/track-finalize.test.js +643 -0
  45. package/dist/tests/commands/watch/track-freeze.test.js +591 -0
  46. package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
  47. package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
  48. package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
  49. package/dist/tests/core/journal/adapter-override.test.js +51 -0
  50. package/dist/tests/core/journal/process.test.js +37 -0
  51. package/dist/tests/core/journal/requests.test.js +21 -0
  52. package/dist/tests/core/journal/store.test.js +28 -0
  53. package/dist/tests/core/journal/types.test.js +89 -0
  54. package/dist/tests/core/same-existing-path.test.js +48 -0
  55. package/dist/tests/core/tracks/concurrency.test.js +134 -0
  56. package/dist/tests/core/tracks/context.test.js +177 -0
  57. package/dist/tests/core/tracks/descriptor.test.js +76 -0
  58. package/dist/tests/core/tracks/git.test.js +132 -0
  59. package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
  60. package/dist/tests/core/tracks/join.test.js +197 -0
  61. package/dist/tests/core/tracks/ownership.test.js +96 -0
  62. package/dist/tests/core/tracks/plan-parser.test.js +94 -0
  63. package/dist/tests/core/tracks/protocol.test.js +416 -0
  64. package/dist/tests/core/tracks/teardown.test.js +62 -0
  65. package/dist/tests/helpers/git-fixture.js +35 -0
  66. package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
  67. package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
  68. package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
  69. package/dist/tests/structural/sensor-configs-are-present.test.js +49 -0
  70. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WATCH_PROVIDERS = void 0;
3
+ exports.CONTROLLER_ARGV_ENV = exports.WATCH_PROVIDERS = void 0;
4
4
  exports.isWatchProvider = isWatchProvider;
5
5
  exports.adapterFor = adapterFor;
6
6
  const process_1 = require("./process");
@@ -27,10 +27,50 @@ exports.WATCH_PROVIDERS = ['codex', 'claude-code'];
27
27
  function isWatchProvider(value) {
28
28
  return typeof value === 'string' && exports.WATCH_PROVIDERS.includes(value);
29
29
  }
30
+ /** Nombre del env var que redirige el LANZAMIENTO del controller a un comando propio.
31
+ * Exportado para que tests y documentacion nunca reescriban el literal. */
32
+ exports.CONTROLLER_ARGV_ENV = 'AWM_CONTROLLER_ARGV';
33
+ /**
34
+ * El supervisor declara no conocer providers: conoce el contrato `ControllerAdapter`. Esa
35
+ * afirmacion era INFALSIFICABLE desde afuera mientras los unicos adapters posibles fueran
36
+ * `codex` y `claude-code` — no habia forma de plantar un controller ajeno y comprobar que
37
+ * el supervisor se comporta igual.
38
+ *
39
+ * `AWM_CONTROLLER_ARGV` cierra eso: un array JSON de strings que reemplaza el argv de
40
+ * lanzamiento, conservando TODO lo demas del adapter elegido (actividad, `safeToReplace`,
41
+ * fencing, custodia). El prompt de la generacion se agrega como ultimo argumento, igual que
42
+ * hacen los adapters nativos, para que el controller reciba su token por la misma via.
43
+ *
44
+ * Array JSON y jamas una linea de shell: es la misma doctrina que el argv de integracion de
45
+ * los tracks (C4) — una string interpretada por un shell convierte un nombre de archivo en
46
+ * un operador. Se valida en el borde: cualquier cosa que no sea un array no vacio de strings
47
+ * no vacios es un error explicito, nunca un fallback silencioso al provider nativo (un
48
+ * override mal escrito que "funciona igual" lanzaria el agente real sin que nadie lo note).
49
+ */
50
+ function controllerArgvOverride() {
51
+ const raw = process.env[exports.CONTROLLER_ARGV_ENV]?.trim();
52
+ if (raw === undefined || raw.length === 0)
53
+ return null;
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ throw new Error(`${exports.CONTROLLER_ARGV_ENV} debe ser un array JSON de strings, no una linea de shell: ${raw}`);
60
+ }
61
+ if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((x) => typeof x !== 'string' || x.length === 0)) {
62
+ throw new Error(`${exports.CONTROLLER_ARGV_ENV} debe ser un array JSON no vacio de strings no vacios: ${raw}`);
63
+ }
64
+ return parsed;
65
+ }
30
66
  function adapterFor(provider) {
31
- if (provider === 'codex')
32
- return codexAdapter;
33
- if (provider === 'claude-code')
34
- return claudeAdapter;
35
- throw new Error(`provider desconocido: ${provider} (validos: ${exports.WATCH_PROVIDERS.join(', ')})`);
67
+ const base = provider === 'codex' ? codexAdapter
68
+ : provider === 'claude-code' ? claudeAdapter
69
+ : null;
70
+ if (base === null)
71
+ throw new Error(`provider desconocido: ${provider} (validos: ${exports.WATCH_PROVIDERS.join(', ')})`);
72
+ const override = controllerArgvOverride();
73
+ if (override === null)
74
+ return base;
75
+ return { ...base, launchArgv: (resumePrompt) => [...override, resumePrompt] };
36
76
  }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.branchSlug = branchSlug;
7
7
  exports.journalDir = journalDir;
8
8
  exports.supervisorLockPath = supervisorLockPath;
9
+ exports.integrationLockPath = integrationLockPath;
9
10
  exports.statePath = statePath;
10
11
  exports.requestsDir = requestsDir;
11
12
  exports.acksDir = acksDir;
@@ -36,6 +37,14 @@ function journalDir(repoRoot, branch) {
36
37
  function supervisorLockPath(repoRoot) {
37
38
  return path_1.default.join(fs_1.default.realpathSync(repoRoot), '.awm', 'journal', 'supervisor.lock');
38
39
  }
40
+ /** Lock de integración (R5.8/R5.9/C7, Task 10): único por PLAN físico, fuera
41
+ * de cualquier dir de rama — mismo criterio de `supervisorLockPath` (clavado
42
+ * por realpath). Se adquiere ANTES del primer join de una cohorte y retiene
43
+ * ownership mientras la generación del plan está pausada y ningún controller
44
+ * administrado corre. */
45
+ function integrationLockPath(repoRoot) {
46
+ return path_1.default.join(fs_1.default.realpathSync(repoRoot), '.awm', 'journal', 'integration.lock');
47
+ }
39
48
  function statePath(repoRoot, branch) {
40
49
  return path_1.default.join(journalDir(repoRoot, branch), 'state.json');
41
50
  }
@@ -381,6 +381,17 @@ function refIsAlive(ref) {
381
381
  if ((0, paths_1.isWindowsNative)()) {
382
382
  if (!pidExistsNative(ref.pid))
383
383
  return false;
384
+ // DELIBERADO (Ronda 3, ver el test 'refIsAlive en win32 usa process.kill(pid,0),
385
+ // NUNCA ps/pgrep'): esta rama NO consulta identidad — ni ps/pgrep ni WMI. La razon
386
+ // es un bug real de CI: `ps`/`pgrep` emulados por MSYS son ciegos a procesos nativos
387
+ // y devuelven exit 1 para pids genuinamente vivos, y confiar en ellos declaraba
388
+ // muertes falsas. `process.kill(pid, 0)` es la unica observacion no ciega.
389
+ //
390
+ // CONSECUENCIA CONOCIDA Y ACEPTADA: un pid RECICLADO por un proceso ajeno se reporta
391
+ // como "nuestro proceso sigue vivo". Eso sobre-reporta vida, que es la direccion
392
+ // SEGURA — jamas autoriza matar nada ajeno (de eso se encarga `win32LeaderReused`
393
+ // antes de cualquier senial) — a costa de que un teardown pueda quedarse esperando a
394
+ // un proceso que nunca fue nuestro. Se prefiere la espera sobre el riesgo.
384
395
  return ref.processGroup === ref.pid;
385
396
  }
386
397
  try {
@@ -530,10 +541,83 @@ async function terminateGroupConfirmed(ref, opts) {
530
541
  catch { /* idem */ }
531
542
  return waitUntilGone(opts.killGraceMs);
532
543
  }
544
+ /** true <=> el slot de PID que identifica al grupo (`ref.processGroup` — para
545
+ * todo caller real de esta funcion coincide con `ref.pid`, el lider que el
546
+ * propio caller spawneo: `captureRefFor`/`spawnStructured` siempre fijan
547
+ * `processGroup` al pgid real de ese pid recien creado, que en un detached
548
+ * spawn -setsid- es su PROPIO pid) esta hoy ocupado por un proceso VIVO cuya
549
+ * identidad (startTime + digest de `ps args`) NO coincide con la que
550
+ * capturamos para `ref`. Cubre reuso de PGID (post-review, hallazgo de
551
+ * revision de Task 13): el SO reciclo ese numero de pid hacia un proceso
552
+ * nuevo y no relacionado que se convirtio en lider de su propio grupo
553
+ * (`setpgid`/`setsid`) con el MISMO numero — sin este chequeo, la senial de
554
+ * `terminatePreviouslyOwnedGroup` de mas abajo alcanzaria a ese proceso
555
+ * ajeno. Una ausencia total o un zombie en ese slot NUNCA cuentan como
556
+ * reuso: o es nuestro propio lider aun sin reap (zombie), o el slot esta
557
+ * libre y cualquier miembro remanente del pgid es necesariamente
558
+ * descendiente nuestro (el SO no puede reutilizar el NUMERO de pid del
559
+ * lider para un proceso nuevo mientras ese pid siga ocupado, vivo o
560
+ * zombie). Fail-closed ante cualquier duda (R2.1/R4.8): un `ps` que no pudo
561
+ * correr, o cualquier campo indeterminado, se trata como reuso — nunca
562
+ * como autorizacion para senializar. */
563
+ /** Hermano win32 de `groupLeaderReused` (R4.8). Mismo contrato, distinta fuente de verdad:
564
+ * POSIX pregunta a `ps` por lstart/pgid/args; win32 pregunta a WMI por creationDate y
565
+ * commandLine, exactamente los dos campos con los que `captureRefFor` construyo el `ref`
566
+ * en esta plataforma. `true` <=> el PID esta ocupado por algo que NO es nuestro proceso.
567
+ *
568
+ * Fail-closed identico al POSIX: WMI indisponible o ilegible (`null`) NO prueba que el
569
+ * proceso sea nuestro, asi que se trata como ajeno y la senial jamas sale. Un `ref`
570
+ * degradado a `'unknown'` (WMI no respondio al capturarlo) tampoco autoriza nada — es la
571
+ * consecuencia correcta de no haber podido probar identidad al momento del spawn.
572
+ * `'absent'` es el unico caso que devuelve `false`: no hay nadie ahi a quien confundir. */
573
+ function win32LeaderReused(ref) {
574
+ const info = win32ProcessInfo(ref.processGroup);
575
+ if (info === 'absent')
576
+ return false;
577
+ if (info === null)
578
+ return true;
579
+ if (info.creationDate !== ref.startTime)
580
+ return true;
581
+ return identityDigest(info.commandLine, ref.spawnNonce, ref.argvDigest) !== ref.psArgsDigest;
582
+ }
583
+ function groupLeaderReused(ref) {
584
+ try {
585
+ const stat = psField(ref.processGroup, 'stat');
586
+ if (stat === null || stat.startsWith('Z'))
587
+ return false; // ausente o zombie: nunca un impostor vivo
588
+ const start = psField(ref.processGroup, 'lstart');
589
+ if (start === null || start !== ref.startTime)
590
+ return true;
591
+ const argsDig = psArgsDigestOf(ref.processGroup, ref.spawnNonce, ref.argvDigest);
592
+ if (argsDig === null || argsDig !== ref.psArgsDigest)
593
+ return true;
594
+ return false;
595
+ }
596
+ catch {
597
+ return true; // sin evidencia, jamas autorizar la senial (R2.1)
598
+ }
599
+ }
533
600
  /** Drena un grupo cuya propiedad fue capturada por el caller mientras el
534
601
  * lider aun estaba vivo. Se usa inmediatamente tras el exit del lider para
535
602
  * eliminar descendientes remanentes; el PGID no puede reutilizarse mientras
536
- * esos miembros sigan presentes. */
603
+ * esos miembros sigan presentes.
604
+ *
605
+ * Post-review (hallazgo de revision de Task 13): a diferencia de
606
+ * `terminateGroupConfirmed`, esta funcion se usa PRECISAMENTE cuando el lider
607
+ * ya se espera muerto — por eso no puede reusar `refIsAlive` (exige lider
608
+ * vivo) como gate de identidad. `groupLeaderReused` cubre el mismo riesgo
609
+ * (R4.8: jamas senializar sin identidad verificada) sin asumir que el lider
610
+ * sigue vivo. Antes de este fix, esta funcion confiaba en que el CALLER
611
+ * hubiera verificado identidad momentos antes: `exec-wrapper.ts` lo hace por
612
+ * construccion (llama esto en una ventana de milisegundos justo tras
613
+ * observar el exit del propio hijo), y el caller de Task 13
614
+ * (`stopOwnSupervisor` en `tracks.ts`) tambien, indirectamente — la capa de
615
+ * arriba (`gatherTeardownObservation`) ya recalcula `refIsAlive` en la MISMA
616
+ * tick antes de siquiera elegir la decision `stop-own-supervisor`. Pero esa
617
+ * garantia era implicita y externa al primitivo: un caller futuro (o un
618
+ * cambio en esa capa de arriba) podia perderla sin que este archivo lo
619
+ * notara. Con el chequeo ACA DENTRO, el primitivo deja de depender de la
620
+ * disciplina del caller — se auto-defiende, igual que `terminateGroupConfirmed`. */
537
621
  async function terminatePreviouslyOwnedGroup(ref, opts) {
538
622
  const waitUntilGone = async (maxMs) => {
539
623
  const deadline = Date.now() + maxMs;
@@ -546,13 +630,29 @@ async function terminatePreviouslyOwnedGroup(ref, opts) {
546
630
  };
547
631
  if (groupIsGone(ref.processGroup))
548
632
  return true;
633
+ // Windows PRIMERO: sale sin tocar la logica de grupos, que es semantica POSIX.
634
+ // El guard `groupLeaderReused` de abajo razona sobre PGIDs y no aplica aca — pero el
635
+ // PELIGRO que evita si aplica, y durante un tiempo win32 no tuvo ninguna proteccion
636
+ // equivalente: `groupIsGone` en esta plataforma solo pregunta si el PID existe
637
+ // (`pidExistsNative`), sin verificar identidad, asi que un PID reciclado por un proceso
638
+ // AJENO vivo se leia como "nuestro supervisor sigue vivo" y `killTreeWindows` lo mataba.
639
+ // Eso es exactamente el `kill(pid)` crudo que R4.8 prohibe. `win32LeaderReused` es el
640
+ // hermano que faltaba, con el mismo criterio fail-closed: sin prueba positiva de que el
641
+ // proceso es NUESTRO, jamas se le manda una senial.
549
642
  if ((0, paths_1.isWindowsNative)()) {
643
+ if (win32LeaderReused(ref))
644
+ return false;
550
645
  killTreeWindows(ref.pid);
551
646
  if (await waitUntilGone(opts.termGraceMs))
552
647
  return true;
553
648
  killTreeWindows(ref.pid);
554
649
  return waitUntilGone(opts.killGraceMs);
555
650
  }
651
+ // Un PGID cuyo slot de lider fue reciclado hacia un proceso vivo ajeno
652
+ // NUNCA se confunde con el nuestro (mismo criterio que `refIsAlive`
653
+ // aplica en `terminateGroupConfirmed`, adaptado a un lider ya-muerto).
654
+ if (groupLeaderReused(ref))
655
+ return false;
556
656
  try {
557
657
  process.kill(-ref.processGroup, 'SIGTERM');
558
658
  }
@@ -65,7 +65,11 @@ function emitRequest(repoRoot, branch, env) {
65
65
  (0, atomic_file_1.fsyncDirSync)(dir); // la ENTRADA renombrada tambien debe sobrevivir un crash (R1.3)
66
66
  return { requestId, idempotencyKey: env.idempotencyKey, payloadDigest: digestOf(payload), file: final };
67
67
  }
68
- const KNOWN_KINDS = ['job-request', 'register-entity', 'controller-heartbeat', 'verdict'];
68
+ const KNOWN_KINDS = [
69
+ 'job-request', 'register-entity', 'controller-heartbeat', 'verdict',
70
+ 'track-prepare-request', 'track-freeze-request', 'track-join-request',
71
+ 'track-teardown-request', 'track-finalize-request',
72
+ ];
69
73
  function isRecord(x) {
70
74
  return typeof x === 'object' && x !== null && !Array.isArray(x);
71
75
  }
@@ -8,12 +8,17 @@ exports.readJournal = readJournal;
8
8
  exports.writeJournal = writeJournal;
9
9
  exports.appendEvent = appendEvent;
10
10
  const fs_1 = __importDefault(require("fs"));
11
+ const crypto_1 = __importDefault(require("crypto"));
11
12
  const atomic_file_1 = require("../atomic-file");
12
13
  const types_1 = require("./types");
13
14
  const paths_1 = require("./paths");
14
- /** Schema 1 evoluciono de forma aditiva durante R1. Normalizamos solamente
15
+ /** Schema 1 evoluciono de forma aditiva durante R1/R5. Normalizamos solamente
15
16
  * campos que antes no existian; evidencia legacy queda deliberadamente con
16
- * fingerprint vacio para que el gate la considere stale, nunca certificada. */
17
+ * fingerprint vacio para que el gate la considere stale, nunca certificada.
18
+ * `tracks`/`trackContext` NO se normalizan por ausencia: son opcionales
19
+ * (solo el journal de un plan-con-tracks o de un track individual los
20
+ * lleva), asi que faltar es una forma legitima, no legacy — no se les
21
+ * inventa un default para un journal que jamas los tuvo (R9.2). */
17
22
  function normalizeSchemaOne(value) {
18
23
  if (typeof value !== 'object' || value === null || Array.isArray(value))
19
24
  return value;
@@ -24,12 +29,39 @@ function normalizeSchemaOne(value) {
24
29
  parsed.requestProblems = [];
25
30
  if (parsed.custodyDecisions === undefined)
26
31
  parsed.custodyDecisions = [];
32
+ // R9.3: journals legacy pre-R5 no tenian journalId. Se materializa
33
+ // DETERMINISTICAMENTE desde (branch, cycle.startedAt) para que dos
34
+ // lecturas del MISMO snapshot legacy nunca produzcan identidades
35
+ // distintas (a diferencia de emptyState, que usa randomUUID solo para
36
+ // journals genuinamente nuevos). Se persiste en la siguiente escritura
37
+ // CAS normal — la lectura por si sola sigue siendo read-only.
38
+ if (parsed.journalId === undefined) {
39
+ const branch = typeof parsed.branch === 'string' ? parsed.branch : '';
40
+ const cycle = typeof parsed.cycle === 'object' && parsed.cycle !== null
41
+ ? parsed.cycle : {};
42
+ const startedAt = typeof cycle.startedAt === 'string' ? cycle.startedAt : '';
43
+ parsed.journalId = `legacy-${crypto_1.default.createHash('sha256')
44
+ .update(`${branch}\0${startedAt}`).digest('hex').slice(0, 32)}`;
45
+ }
27
46
  if (typeof parsed.cycle === 'object' && parsed.cycle !== null && !Array.isArray(parsed.cycle)) {
28
47
  const cycle = parsed.cycle;
29
48
  if (cycle.status === 'IN_PROGRESS' && cycle.nextAction === undefined) {
30
49
  cycle.nextAction = { actionId: 'bootstrap-cycle', type: 'plan-cycle', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' };
31
50
  }
32
51
  }
52
+ // R7 Task 12: `Job.satisfies` migró de `string` a `string[]` (varios items
53
+ // satisfechos por un mismo job, ej. el job canónico de integración final).
54
+ // Un journal legacy con `satisfies` string se normaliza a un array de un
55
+ // elemento — jamás se pierde el enlace ya persistido.
56
+ if (typeof parsed.jobs === 'object' && parsed.jobs !== null && !Array.isArray(parsed.jobs)) {
57
+ for (const job of Object.values(parsed.jobs)) {
58
+ if (typeof job !== 'object' || job === null || Array.isArray(job))
59
+ continue;
60
+ const j = job;
61
+ if (typeof j.satisfies === 'string')
62
+ j.satisfies = [j.satisfies];
63
+ }
64
+ }
33
65
  if (Array.isArray(parsed.verdicts)) {
34
66
  for (const item of parsed.verdicts) {
35
67
  if (typeof item !== 'object' || item === null || Array.isArray(item))
@@ -1,12 +1,17 @@
1
1
  "use strict";
2
2
  // Única fuente de tipos del journal. CONSTITUTION: estados separados, nunca
3
3
  // sobrecargados; shape validation antes de usar campos deserializados.
4
+ var __importDefault = (this && this.__importDefault) || function (mod) {
5
+ return (mod && mod.__esModule) ? mod : { "default": mod };
6
+ };
4
7
  Object.defineProperty(exports, "__esModule", { value: true });
5
8
  exports.GENERATION_STATES = exports.EXECUTION_STATES = void 0;
6
9
  exports.emptyState = emptyState;
7
10
  exports.isWellFormedState = isWellFormedState;
8
11
  exports.isWellFormedProcessRef = isWellFormedProcessRef;
9
12
  exports.isWellFormedJob = isWellFormedJob;
13
+ const crypto_1 = __importDefault(require("crypto"));
14
+ const types_1 = require("../tracks/types");
10
15
  exports.EXECUTION_STATES = [
11
16
  'received', 'spawn-intent', 'claimed', 'running',
12
17
  'exited', 'cancel-requested', 'cancelled', 'orphaned',
@@ -14,9 +19,21 @@ exports.EXECUTION_STATES = [
14
19
  exports.GENERATION_STATES = [
15
20
  'active', 'controller-suspected-stall', 'terminated', 'superseded',
16
21
  ];
22
+ /** Debe permanecer sincronizado con el union type `CohortPhase` de
23
+ * `../tracks/types` — ese módulo no exporta un const array propio (a
24
+ * diferencia de TRACK_PHASES), así que replicamos la lista SOLO para shape
25
+ * validation en el journal (nunca para lógica de protocolo, que vive en
26
+ * tracks/protocol.ts). */
27
+ const COHORT_PHASES = [
28
+ 'PREPARING', 'ACTIVE', 'JOINING', 'FINAL_QA',
29
+ 'FINAL_INTEGRATION', 'FINAL_INTERLOCK', 'COMPLETE',
30
+ 'FALLBACK_PENDING', 'SERIAL', 'BLOCKED',
31
+ ];
32
+ const _cohortPhasesComplete = true;
33
+ const VERIFICATION_KINDS = ['test', 'lint', 'sensors', 'review', 'qa', 'interlock', 'track-integration'];
17
34
  function emptyState(branch) {
18
35
  return {
19
- schema: 1, revision: 0, branch,
36
+ schema: 1, revision: 0, journalId: `j-${crypto_1.default.randomUUID()}`, branch,
20
37
  cycle: {
21
38
  status: 'IN_PROGRESS', startedAt: new Date().toISOString(),
22
39
  nextAction: { actionId: 'bootstrap-cycle', type: 'plan-cycle', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' },
@@ -35,6 +52,8 @@ function isWellFormedState(x) {
35
52
  return false;
36
53
  if (typeof x.revision !== 'number')
37
54
  return false;
55
+ if (typeof x.journalId !== 'string' || x.journalId.length === 0)
56
+ return false;
38
57
  if (typeof x.branch !== 'string')
39
58
  return false;
40
59
  if (!isObj(x.cycle) || !['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(String(x.cycle.status))
@@ -48,7 +67,7 @@ function isWellFormedState(x) {
48
67
  return false;
49
68
  if (!Array.isArray(x.cycleVerificationPlan) || !Array.isArray(x.verdicts) || !Array.isArray(x.fixes))
50
69
  return false;
51
- if (!Array.isArray(x.requiredVerifiers) || !x.requiredVerifiers.every((kind) => ['test', 'lint', 'sensors', 'review', 'qa', 'interlock'].includes(String(kind)))
70
+ if (!Array.isArray(x.requiredVerifiers) || !x.requiredVerifiers.every((kind) => VERIFICATION_KINDS.includes(kind))
52
71
  || !Array.isArray(x.dispatches) || !x.dispatches.every(isWellFormedDispatch))
53
72
  return false;
54
73
  if (!isObj(x.jobs) || !Object.values(x.jobs).every(isWellFormedJob))
@@ -65,6 +84,33 @@ function isWellFormedState(x) {
65
84
  return false;
66
85
  if (!x.verdicts.every(isWellFormedVerdict) || !x.fixes.every(isWellFormedFix))
67
86
  return false;
87
+ if (x.tracks !== undefined && (!Array.isArray(x.tracks) || !x.tracks.every(isWellFormedTrackRef)))
88
+ return false;
89
+ if (x.trackContext !== undefined && !isWellFormedTrackContext(x.trackContext))
90
+ return false;
91
+ if (x.cohortPhase !== undefined && !COHORT_PHASES.includes(String(x.cohortPhase)))
92
+ return false;
93
+ if (x.cohortBaseSha !== undefined && typeof x.cohortBaseSha !== 'string')
94
+ return false;
95
+ if (x.cohortPlanHeadSha !== undefined && typeof x.cohortPlanHeadSha !== 'string')
96
+ return false;
97
+ if (x.cohortFallbackReason !== undefined && typeof x.cohortFallbackReason !== 'string')
98
+ return false;
99
+ if (x.trackIntegration !== undefined && !isWellFormedTrackIntegration(x.trackIntegration))
100
+ return false;
101
+ if (x.freezeRequested !== undefined && typeof x.freezeRequested !== 'boolean')
102
+ return false;
103
+ if (x.frozen !== undefined && !(isObj(x.frozen) && typeof x.frozen.headSha === 'string' && typeof x.frozen.at === 'string'))
104
+ return false;
105
+ if (x.cohortParallelInvalidatedBy !== undefined && !strings(x.cohortParallelInvalidatedBy))
106
+ return false;
107
+ if (x.globalQaHeadSha !== undefined && typeof x.globalQaHeadSha !== 'string')
108
+ return false;
109
+ if (x.finalIntegrationJobId !== undefined && typeof x.finalIntegrationJobId !== 'string')
110
+ return false;
111
+ if (x.qaFinalizeRequested !== undefined
112
+ && !(isObj(x.qaFinalizeRequested) && typeof x.qaFinalizeRequested.headSha === 'string' && typeof x.qaFinalizeRequested.at === 'string'))
113
+ return false;
68
114
  return true;
69
115
  }
70
116
  function isWellFormedNextAction(x) {
@@ -80,7 +126,7 @@ function strings(x) {
80
126
  }
81
127
  function isWellFormedVerificationItem(x) {
82
128
  return isObj(x) && typeof x.id === 'string'
83
- && ['test', 'lint', 'sensors', 'review', 'qa', 'interlock'].includes(String(x.kind))
129
+ && VERIFICATION_KINDS.includes(x.kind)
84
130
  && (x.satisfiedBy === undefined || typeof x.satisfiedBy === 'string');
85
131
  }
86
132
  function isWellFormedReviewObligation(x) {
@@ -159,7 +205,50 @@ function isWellFormedJob(x) {
159
205
  && (x.logPath === undefined || typeof x.logPath === 'string')
160
206
  && (x.result === undefined || (isObj(x.result) && typeof x.result.exitCode === 'number'
161
207
  && typeof x.result.endedAt === 'string' && typeof x.result.resultPath === 'string'))
162
- && (x.satisfies === undefined || typeof x.satisfies === 'string')
208
+ && (x.satisfies === undefined || strings(x.satisfies))
163
209
  && (x.attemptOf === undefined || typeof x.attemptOf === 'string')
164
210
  && exports.EXECUTION_STATES.includes(x.executionState);
165
211
  }
212
+ function isWellFormedTrackContext(x) {
213
+ return isObj(x) && typeof x.trackId === 'string' && x.trackId.length > 0
214
+ && strings(x.taskIds)
215
+ && typeof x.planDigest === 'string'
216
+ && typeof x.baseSha === 'string'
217
+ && typeof x.planJournalId === 'string' && x.planJournalId.length > 0;
218
+ }
219
+ function isWellFormedSupervisorIntent(x) {
220
+ return isObj(x) && typeof x.nonce === 'string' && x.nonce.length > 0
221
+ && strings(x.argv) && typeof x.claimPath === 'string';
222
+ }
223
+ function isWellFormedJoinIntent(x) {
224
+ return isObj(x) && typeof x.expectedPlanHeadSha === 'string' && typeof x.expectedTrackHeadSha === 'string'
225
+ && x.strategy === types_1.JOIN_STRATEGY_NO_FF;
226
+ }
227
+ function isWellFormedTeardownIntent(x) {
228
+ return isObj(x) && typeof x.worktreePath === 'string' && typeof x.branch === 'string'
229
+ && (x.supervisorNonce === undefined || typeof x.supervisorNonce === 'string');
230
+ }
231
+ /** Shape completa (R9.2/R9.7): fencingToken y readinessNonce nunca vacios —
232
+ * igual criterio que ProcessRef, la identidad es todo-o-nada. */
233
+ function isWellFormedTrackRef(x) {
234
+ if (!isObj(x))
235
+ return false;
236
+ return typeof x.trackId === 'string' && x.trackId.length > 0
237
+ && typeof x.worktreePath === 'string' && x.worktreePath.length > 0
238
+ && typeof x.branch === 'string' && x.branch.length > 0
239
+ && strings(x.ownership) && strings(x.sharedResources) && strings(x.dependsOn)
240
+ && typeof x.fencingToken === 'string' && x.fencingToken.length > 0
241
+ && types_1.TRACK_PHASES.includes(x.phase)
242
+ && typeof x.readinessNonce === 'string' && x.readinessNonce.length > 0
243
+ && (x.readinessAt === undefined || typeof x.readinessAt === 'string')
244
+ && (x.frozenHeadSha === undefined || typeof x.frozenHeadSha === 'string')
245
+ && (x.supervisorIntent === undefined || isWellFormedSupervisorIntent(x.supervisorIntent))
246
+ && (x.supervisorProcessRef === undefined || isWellFormedProcessRef(x.supervisorProcessRef))
247
+ && (x.joinIntent === undefined || isWellFormedJoinIntent(x.joinIntent))
248
+ && (x.teardownIntent === undefined || isWellFormedTeardownIntent(x.teardownIntent))
249
+ && (x.joinedCommitSha === undefined || typeof x.joinedCommitSha === 'string')
250
+ && (x.blockedReason === undefined || typeof x.blockedReason === 'string');
251
+ }
252
+ function isWellFormedTrackIntegration(x) {
253
+ return isObj(x) && strings(x.argv) && strings(x.paths) && typeof x.planDigest === 'string';
254
+ }
@@ -12,6 +12,7 @@ exports.isWindowsNative = isWindowsNative;
12
12
  exports.platformLabel = platformLabel;
13
13
  exports.noteWindowsCaveat = noteWindowsCaveat;
14
14
  exports.resolveOnPath = resolveOnPath;
15
+ exports.sameExistingPath = sameExistingPath;
15
16
  // cli/src/core/paths.ts
16
17
  //
17
18
  // Single source of truth for home / AWM_HOME resolution and platform detection.
@@ -158,3 +159,53 @@ function resolveOnPath(bin) {
158
159
  const entries = (process.env.PATH ?? '').split(path_1.default.delimiter).filter(Boolean);
159
160
  return entries.some((dir) => matches(path_1.default.join(dir, bin)));
160
161
  }
162
+ /**
163
+ * ¿Dos rutas nombran el MISMO directorio/archivo existente?
164
+ *
165
+ * Comparar `fs.realpathSync(a) !== fs.realpathSync(b)` como strings parece obvio y es
166
+ * incorrecto en Windows: el mismo directorio tiene más de una grafía legítima —
167
+ * `C:\Users\RUNNER~1\AppData\...` (nombre corto 8.3, lo que devuelve `os.tmpdir()`) y
168
+ * `C:/Users/runneradmin/AppData/...` (lo que devuelve `git worktree list`, con separadores
169
+ * POSIX) — y `realpathSync` NO reconcilia las dos. En CI eso hacía que
170
+ * `ownedWorktreeExists` respondiera "este worktree no es mío" sobre un worktree propio.
171
+ *
172
+ * Falla cerrado, así que no corrompía nada — pero dejaba la adopción tras crash inservible
173
+ * en Windows: el teardown no podía probar propiedad, los tracks quedaban `BLOCKED`, y la
174
+ * cohorte no llegaba nunca ni a `SERIAL` ni a `ACTIVE`. Un solo bug de comparación, quince
175
+ * tests en cascada.
176
+ *
177
+ * La identidad se toma del filesystem — `(dev, ino)` — que es agnóstica a separadores, a
178
+ * mayúsculas y a nombres cortos. Cuando el volumen no expone inode (`ino === 0`, posible en
179
+ * algunos filesystems de Windows) se cae a `realpathSync.native` — que sí resuelve 8.3 vía
180
+ * la API del SO — normalizando separadores y capitalización.
181
+ *
182
+ * Cualquier fallo de `stat` significa que al menos una no existe: `false`. La identidad
183
+ * nunca se afirma sin prueba.
184
+ */
185
+ function sameExistingPath(a, b) {
186
+ let sa;
187
+ let sb;
188
+ try {
189
+ sa = fs_1.default.statSync(a);
190
+ sb = fs_1.default.statSync(b);
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ // `ino === 0` en AMBOS lados no prueba identidad: probaría que el filesystem no la
196
+ // reporta. Tratarlo como match haría coincidir dos rutas cualesquiera del mismo volumen.
197
+ if (sa.ino !== 0 && sb.ino !== 0)
198
+ return sa.dev === sb.dev && sa.ino === sb.ino;
199
+ const canonical = (p) => {
200
+ try {
201
+ const real = fs_1.default.realpathSync.native(p).replaceAll('\\', '/');
202
+ return isWindowsNative() ? real.toLowerCase() : real;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ };
208
+ const ca = canonical(a);
209
+ const cb = canonical(b);
210
+ return ca !== null && ca === cb;
211
+ }
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.deriveDefaultParallelism = deriveDefaultParallelism;
7
+ exports.parseMaxParallel = parseMaxParallel;
8
+ exports.scheduleTracks = scheduleTracks;
9
+ exports.loadDefaultParallelism = loadDefaultParallelism;
10
+ // R10.2/R10.3: tope de paralelismo derivado de una medición real (fingerprint
11
+ // budget, ver docs/research/r5/benchmark-fingerprint.mjs), nunca de un número
12
+ // escrito a mano. Funciones puras/testeables — sin I/O aquí salvo
13
+ // loadDefaultParallelism, que es un loader delgado sobre el artefacto
14
+ // empaquetado y cae a serial (1) si no lo puede resolver (nunca crashea `awm watch`).
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const path_1 = __importDefault(require("path"));
17
+ /** R10.3: el mayor N de supervisores concurrentes cuyo p95 medido no supera
18
+ * 1.5x el baseline serial (N=1) ni el 20% del tick — nunca N=1 si ninguna
19
+ * medición lo habilita (R10.2, fail-closed hacia serial). */
20
+ function deriveDefaultParallelism(budget) {
21
+ if (!Number.isInteger(budget.cpuCount) || budget.cpuCount < 1 || budget.samples.length === 0)
22
+ throw new Error('budget inválido');
23
+ const baseline = budget.samples.find((s) => s.supervisors === 1);
24
+ if (baseline === undefined || baseline.p95Ms <= 0)
25
+ throw new Error('budget sin baseline N=1');
26
+ return budget.samples
27
+ .filter((s) => Number.isInteger(s.supervisors) && s.supervisors <= budget.cpuCount
28
+ && s.p95Ms <= baseline.p95Ms * 1.5 && s.p95Ms <= budget.tickMs * 0.2)
29
+ .reduce((max, s) => Math.max(max, s.supervisors), 1);
30
+ }
31
+ /** Parsea `--max-parallel <n>`, mismo estilo de validación que `minutes()` en
32
+ * watch/index.ts: entero >= 1, fail-closed en cualquier otro caso. */
33
+ function parseMaxParallel(raw) {
34
+ const n = Number(raw);
35
+ if (!Number.isInteger(n) || n < 1)
36
+ throw new Error('--max-parallel requiere un entero >= 1');
37
+ return n;
38
+ }
39
+ /** Todos los tracks alcanzan ARMED; solo hasta `maxParallel` (menos los ya
40
+ * activos) pasan a arrancar. El resto queda `waiting` — el wrapper excedente
41
+ * espera sin arrancar su loop de watch/fingerprint hasta que un slot libere. */
42
+ function scheduleTracks(tracks, active, maxParallel) {
43
+ if (!Number.isInteger(maxParallel) || maxParallel < 1)
44
+ throw new Error('maxParallel debe ser entero >= 1');
45
+ const capacity = Math.max(0, maxParallel - active.size);
46
+ const candidates = tracks.filter((t) => !active.has(t));
47
+ return { start: candidates.slice(0, capacity), waiting: candidates.slice(capacity) };
48
+ }
49
+ /** Busca `docs/research/r5/fingerprint-budget.json` subiendo desde `startDir`
50
+ * (por defecto este archivo), sin asumir una profundidad fija de directorios.
51
+ * `startDir` es parametrizable solo para poder testear los caminos de fallo
52
+ * de `loadDefaultParallelism` con un árbol de directorios controlado —
53
+ * ver docs/research/r5/benchmark-fingerprint.mjs para el estado real de
54
+ * distribución de este artefacto (hoy NO viaja en el npm publish). */
55
+ function findBudgetArtifact(startDir) {
56
+ let dir = startDir;
57
+ for (let i = 0; i < 8; i += 1) {
58
+ const candidate = path_1.default.join(dir, 'docs', 'research', 'r5', 'fingerprint-budget.json');
59
+ if (fs_1.default.existsSync(candidate))
60
+ return candidate;
61
+ const parent = path_1.default.dirname(dir);
62
+ if (parent === dir)
63
+ break;
64
+ dir = parent;
65
+ }
66
+ return null;
67
+ }
68
+ /** `derivedDefault` del artefacto, si se puede resolver y es válido. Si el
69
+ * artefacto no resuelve, tiene JSON corrupto, o `derivedDefault` falta / no
70
+ * es entero / es < 1, cae a serial (1) — nunca crashea `awm watch` por esto.
71
+ * `startDir` default = este archivo; solo se sobreescribe en tests. */
72
+ function loadDefaultParallelism(startDir = __dirname) {
73
+ const artifactPath = findBudgetArtifact(startDir);
74
+ if (artifactPath === null)
75
+ return 1;
76
+ try {
77
+ const data = JSON.parse(fs_1.default.readFileSync(artifactPath, 'utf8'));
78
+ if (Number.isInteger(data.derivedDefault) && data.derivedDefault >= 1)
79
+ return data.derivedDefault;
80
+ return 1;
81
+ }
82
+ catch {
83
+ return 1;
84
+ }
85
+ }