agentic-workflow-manager 6.2.0 → 6.3.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 (75) 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/diagnostics/provider-checks.js +12 -9
  16. package/dist/src/core/journal/adapter.js +46 -6
  17. package/dist/src/core/journal/paths.js +9 -0
  18. package/dist/src/core/journal/process.js +101 -1
  19. package/dist/src/core/journal/requests.js +5 -1
  20. package/dist/src/core/journal/store.js +34 -2
  21. package/dist/src/core/journal/types.js +93 -4
  22. package/dist/src/core/paths.js +51 -0
  23. package/dist/src/core/profile.js +15 -1
  24. package/dist/src/core/tracks/concurrency.js +85 -0
  25. package/dist/src/core/tracks/context.js +89 -0
  26. package/dist/src/core/tracks/descriptor.js +40 -0
  27. package/dist/src/core/tracks/git.js +318 -0
  28. package/dist/src/core/tracks/join.js +185 -0
  29. package/dist/src/core/tracks/ownership.js +100 -0
  30. package/dist/src/core/tracks/plan-parser.js +108 -0
  31. package/dist/src/core/tracks/protocol.js +466 -0
  32. package/dist/src/core/tracks/teardown.js +34 -0
  33. package/dist/src/core/tracks/types.js +14 -0
  34. package/dist/src/core/update-check.js +5 -1
  35. package/dist/src/index.js +2 -0
  36. package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
  37. package/dist/tests/commands/track/fixtures.js +13 -0
  38. package/dist/tests/commands/track/status.test.js +157 -0
  39. package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
  40. package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
  41. package/dist/tests/commands/track/verbs.test.js +326 -0
  42. package/dist/tests/commands/watch/apply.test.js +116 -4
  43. package/dist/tests/commands/watch/runner.test.js +22 -0
  44. package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
  45. package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
  46. package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
  47. package/dist/tests/commands/watch/track-finalize.test.js +643 -0
  48. package/dist/tests/commands/watch/track-freeze.test.js +591 -0
  49. package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
  50. package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
  51. package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
  52. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  53. package/dist/tests/core/journal/adapter-override.test.js +51 -0
  54. package/dist/tests/core/journal/process.test.js +37 -0
  55. package/dist/tests/core/journal/requests.test.js +21 -0
  56. package/dist/tests/core/journal/store.test.js +28 -0
  57. package/dist/tests/core/journal/types.test.js +89 -0
  58. package/dist/tests/core/profile.test.js +9 -2
  59. package/dist/tests/core/same-existing-path.test.js +48 -0
  60. package/dist/tests/core/tracks/concurrency.test.js +134 -0
  61. package/dist/tests/core/tracks/context.test.js +177 -0
  62. package/dist/tests/core/tracks/descriptor.test.js +76 -0
  63. package/dist/tests/core/tracks/git.test.js +132 -0
  64. package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
  65. package/dist/tests/core/tracks/join.test.js +197 -0
  66. package/dist/tests/core/tracks/ownership.test.js +96 -0
  67. package/dist/tests/core/tracks/plan-parser.test.js +94 -0
  68. package/dist/tests/core/tracks/protocol.test.js +416 -0
  69. package/dist/tests/core/tracks/teardown.test.js +62 -0
  70. package/dist/tests/core/update-check.test.js +34 -3
  71. package/dist/tests/helpers/git-fixture.js +35 -0
  72. package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
  73. package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
  74. package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
  75. package/package.json +1 -1
@@ -0,0 +1,416 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const protocol_1 = require("../../../src/core/tracks/protocol");
4
+ const ids = ['cli', 'docs'];
5
+ describe('parallel-track protocol model', () => {
6
+ test('no activa parcialmente: todos deben estar ARMED (R4.4, C1)', () => {
7
+ let s = (0, protocol_1.initialCohort)('journal-1', ids);
8
+ s.tracks.cli.phase = 'ARMED';
9
+ expect((0, protocol_1.nextProtocolEffect)(s)).not.toEqual({ kind: 'activate-cohort' });
10
+ s.tracks.docs.phase = 'ARMED';
11
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({ kind: 'activate-cohort' });
12
+ });
13
+ test('un fallo pre-ACTIVE entra a teardown y solo luego a serial (R4.5, C2)', () => {
14
+ let s = (0, protocol_1.initialCohort)('journal-1', ids);
15
+ s.tracks.cli.phase = 'WORKTREE_CREATED';
16
+ s.tracks.docs.phase = 'WORKTREE_CREATED';
17
+ s = (0, protocol_1.reconcileProtocol)(s, { kind: 'prepare-failed', trackId: 'docs', detail: 'disk full' });
18
+ expect(s.cohortPhase).toBe('FALLBACK_PENDING');
19
+ expect(s.tracks.docs.phase).toBe('TEARDOWN_REQUESTED'); // no BLOCKED: el fallo es demostrable
20
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({ kind: 'begin-teardown', trackId: 'cli' });
21
+ s.tracks.cli.phase = 'REMOVED';
22
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({ kind: 'begin-teardown', trackId: 'docs' });
23
+ s.tracks.docs.phase = 'REMOVED';
24
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({ kind: 'enter-serial', reason: 'prepare-failed:docs' });
25
+ });
26
+ test('un track BLOCKED nunca habilita serial (R4.6, C2)', () => {
27
+ let s = (0, protocol_1.initialCohort)('journal-1', ids);
28
+ s.tracks.cli.phase = 'REMOVED';
29
+ s.tracks.docs.phase = 'BLOCKED';
30
+ s.cohortPhase = 'FALLBACK_PENDING';
31
+ s.fallbackReason = 'prepare-failed:cli';
32
+ // Propiedad indemostrable: la cohorte se detiene, no serializa con recursos vivos.
33
+ expect((0, protocol_1.nextProtocolEffect)(s)).toBeNull();
34
+ expect(() => (0, protocol_1.observeProtocolEffect)(s, { kind: 'enter-serial', reason: 'x' }, { kind: 'effect-applied', effect: { kind: 'enter-serial', reason: 'x' } }))
35
+ .toThrow(/SERIAL prohíbe recursos paralelos activos/);
36
+ });
37
+ test('ningún merge empieza antes de que todos los tracks estén FROZEN (C3)', () => {
38
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
39
+ s.cohortPhase = 'JOINING';
40
+ s.planHeadSha = 'plan-head';
41
+ s.tracks.cli.phase = 'FROZEN';
42
+ s.tracks.cli.frozenHeadSha = 'cli-head';
43
+ s.tracks.docs.phase = 'ACTIVE';
44
+ expect((0, protocol_1.nextProtocolEffect)(s)).toBeNull(); // docs todavía trabaja: no se persiste join intent
45
+ s.tracks.docs.phase = 'JOIN_REQUESTED';
46
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({ kind: 'freeze-track', trackId: 'docs' });
47
+ });
48
+ test('los joins son serie estricta: un solo JOIN_INTENT vivo (R6.3, C7)', () => {
49
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
50
+ s.cohortPhase = 'JOINING';
51
+ s.planHeadSha = 'plan-head';
52
+ for (const t of Object.values(s.tracks)) {
53
+ t.phase = 'FROZEN';
54
+ t.frozenHeadSha = `${t.trackId}-head`;
55
+ }
56
+ const first = (0, protocol_1.nextProtocolEffect)(s);
57
+ expect(first).toEqual({
58
+ kind: 'persist-join-intent', trackId: 'cli',
59
+ expectedPlanHeadSha: 'plan-head', expectedTrackHeadSha: 'cli-head',
60
+ });
61
+ const armed = (0, protocol_1.observeProtocolEffect)(s, first, { kind: 'effect-applied', effect: first });
62
+ expect((0, protocol_1.nextProtocolEffect)(armed)).toMatchObject({ kind: 'merge-track', trackId: 'cli' });
63
+ expect(() => (0, protocol_1.assertProtocolInvariants)({
64
+ ...armed, tracks: { ...armed.tracks, docs: { ...armed.tracks.docs, phase: 'JOIN_INTENT' } },
65
+ })).toThrow(/un solo JOIN_INTENT/);
66
+ });
67
+ test('el orden final no produce evidencia antes del HEAD final (R7.7, C3)', () => {
68
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
69
+ s.cohortPhase = 'JOINING';
70
+ s.planHeadSha = 'plan-head';
71
+ s.tracks.cli.phase = 'MERGED_UNVERIFIED';
72
+ s.tracks.cli.frozenHeadSha = 'cli-head';
73
+ s.tracks.docs.phase = 'FROZEN';
74
+ s.tracks.docs.frozenHeadSha = 'docs-head';
75
+ expect((0, protocol_1.nextProtocolEffect)(s)).not.toMatchObject({ kind: 'request-global-qa' });
76
+ s.tracks.docs.phase = 'MERGED_UNVERIFIED';
77
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({ kind: 'request-global-qa' });
78
+ });
79
+ test('una observación indemostrable bloquea sin efecto destructivo (R6.9, C11)', () => {
80
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
81
+ s.cohortPhase = 'JOINING';
82
+ s.planHeadSha = 'plan-head';
83
+ for (const t of Object.values(s.tracks)) {
84
+ t.phase = 'FROZEN';
85
+ t.frozenHeadSha = `${t.trackId}-head`;
86
+ }
87
+ s.tracks.cli.phase = 'JOIN_INTENT';
88
+ s.tracks.cli.expectedPlanHeadSha = 'plan-head';
89
+ s.tracks.cli.expectedTrackHeadSha = 'cli-head';
90
+ const out = (0, protocol_1.reconcileProtocol)(s, { kind: 'join-observation', trackId: 'cli', mergeHead: 'other', planHead: 'other' });
91
+ expect(out.tracks.cli.phase).toBe('BLOCKED');
92
+ expect(out.cohortPhase).toBe('BLOCKED');
93
+ expect((0, protocol_1.nextProtocolEffect)(out)).toBeNull();
94
+ });
95
+ test('ningún estado alcanzable viola invariantes al crashear entre fronteras (R4.2, R6.8, C9, C11)', () => {
96
+ const { states } = exploreCohort(ids);
97
+ for (const state of states)
98
+ expect(() => (0, protocol_1.assertProtocolInvariants)(state)).not.toThrow();
99
+ expect(states.length).toBeGreaterThan(50);
100
+ });
101
+ test('SERIAL solo es alcanzable con la cohorte desmantelada (R4.5, C2)', () => {
102
+ const serial = exploreCohort(ids).states.filter((s) => s.cohortPhase === 'SERIAL');
103
+ expect(serial.length).toBeGreaterThan(0);
104
+ for (const s of serial) {
105
+ expect(Object.values(s.tracks).every((t) => ['REMOVED', 'DECLARED'].includes(t.phase))).toBe(true);
106
+ }
107
+ });
108
+ test('Step 6: el recorrido cubre las 14 fronteras', () => {
109
+ expect([...exploreCohort(ids).effects].sort()).toEqual([
110
+ 'activate-cohort', 'activate-track', 'begin-teardown', 'create-track-journal', 'create-worktree',
111
+ 'enter-serial', 'freeze-track', 'merge-track', 'persist-join-intent',
112
+ 'persist-prepare-intent', 'request-final-integration', 'request-global-qa',
113
+ 'run-final-interlock', 'spawn-track-supervisor',
114
+ ].sort());
115
+ });
116
+ test('Step 3 (Task 9): el recorrido también ejercita decidePrepare, no solo nextProtocolEffect', () => {
117
+ // 'begin-fallback' queda deliberadamente afuera: es el fail-closed de
118
+ // una fase que `nextProtocolEffect` nunca selecciona para un efecto de
119
+ // PREPARING (ver el comentario de `decidePrepare`) — por diseño, la
120
+ // exploración (que solo llama `decidePrepare` con la fase que
121
+ // `nextProtocolEffect` ya eligió) jamás la alcanza; la matriz de
122
+ // arriba ('decidePrepare — Task 9') sí la cubre directamente.
123
+ expect([...exploreCohort(ids).prepareDecisions].sort()).toEqual([
124
+ 'accept-readiness', 'accept-worktree', 'block-foreign',
125
+ 'retry-supervisor-same-intent', 'retry-worktree', 'write-descriptor',
126
+ ].sort());
127
+ });
128
+ test('Step 5 (Task 13): el recorrido también ejercita decideTeardown con las 7 decisiones posibles', () => {
129
+ // Mismo criterio que el test análogo de `prepareDecisions` (Step 3,
130
+ // Task 9): sin esta aserción, una regresión que dejara de alcanzar
131
+ // alguna `TeardownDecision` durante la exploración pasaría
132
+ // desapercibida, aunque el set se siguiera recolectando.
133
+ expect([...exploreCohort(ids).teardownDecisions].sort()).toEqual([
134
+ 'accept-supervisor-stopped', 'block-foreign', 'mark-removed', 'persist-intent',
135
+ 'remove-owned-branch', 'remove-owned-worktree', 'stop-own-supervisor',
136
+ ].sort());
137
+ });
138
+ describe('decidePrepare — Task 9 (R4.2, R4.6, C11): matriz observable de recuperación de crash', () => {
139
+ const track = (phase) => ({
140
+ trackId: 'a', phase, fencingToken: 'f'.repeat(32), readinessNonce: 'r'.repeat(32),
141
+ });
142
+ const cases = [
143
+ // PREPARE_INTENT (create-worktree): ownedWorktreeExists manda por
144
+ // sobre "no vacío" — un destino nuestro nunca se re-crea ni bloquea.
145
+ ['PREPARE_INTENT', {}, 'retry-worktree'],
146
+ ['PREPARE_INTENT', { worktreeOwned: true }, 'accept-worktree'],
147
+ ['PREPARE_INTENT', { worktreeOwned: true, worktreeForeignNonEmpty: true }, 'accept-worktree'],
148
+ ['PREPARE_INTENT', { worktreeForeignNonEmpty: true }, 'block-foreign'],
149
+ ['PREPARE_INTENT', { worktreeOwned: false, worktreeForeignNonEmpty: false }, 'retry-worktree'],
150
+ // WORKTREE_CREATED (create-track-journal): `initTrackJournal` ya es
151
+ // idempotente por construcción (initJournal no pisa un state.json
152
+ // existente, writeDescriptor sobreescribe igual siempre) — una
153
+ // sola decisión alcanza, sin necesitar distinguir "primera vez" de
154
+ // "reintento tras crash".
155
+ ['WORKTREE_CREATED', {}, 'write-descriptor'],
156
+ // SUPERVISOR_STARTING (spawn-track-supervisor, con supervisorIntent
157
+ // ya persistido): 'absent' es el ÚNICO caso seguro para
158
+ // (re)spawnear — cualquier otra cosa es evidencia real de un
159
+ // intento previo (mismo intent) que jamás se debe duplicar.
160
+ ['SUPERVISOR_STARTING', { supervisorArtifact: 'absent' }, 'retry-supervisor-same-intent'],
161
+ ['SUPERVISOR_STARTING', {}, 'retry-supervisor-same-intent'],
162
+ ['SUPERVISOR_STARTING', { supervisorArtifact: 'claimed' }, 'accept-readiness'],
163
+ ['SUPERVISOR_STARTING', { supervisorArtifact: 'ready' }, 'accept-readiness'],
164
+ ['SUPERVISOR_STARTING', { supervisorArtifact: 'foreign' }, 'block-foreign'],
165
+ // Fase inesperada para un efecto de PREPARING: fail-closed a fallback.
166
+ ['ACTIVE', {}, 'begin-fallback'],
167
+ ];
168
+ test.each(cases)('fase %s + observación %j -> %s', (phase, observed, expected) => {
169
+ expect((0, protocol_1.decidePrepare)(track(phase), observed)).toBe(expected);
170
+ });
171
+ test('la matriz cubre las 7 decisiones posibles', () => {
172
+ const seen = new Set(cases.map(([, , decision]) => decision));
173
+ expect([...seen].sort()).toEqual([
174
+ 'accept-readiness', 'accept-worktree', 'begin-fallback', 'block-foreign',
175
+ 'retry-supervisor-same-intent', 'retry-worktree', 'write-descriptor',
176
+ ].sort());
177
+ });
178
+ });
179
+ });
180
+ /** Recorrido acotado del espacio de estados con crash inyectado en cada frontera. */
181
+ function exploreCohort(ids) {
182
+ const queue = [{ ...(0, protocol_1.initialCohort)('journal-1', ids), planHeadSha: 'plan-head' }];
183
+ const seen = new Map();
184
+ const effects = new Set();
185
+ const prepareDecisions = new Set();
186
+ const teardownDecisions = new Set();
187
+ while (queue.length > 0 && seen.size < 20000) {
188
+ const state = queue.shift();
189
+ const key = JSON.stringify(state);
190
+ if (seen.has(key))
191
+ continue;
192
+ seen.set(key, state);
193
+ const effect = (0, protocol_1.nextProtocolEffect)(state);
194
+ if (effect === null) {
195
+ // Quiescencia: solo avanza si llega un request externo (un track pide unirse).
196
+ for (const observation of externalObservationsFor(state)) {
197
+ queue.push((0, protocol_1.reconcileProtocol)(structuredClone(state), observation));
198
+ }
199
+ continue;
200
+ }
201
+ effects.add(effect.kind);
202
+ for (const observation of observationsFor(effect, state, prepareDecisions, teardownDecisions)) {
203
+ queue.push((0, protocol_1.observeProtocolEffect)(structuredClone(state), effect, observation));
204
+ queue.push((0, protocol_1.reconcileProtocol)(structuredClone(state), observation)); // crash antes de result
205
+ }
206
+ }
207
+ return { states: [...seen.values()], effects, prepareDecisions, teardownDecisions };
208
+ }
209
+ /** Requests que nacen fuera del reducer y por eso no son consecuencia de ningún effect. */
210
+ function externalObservationsFor(state) {
211
+ return Object.values(state.tracks)
212
+ .filter((t) => t.phase === 'ACTIVE')
213
+ .map((t) => ({ kind: 'join-requested', trackId: t.trackId }));
214
+ }
215
+ /** Los 7 valores que `decidePrepare` puede devolver (mismo catálogo que
216
+ * `PrepareDecision`) — usado para que la exploración denuncie si algún día
217
+ * devuelve algo fuera de este conjunto. */
218
+ const VALID_PREPARE_DECISIONS = [
219
+ 'retry-worktree', 'accept-worktree', 'write-descriptor',
220
+ 'retry-supervisor-same-intent', 'accept-readiness', 'block-foreign', 'begin-fallback',
221
+ ];
222
+ /** Variantes de `PrepareObservation` que un driver real reuniría para
223
+ * `create-worktree` (fase PREPARE_INTENT) — mismo catálogo que la matriz
224
+ * `decidePrepare — Task 9` de más abajo. */
225
+ const PREPARE_OBSERVATIONS_FOR_WORKTREE = [
226
+ {}, { worktreeOwned: true }, { worktreeOwned: true, worktreeForeignNonEmpty: true },
227
+ { worktreeForeignNonEmpty: true }, { worktreeOwned: false, worktreeForeignNonEmpty: false },
228
+ ];
229
+ /** Las 7 decisiones que `decideTeardown` puede devolver (Task 13) — usado
230
+ * para que la exploración denuncie si algún día devuelve algo fuera de este
231
+ * conjunto, mismo criterio que `VALID_PREPARE_DECISIONS`. */
232
+ const VALID_TEARDOWN_DECISIONS = [
233
+ 'persist-intent', 'stop-own-supervisor', 'accept-supervisor-stopped',
234
+ 'remove-owned-worktree', 'remove-owned-branch', 'mark-removed', 'block-foreign',
235
+ ];
236
+ /** Variantes de `TeardownObservation` relevantes a CADA fase de teardown —
237
+ * mismo criterio que `PREPARE_OBSERVATIONS_FOR_WORKTREE`/`_SUPERVISOR`:
238
+ * cubre lo que `gatherTeardownObservation` (`tracks.ts`) realmente puede
239
+ * reunir en esa fase, más las variantes "ajeno" defensivas que
240
+ * `decideTeardown` también contempla (Step 1 del plan T13). */
241
+ const TEARDOWN_OBSERVATIONS_BY_PHASE = {
242
+ TEARDOWN_REQUESTED: [{}],
243
+ TEARDOWN_INTENT: [{}, { ownSupervisorAlive: true }, { ownSupervisorAlive: false }, { foreignSupervisor: true }],
244
+ SUPERVISOR_STOPPED: [{}, { ownedWorktreeExists: true }, { ownedWorktreeExists: false }, { foreignWorktree: true }],
245
+ WORKTREE_REMOVED: [{}, { ownedBranchExists: true }, { ownedBranchExists: false }],
246
+ BRANCH_REMOVED: [{}],
247
+ };
248
+ /** Idem para `spawn-track-supervisor` (fase SUPERVISOR_STARTING). */
249
+ const PREPARE_OBSERVATIONS_FOR_SUPERVISOR = [
250
+ {}, { supervisorArtifact: 'absent' }, { supervisorArtifact: 'claimed' },
251
+ { supervisorArtifact: 'ready' }, { supervisorArtifact: 'foreign' },
252
+ ];
253
+ /**
254
+ * Task 9 (Step 3 del plan): llama a `decidePrepare` — la MISMA autoridad que
255
+ * usa `tracks.ts` — para cada `PrepareObservation` relevante a la fase que
256
+ * `nextProtocolEffect` ya eligió, y traduce cada decisión a la
257
+ * `ProtocolObservation` que un driver real produciría con ella (mismo mapeo,
258
+ * letra por letra, que `runCreateWorktree`/`runSpawnTrackSupervisor` en
259
+ * `tracks.ts`). Sin esto, `nextProtocolEffect` (qué efecto sigue) y
260
+ * `decidePrepare` (qué hacer con ESE efecto) podían divergir en silencio —
261
+ * ningún test lo hubiera visto porque la exploración nunca invocaba
262
+ * `decidePrepare`. Las observaciones devueltas se mezclan con las de
263
+ * `observationsFor` y entran al mismo BFS: si alguna hiciera que
264
+ * `reconcileProtocol`/`observeProtocolEffect` tiraran, o llevara a un estado
265
+ * que viola invariantes, el test de exploración ya existente lo denuncia.
266
+ */
267
+ function decidePrepareObservationsFor(effect, state, prepareDecisions) {
268
+ if (effect.kind !== 'create-worktree' && effect.kind !== 'create-track-journal' && effect.kind !== 'spawn-track-supervisor') {
269
+ return [];
270
+ }
271
+ const track = state.tracks[effect.trackId];
272
+ // `spawn-track-supervisor` cubre DOS fases (JOURNAL_CREATED |
273
+ // SUPERVISOR_STARTING, ver `nextProtocolEffect`), pero `tracks.ts`
274
+ // (`runSpawnTrackSupervisor`) solo invoca `decidePrepare` en la SEGUNDA
275
+ // — la primera vez (JOURNAL_CREATED, `ref.supervisorIntent === undefined`)
276
+ // persiste el intent y retorna ANTES de siquiera considerar `decidePrepare`,
277
+ // porque el protocolo puro no distingue "intent ya persistido" (eso vive
278
+ // solo en `TrackRef.supervisorIntent`, un dato del journal — regla de
279
+ // capas de T1, `protocol.ts` no lo conoce). Llamar `decidePrepare` acá con
280
+ // JOURNAL_CREATED probaría un caso que el driver real NUNCA ejercita
281
+ // (decidePrepare cae a 'begin-fallback' porque no tiene rama para esa
282
+ // fase) — se salta, fiel al comportamiento real, no a lo que `protocol.ts`
283
+ // podría en teoría hacer con ella.
284
+ if (effect.kind === 'spawn-track-supervisor' && track.phase !== 'SUPERVISOR_STARTING')
285
+ return [];
286
+ const variants = effect.kind === 'create-worktree' ? PREPARE_OBSERVATIONS_FOR_WORKTREE
287
+ : effect.kind === 'spawn-track-supervisor' ? PREPARE_OBSERVATIONS_FOR_SUPERVISOR
288
+ : [{}];
289
+ const out = [];
290
+ for (const observed of variants) {
291
+ const decision = (0, protocol_1.decidePrepare)(track, observed);
292
+ expect(VALID_PREPARE_DECISIONS).toContain(decision);
293
+ prepareDecisions.add(decision);
294
+ if (effect.kind === 'create-worktree') {
295
+ if (decision === 'accept-worktree')
296
+ out.push({ kind: 'worktree-observed', trackId: effect.trackId, owned: true });
297
+ else if (decision === 'block-foreign')
298
+ out.push({ kind: 'worktree-observed', trackId: effect.trackId, owned: false });
299
+ // 'retry-worktree': recién ahí `tracks.ts` toca `runtime` de
300
+ // verdad — su resultado real (éxito u `effect-failed`) ya está
301
+ // en el `case 'create-worktree'` de `observationsFor`.
302
+ }
303
+ else if (effect.kind === 'spawn-track-supervisor') {
304
+ if (decision === 'block-foreign')
305
+ out.push({ kind: 'supervisor-observed', trackId: effect.trackId, identity: 'other' });
306
+ else if (decision === 'accept-readiness' && observed.supervisorArtifact === 'ready') {
307
+ out.push({ kind: 'supervisor-observed', trackId: effect.trackId, identity: 'expected', readinessNonce: effect.readinessNonce });
308
+ }
309
+ // 'retry-supervisor-same-intent' y 'accept-readiness' con
310
+ // 'claimed': ningún tick produce todavía una observación de
311
+ // protocolo (esperan al próximo, o recién ahí tocan `runtime`).
312
+ }
313
+ else {
314
+ // create-track-journal: `write-descriptor` es la única decisión
315
+ // posible en WORKTREE_CREATED (initTrackJournal ya es idempotente
316
+ // por construcción) — su resultado ya lo cubre el `default` de
317
+ // `observationsFor`.
318
+ expect(decision).toBe('write-descriptor');
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+ /**
324
+ * Task 13 (mismo criterio que `decidePrepareObservationsFor`, Task 9): llama
325
+ * a `decideTeardown` — la MISMA autoridad que usa `runBeginTeardown` — para
326
+ * cada `TeardownObservation` relevante a la fase ACTUAL del track en
327
+ * `begin-teardown`, y traduce cada decisión "ya resuelta" (`block-foreign`,
328
+ * `accept-supervisor-stopped`, `remove-owned-branch` desde SUPERVISOR_STOPPED,
329
+ * `mark-removed`) a la `teardown-observation` que un driver real produciría
330
+ * con ella (mismo mapeo que `runBeginTeardown` en `tracks.ts`). Las
331
+ * decisiones "falta trabajo real" (`stop-own-supervisor`,
332
+ * `remove-owned-worktree`, `remove-owned-branch` desde WORKTREE_REMOVED,
333
+ * `persist-intent`) no producen observación acá — su resultado real (éxito
334
+ * u `effect-failed`) ya lo cubre el `case 'begin-teardown'` de
335
+ * `observationsFor`.
336
+ */
337
+ function decideTeardownObservationsFor(effect, state, teardownDecisions) {
338
+ if (effect.kind !== 'begin-teardown')
339
+ return [];
340
+ const track = state.tracks[effect.trackId];
341
+ const variants = TEARDOWN_OBSERVATIONS_BY_PHASE[track.phase] ?? [{}];
342
+ const out = [];
343
+ for (const observed of variants) {
344
+ const decision = (0, protocol_1.decideTeardown)(track, observed);
345
+ expect(VALID_TEARDOWN_DECISIONS).toContain(decision);
346
+ teardownDecisions.add(decision);
347
+ if (decision === 'block-foreign' || decision === 'accept-supervisor-stopped' || decision === 'mark-removed'
348
+ || (decision === 'remove-owned-branch' && track.phase === 'SUPERVISOR_STOPPED')) {
349
+ out.push({ kind: 'teardown-observation', trackId: effect.trackId, ...observed });
350
+ }
351
+ // 'persist-intent' / 'stop-own-supervisor' / 'remove-owned-worktree'
352
+ // / 'remove-owned-branch' (desde WORKTREE_REMOVED, branch real por
353
+ // remover): el driver TODAVÍA no completó el efecto real acá.
354
+ }
355
+ return out;
356
+ }
357
+ function observationsFor(effect, state, prepareDecisions, teardownDecisions) {
358
+ if (effect === null)
359
+ return [];
360
+ const base = (() => {
361
+ switch (effect.kind) {
362
+ case 'create-worktree': return [
363
+ { kind: 'effect-applied', effect },
364
+ { kind: 'worktree-observed', trackId: effect.trackId, owned: true },
365
+ { kind: 'worktree-observed', trackId: effect.trackId, owned: false },
366
+ { kind: 'effect-failed', trackId: effect.trackId, effect: effect.kind, detail: 'injected' },
367
+ ];
368
+ case 'merge-track': return [
369
+ { kind: 'join-observation', trackId: effect.trackId, mergeHead: null, planHead: 'merged', trackIsAncestor: true },
370
+ { kind: 'join-observation', trackId: effect.trackId, mergeHead: effect.expectedTrackHeadSha, planHead: effect.expectedPlanHeadSha },
371
+ ];
372
+ case 'freeze-track': return [
373
+ { kind: 'effect-applied', effect },
374
+ { kind: 'freeze-observation', trackId: effect.trackId, frozenHeadSha: `${effect.trackId}-head` },
375
+ ];
376
+ case 'spawn-track-supervisor': return [
377
+ { kind: 'effect-applied', effect },
378
+ { kind: 'supervisor-observed', trackId: effect.trackId, identity: 'expected', readinessNonce: effect.readinessNonce },
379
+ { kind: 'supervisor-observed', trackId: effect.trackId, identity: 'expected', readinessNonce: 'nonce-ajeno' },
380
+ { kind: 'supervisor-observed', trackId: effect.trackId, identity: 'other' },
381
+ ];
382
+ // T13: reemplaza las observaciones gruesas `track-removed`/
383
+ // `teardown-blocked` por el vocabulario fino de
384
+ // `TeardownObservation` — `decideTeardownObservationsFor` abajo
385
+ // agrega, además, las variantes específicas de la fase actual
386
+ // (mismo criterio que `decidePrepareObservationsFor`).
387
+ case 'begin-teardown': return [
388
+ { kind: 'effect-applied', effect },
389
+ { kind: 'effect-failed', trackId: effect.trackId, effect: effect.kind, detail: 'worktree sucio: x' },
390
+ ];
391
+ case 'request-global-qa': return [{ kind: 'global-qa-pass', headSha: 'H-qa', clean: true }];
392
+ case 'request-final-integration': return [{ kind: 'integration-pass', jobId: 'job-1', headSha: 'H-qa' }];
393
+ case 'run-final-interlock': return [{ kind: 'interlock-pass', headSha: 'H-qa' }];
394
+ default: return [{ kind: 'effect-applied', effect }];
395
+ }
396
+ })();
397
+ // Task 9 (Step 3): agrega las observaciones que `decidePrepare` ejercita
398
+ // para las 3 fronteras de PREPARING que gobierna, deduplicando contra las
399
+ // que este mismo `switch` ya listaba a mano.
400
+ const seen = new Set(base.map((o) => JSON.stringify(o)));
401
+ for (const observation of decidePrepareObservationsFor(effect, state, prepareDecisions)) {
402
+ const key = JSON.stringify(observation);
403
+ if (!seen.has(key)) {
404
+ base.push(observation);
405
+ seen.add(key);
406
+ }
407
+ }
408
+ for (const observation of decideTeardownObservationsFor(effect, state, teardownDecisions)) {
409
+ const key = JSON.stringify(observation);
410
+ if (!seen.has(key)) {
411
+ base.push(observation);
412
+ seen.add(key);
413
+ }
414
+ }
415
+ return base;
416
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // Task 13 (R4.2/R4.3/R4.6/R4.10/C2/C9): matriz PURA de `decideTeardown` —
4
+ // mismo criterio que `join-reconcile.test.ts` (T11): importa el re-export de
5
+ // `teardown.ts`, así que cualquier fix que esta matriz descubra vive en
6
+ // `protocol.ts` (autoridad única) y `protocol.test.ts` vuelve a correr su
7
+ // exploración en el mismo commit.
8
+ const teardown_1 = require("../../../src/core/tracks/teardown");
9
+ const phase = (p) => ({
10
+ trackId: 'a', phase: p, fencingToken: 'f'.repeat(32), readinessNonce: 'r'.repeat(32),
11
+ });
12
+ const observed = (o = {}) => o;
13
+ describe('decideTeardown — Task 13 (R4.2/R4.3/R4.6/R4.10, C2/C9): matriz pura', () => {
14
+ const cases = [
15
+ ['request sin intent', phase('TEARDOWN_REQUESTED'), observed(), 'persist-intent'],
16
+ ['supervisor propio vivo', phase('TEARDOWN_INTENT'), observed({ ownSupervisorAlive: true }), 'stop-own-supervisor'],
17
+ ['identidad ajena', phase('TEARDOWN_INTENT'), observed({ foreignSupervisor: true }), 'block-foreign'],
18
+ ['supervisor ausente', phase('TEARDOWN_INTENT'), observed({ ownSupervisorAlive: false }), 'accept-supervisor-stopped'],
19
+ ['worktree propio', phase('SUPERVISOR_STOPPED'), observed({ ownedWorktreeExists: true }), 'remove-owned-worktree'],
20
+ ['worktree ajeno', phase('SUPERVISOR_STOPPED'), observed({ foreignWorktree: true }), 'block-foreign'],
21
+ ['branch propia', phase('WORKTREE_REMOVED'), observed({ ownedBranchExists: true }), 'remove-owned-branch'],
22
+ ['todo ausente', phase('BRANCH_REMOVED'), observed(), 'mark-removed'],
23
+ ];
24
+ test.each(cases)('%s (R4.3, C9)', (_name, ref, observation, action) => {
25
+ expect((0, teardown_1.decideTeardown)(ref, observation)).toBe(action);
26
+ });
27
+ // Cobertura complementaria (no listada en el snippet del plan, pero
28
+ // exigida por "identidad ajena SIEMPRE gana primero, sin importar la
29
+ // fase" del comentario de `decideTeardown`): `foreignSupervisor`/
30
+ // `foreignWorktree` bloquean sin importar en qué paso del teardown esté
31
+ // el track — nunca se toca algo ajeno solo porque la fase "no le toca".
32
+ test('ajeno gana primero sin importar la fase (R4.6, C9)', () => {
33
+ expect((0, teardown_1.decideTeardown)(phase('SUPERVISOR_STOPPED'), { foreignSupervisor: true })).toBe('block-foreign');
34
+ expect((0, teardown_1.decideTeardown)(phase('WORKTREE_REMOVED'), { foreignWorktree: true })).toBe('block-foreign');
35
+ });
36
+ test('sin worktree ni branch propios, SUPERVISOR_STOPPED avanza directo a remove-owned-branch (mecánico, C9)', () => {
37
+ expect((0, teardown_1.decideTeardown)(phase('SUPERVISOR_STOPPED'), {})).toBe('remove-owned-branch');
38
+ });
39
+ test('WORKTREE_REMOVED sin branch propia marca removed (mecánico, C9)', () => {
40
+ expect((0, teardown_1.decideTeardown)(phase('WORKTREE_REMOVED'), {})).toBe('mark-removed');
41
+ });
42
+ // R4.6/C9: BLOCKED es un dead end DELIBERADO — ninguna fase alcanzable
43
+ // por `nextProtocolEffect` para `begin-teardown` es BLOCKED (se filtra
44
+ // antes, ver `protocol.ts`), así que `decideTeardown` jamás la resuelve
45
+ // sola: lanza fail-closed en vez de inventar una salida automática. Salir
46
+ // de BLOCKED es siempre un acto explícito del operador (volver a
47
+ // TEARDOWN_REQUESTED), nunca algo que esta función decida por sí misma.
48
+ test('BLOCKED nunca es una fase de entrada válida (R4.6, C9): fail-closed', () => {
49
+ expect(() => (0, teardown_1.decideTeardown)(phase('BLOCKED'), {})).toThrow(/teardown no válido desde BLOCKED/);
50
+ });
51
+ test('fase de trabajo normal (nunca en teardown) también fail-closed', () => {
52
+ expect(() => (0, teardown_1.decideTeardown)(phase('ACTIVE'), {})).toThrow(/teardown no válido desde ACTIVE/);
53
+ });
54
+ test('la matriz cubre las 7 decisiones posibles', () => {
55
+ const seen = new Set(cases.map(([, , , decision]) => decision));
56
+ seen.add((0, teardown_1.decideTeardown)(phase('SUPERVISOR_STOPPED'), {})); // remove-owned-branch (variante mecánica)
57
+ expect([...seen].sort()).toEqual([
58
+ 'accept-supervisor-stopped', 'block-foreign', 'mark-removed',
59
+ 'persist-intent', 'remove-owned-branch', 'remove-owned-worktree', 'stop-own-supervisor',
60
+ ].sort());
61
+ });
62
+ });
@@ -59,12 +59,14 @@ describe('update-check', () => {
59
59
  it('maybeNotifyUpdate avisa si el cache trae versión más nueva y NO refresca cache fresco', () => {
60
60
  const m = require('../../src/core/update-check');
61
61
  m.writeUpdateCache({ lastCheck: 1_000_000, latest: '99.0.0' });
62
- const log = jest.spyOn(console, 'log').mockImplementation(() => { });
62
+ // stderr, no stdout: el aviso sale al final de cualquier comando y en stdout
63
+ // rompia la salida de `--json`.
64
+ const err = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
63
65
  const spawnWorker = jest.fn();
64
66
  m.maybeNotifyUpdate({ now: 1_000_000 + 1000, spawnWorker });
65
- expect(log.mock.calls.flat().join('\n')).toContain('99.0.0');
67
+ expect(err.mock.calls.flat().join('\n')).toContain('99.0.0');
66
68
  expect(spawnWorker).not.toHaveBeenCalled();
67
- log.mockRestore();
69
+ err.mockRestore();
68
70
  });
69
71
  it('cache viejo (>24h) dispara refresh en background', () => {
70
72
  const m = require('../../src/core/update-check');
@@ -104,3 +106,32 @@ describe('update-check', () => {
104
106
  expect(cache.lastCheck).toBeGreaterThan(0);
105
107
  });
106
108
  });
109
+ // El aviso de version nueva va por stderr, no por stdout.
110
+ //
111
+ // Se imprime al final de CUALQUIER comando, asi que en stdout se mezclaba con la salida
112
+ // de `--json` y rompia a cualquiera que parsee. Encontrado tropezando con el:
113
+ // `awm doctor --json | node -e 'JSON.parse(...)'` fallaba con un SyntaxError que no
114
+ // menciona la causa. stdout es la interfaz de maquina.
115
+ describe('the update banner never contaminates stdout', () => {
116
+ it('writes to stderr so `--json` output stays parseable', () => {
117
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
118
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
119
+ const log = jest.spyOn(console, 'log').mockImplementation(() => { });
120
+ try {
121
+ const { maybeNotifyUpdate } = require('../../src/core/update-check');
122
+ maybeNotifyUpdate({ spawnWorker: () => { } });
123
+ const onStdout = stdout.mock.calls.map((c) => String(c[0])).join('')
124
+ + log.mock.calls.map((c) => String(c[0])).join('');
125
+ expect(onStdout).not.toContain('available');
126
+ // Y si habia aviso, salio por stderr — no se perdio, se movio.
127
+ const emitted = stderr.mock.calls.map((c) => String(c[0])).join('');
128
+ if (emitted.length > 0)
129
+ expect(emitted).toContain('available');
130
+ }
131
+ finally {
132
+ stdout.mockRestore();
133
+ stderr.mockRestore();
134
+ log.mockRestore();
135
+ }
136
+ });
137
+ });
@@ -0,0 +1,35 @@
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.initRepo = initRepo;
7
+ exports.commitFile = commitFile;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const process_1 = require("../../src/core/journal/process");
13
+ /** Identidad de commit local al tmpdir del fixture — jamas toca ~/.gitconfig
14
+ * ni ninguna config global (CLAUDE.md: `~/.awm` y el entorno real del dueño
15
+ * son territorio prohibido para sesiones de desarrollo/tests). `EXEC_STDIO`
16
+ * explicito por la misma razon que en git.ts (ver process.ts): sin esto
17
+ * execFileSync hereda el stderr del proceso llamante. */
18
+ const git = (repo, args) => (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd: repo, encoding: 'utf8', stdio: process_1.EXEC_STDIO });
19
+ /** Crea un repo git aislado en un tmpdir propio. El caller es responsable de
20
+ * `fs.rmSync(repo, { recursive: true, force: true })` en su `afterEach`. */
21
+ function initRepo() {
22
+ const repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-git-fixture-'));
23
+ // `-b main` fija el nombre de la rama inicial explicitamente: sin esto el
24
+ // default depende de `init.defaultBranch` (global, no tocable desde aca
25
+ // per CLAUDE.md) y varia entre instalaciones de git (master vs main).
26
+ git(repo, ['init', '-q', '-b', 'main']);
27
+ return repo;
28
+ }
29
+ /** Escribe `name` con `content`, lo commitea y devuelve el SHA resultante. */
30
+ function commitFile(repo, name, content) {
31
+ fs_1.default.writeFileSync(path_1.default.join(repo, name), content);
32
+ git(repo, ['add', '--', name]);
33
+ git(repo, ['commit', '-qm', `add ${name}`]);
34
+ return git(repo, ['rev-parse', 'HEAD']).trim();
35
+ }