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.
- package/README.md +17 -0
- package/dist/src/commands/job/gate.js +37 -13
- package/dist/src/commands/job/index.js +48 -8
- package/dist/src/commands/job/request.js +44 -6
- package/dist/src/commands/track/emit.js +26 -0
- package/dist/src/commands/track/index.js +194 -0
- package/dist/src/commands/track/status.js +63 -0
- package/dist/src/commands/track/supervisor-wrapper.js +89 -0
- package/dist/src/commands/watch/apply.js +201 -13
- package/dist/src/commands/watch/index.js +14 -0
- package/dist/src/commands/watch/runner.js +20 -1
- package/dist/src/commands/watch/supervisor.js +251 -27
- package/dist/src/commands/watch/teardown-driver.js +189 -0
- package/dist/src/commands/watch/tracks.js +1100 -0
- package/dist/src/core/diagnostics/provider-checks.js +12 -9
- package/dist/src/core/journal/adapter.js +46 -6
- package/dist/src/core/journal/paths.js +9 -0
- package/dist/src/core/journal/process.js +101 -1
- package/dist/src/core/journal/requests.js +5 -1
- package/dist/src/core/journal/store.js +34 -2
- package/dist/src/core/journal/types.js +93 -4
- package/dist/src/core/paths.js +51 -0
- package/dist/src/core/profile.js +15 -1
- package/dist/src/core/tracks/concurrency.js +85 -0
- package/dist/src/core/tracks/context.js +89 -0
- package/dist/src/core/tracks/descriptor.js +40 -0
- package/dist/src/core/tracks/git.js +318 -0
- package/dist/src/core/tracks/join.js +185 -0
- package/dist/src/core/tracks/ownership.js +100 -0
- package/dist/src/core/tracks/plan-parser.js +108 -0
- package/dist/src/core/tracks/protocol.js +466 -0
- package/dist/src/core/tracks/teardown.js +34 -0
- package/dist/src/core/tracks/types.js +14 -0
- package/dist/src/core/update-check.js +5 -1
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
- package/dist/tests/commands/track/fixtures.js +13 -0
- package/dist/tests/commands/track/status.test.js +157 -0
- package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
- package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
- package/dist/tests/commands/track/verbs.test.js +326 -0
- package/dist/tests/commands/watch/apply.test.js +116 -4
- package/dist/tests/commands/watch/runner.test.js +22 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
- package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
- package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
- package/dist/tests/commands/watch/track-finalize.test.js +643 -0
- package/dist/tests/commands/watch/track-freeze.test.js +591 -0
- package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
- package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
- package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
- package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
- package/dist/tests/core/journal/adapter-override.test.js +51 -0
- package/dist/tests/core/journal/process.test.js +37 -0
- package/dist/tests/core/journal/requests.test.js +21 -0
- package/dist/tests/core/journal/store.test.js +28 -0
- package/dist/tests/core/journal/types.test.js +89 -0
- package/dist/tests/core/profile.test.js +9 -2
- package/dist/tests/core/same-existing-path.test.js +48 -0
- package/dist/tests/core/tracks/concurrency.test.js +134 -0
- package/dist/tests/core/tracks/context.test.js +177 -0
- package/dist/tests/core/tracks/descriptor.test.js +76 -0
- package/dist/tests/core/tracks/git.test.js +132 -0
- package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
- package/dist/tests/core/tracks/join.test.js +197 -0
- package/dist/tests/core/tracks/ownership.test.js +96 -0
- package/dist/tests/core/tracks/plan-parser.test.js +94 -0
- package/dist/tests/core/tracks/protocol.test.js +416 -0
- package/dist/tests/core/tracks/teardown.test.js +62 -0
- package/dist/tests/core/update-check.test.js +34 -3
- package/dist/tests/helpers/git-fixture.js +35 -0
- package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
- package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
- package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
- package/package.json +1 -1
|
@@ -0,0 +1,439 @@
|
|
|
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
|
+
// Task 11 (R6.2/R6.3/R6.6-R6.9/C7): crash/restart de P2 (join durable) contra
|
|
7
|
+
// git REAL — mismo criterio que `track-bootstrap-crash.test.ts` (Task 9):
|
|
8
|
+
// probar "sin doble merge" (C11-adjacent) solo tiene sentido mirando el
|
|
9
|
+
// repo real, no eventos. Se reutiliza la MISMA instancia de `TrackRuntime`
|
|
10
|
+
// (`defaultTrackRuntime`) a lo largo de "antes" y "después" del crash — el
|
|
11
|
+
// "crash" se simula deteniendo los ticks y, cuando el efecto real ya ocurrió
|
|
12
|
+
// pero no se persistió, reproduciéndolo a mano con las MISMAS funciones
|
|
13
|
+
// reales (`mergeFrozenTrack`), exactamente como `buildRuntime`/`stopWhen` de
|
|
14
|
+
// Task 9 reconstruyen el mundo tras un crash sin volver a levantar un
|
|
15
|
+
// proceso nuevo (dentro de un mismo proceso Jest, un "proceso nuevo" real
|
|
16
|
+
// para `integration.lock` reclamaría identidad viva-por-definición del pid
|
|
17
|
+
// del test runner — no es lo que este archivo necesita probar; eso ya lo
|
|
18
|
+
// cubre `join.test.ts`/Task 10 con `ProcessRef`s fabricados).
|
|
19
|
+
const fs_1 = __importDefault(require("fs"));
|
|
20
|
+
const os_1 = __importDefault(require("os"));
|
|
21
|
+
const path_1 = __importDefault(require("path"));
|
|
22
|
+
const child_process_1 = require("child_process");
|
|
23
|
+
const git_fixture_1 = require("../../helpers/git-fixture");
|
|
24
|
+
const tracks_1 = require("../../../src/commands/watch/tracks");
|
|
25
|
+
const git_1 = require("../../../src/core/tracks/git");
|
|
26
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
27
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
28
|
+
const BRANCH = 'main';
|
|
29
|
+
function gitLocal(repo, args) {
|
|
30
|
+
return (0, child_process_1.execFileSync)('git', args, { cwd: repo, encoding: 'utf8', stdio: 'pipe' });
|
|
31
|
+
}
|
|
32
|
+
function trackRef(id, overrides = {}) {
|
|
33
|
+
return {
|
|
34
|
+
trackId: id, worktreePath: `/nonexistent/${id}`, branch: `awm-track/${id}`,
|
|
35
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
36
|
+
fencingToken: `fence-${id}`.padEnd(32, '0'), phase: 'FROZEN', readinessNonce: `ready-${id}`.padEnd(32, '0'),
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function mergeCommitCountFor(repo, trackHeadSha) {
|
|
41
|
+
let out;
|
|
42
|
+
try {
|
|
43
|
+
out = gitLocal(repo, ['log', '--all', '--format=%H %P']);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
return out.split('\n').filter((line) => line.trim().length > 0 && line.split(' ').slice(1).includes(trackHeadSha)).length;
|
|
49
|
+
}
|
|
50
|
+
describe('reconcileTracks — crash/restart de P2 (join) con git real (Task 11, R6.2/R6.3/R6.6-R6.9/C7)', () => {
|
|
51
|
+
let planRoot;
|
|
52
|
+
let root;
|
|
53
|
+
let baseSha;
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
planRoot = (0, git_fixture_1.initRepo)();
|
|
56
|
+
gitLocal(planRoot, ['config', 'user.email', 't@t.t']);
|
|
57
|
+
gitLocal(planRoot, ['config', 'user.name', 't']);
|
|
58
|
+
gitLocal(planRoot, ['config', 'commit.gpgsign', 'false']);
|
|
59
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
60
|
+
baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
|
|
61
|
+
root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-join-crash-root-'));
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
try {
|
|
65
|
+
(0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
|
|
66
|
+
}
|
|
67
|
+
catch { /* best-effort */ }
|
|
68
|
+
fs_1.default.rmSync(planRoot, { recursive: true, force: true });
|
|
69
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
/** Worktree REAL para un track — comparte el object store del repo del
|
|
72
|
+
* plan (mismo `.git`), así que el SHA congelado queda alcanzable sin
|
|
73
|
+
* fetch, exactamente como lo dejaría `addOwnedWorktree` en producción. */
|
|
74
|
+
function makeTrackHead(id, filename, content) {
|
|
75
|
+
const wt = path_1.default.join(root, `wt-${id}`);
|
|
76
|
+
gitLocal(planRoot, ['worktree', 'add', '-b', `awm-track/${id}`, wt, baseSha]);
|
|
77
|
+
fs_1.default.writeFileSync(path_1.default.join(wt, filename), content);
|
|
78
|
+
gitLocal(wt, ['add', '--', filename]);
|
|
79
|
+
gitLocal(wt, ['commit', '-qm', `track ${id}`]);
|
|
80
|
+
return gitLocal(wt, ['rev-parse', 'HEAD']).trim();
|
|
81
|
+
}
|
|
82
|
+
function declareCohort(refs) {
|
|
83
|
+
(0, store_1.initJournal)(planRoot, BRANCH);
|
|
84
|
+
const s0 = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
85
|
+
s0.cohortPhase = 'JOINING';
|
|
86
|
+
s0.cohortBaseSha = baseSha;
|
|
87
|
+
s0.tracks = refs;
|
|
88
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s0);
|
|
89
|
+
return (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
90
|
+
}
|
|
91
|
+
async function driveToConvergence(runtime, maxTicks = 20) {
|
|
92
|
+
let s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
93
|
+
for (let i = 0; i < maxTicks; i++) {
|
|
94
|
+
if (s.tracks.every((t) => t.phase === 'MERGED_UNVERIFIED'))
|
|
95
|
+
break;
|
|
96
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
|
|
97
|
+
}
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
// --- Los 5 puntos de crash "en línea recta" (sin conflicto real): -------
|
|
101
|
+
// 'after-merge-effect' y 'after-merge-observation' colapsan a la MISMA
|
|
102
|
+
// frontera observable en esta implementación síncrona (`runMergeTrack`
|
|
103
|
+
// lee-decide-ejecuta-relee-persiste dentro de UNA sola invocación, sin
|
|
104
|
+
// punto async intermedio entre "el merge real ya ocurrió" y "se
|
|
105
|
+
// observó/persistió") — mismo criterio que `track-bootstrap-crash.test.ts`
|
|
106
|
+
// documenta para sus propios puntos no alcanzables como boundary externo
|
|
107
|
+
// (`after-prepare-intent`/`after-worktree-effect`). Ambos se prueban acá
|
|
108
|
+
// con la MISMA construcción manual: el merge real ya corrió (vía
|
|
109
|
+
// `mergeFrozenTrack` invocado directamente, fuera de `reconcileTracks`,
|
|
110
|
+
// igual que `buildRuntime` de Task 9 llama a `defaultTrackRuntime(...)
|
|
111
|
+
// .addWorktree(...)` a mano) pero el journal todavía muestra `JOIN_INTENT`.
|
|
112
|
+
const crashPoints = [
|
|
113
|
+
'after-join-request', 'after-join-intent', 'after-merge-effect', 'after-merge-observation', 'after-merged-result',
|
|
114
|
+
];
|
|
115
|
+
test.each(crashPoints)('join converge desde %s sin doble merge (R6.2, R6.8)', async (point) => {
|
|
116
|
+
const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
|
|
117
|
+
const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
|
|
118
|
+
const refA = trackRef('a', { frozenHeadSha: aHead });
|
|
119
|
+
const refB = trackRef('b', { frozenHeadSha: bHead });
|
|
120
|
+
const intentA = { expectedPlanHeadSha: baseSha, expectedTrackHeadSha: aHead, strategy: 'no-ff' };
|
|
121
|
+
if (point === 'after-join-request') {
|
|
122
|
+
declareCohort([refA, refB]);
|
|
123
|
+
}
|
|
124
|
+
else if (point === 'after-join-intent') {
|
|
125
|
+
declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
|
|
126
|
+
}
|
|
127
|
+
else if (point === 'after-merge-effect' || point === 'after-merge-observation') {
|
|
128
|
+
declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
|
|
129
|
+
// El merge real YA corrió (crash entre el efecto git y su
|
|
130
|
+
// observación/persistencia) — reproducido a mano con la MISMA
|
|
131
|
+
// función real que usaría `runMergeTrack`.
|
|
132
|
+
(0, git_1.mergeFrozenTrack)(planRoot, intentA);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// 'after-merged-result': A ya quedó completamente persistido como
|
|
136
|
+
// MERGED_UNVERIFIED en un ciclo anterior — el merge real también
|
|
137
|
+
// ya corrió.
|
|
138
|
+
(0, git_1.mergeFrozenTrack)(planRoot, intentA);
|
|
139
|
+
const mergedHead = gitLocal(planRoot, ['rev-parse', 'HEAD']).trim();
|
|
140
|
+
const s0 = declareCohort([
|
|
141
|
+
{ ...refA, phase: 'MERGED_UNVERIFIED', joinIntent: intentA, joinedCommitSha: mergedHead },
|
|
142
|
+
refB,
|
|
143
|
+
]);
|
|
144
|
+
s0.cohortPlanHeadSha = mergedHead;
|
|
145
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s0);
|
|
146
|
+
}
|
|
147
|
+
const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
148
|
+
const finalState = await driveToConvergence(runtime);
|
|
149
|
+
// C11-adjacent: ni un merge de más por track, sin importar desde qué
|
|
150
|
+
// frontera arrancó el "restart".
|
|
151
|
+
expect(mergeCommitCountFor(planRoot, aHead)).toBe(1);
|
|
152
|
+
expect(mergeCommitCountFor(planRoot, bHead)).toBe(1);
|
|
153
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
|
|
154
|
+
expect((0, git_1.dirtyPaths)(planRoot)).toEqual([]);
|
|
155
|
+
const a = finalState.tracks.find((t) => t.trackId === 'a');
|
|
156
|
+
const b = finalState.tracks.find((t) => t.trackId === 'b');
|
|
157
|
+
expect(a.phase).toBe('MERGED_UNVERIFIED');
|
|
158
|
+
expect(b.phase).toBe('MERGED_UNVERIFIED');
|
|
159
|
+
// C7: `integration.lock` adquirido antes del primer join y RETENIDO
|
|
160
|
+
// (Task 12 lo libera recién tras el interlock final — esta task
|
|
161
|
+
// nunca lo suelta).
|
|
162
|
+
expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(planRoot))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
// --- 'during-conflict': fixture con conflicto REAL (misma línea en el
|
|
165
|
+
// merge de A ya aplicado y en el frozenHeadSha de B), no incluido en la
|
|
166
|
+
// tabla de arriba porque su convergencia NO es "ambos MERGED_UNVERIFIED"
|
|
167
|
+
// — es "el merge propio se abortó y queda reintentable, con índice
|
|
168
|
+
// limpio" (texto explícito del plan). -----------------------------------
|
|
169
|
+
test('join converge desde during-conflict abortando solo el merge propio, con índice limpio (R6.2, R6.8)', async () => {
|
|
170
|
+
// A y B editan la MISMA línea de `shared.txt`: al mergear A primero
|
|
171
|
+
// (limpio, contra `baseSha`) y a B después (contra el merge de A),
|
|
172
|
+
// git produce un conflicto REAL de contenido — no una carrera de
|
|
173
|
+
// HEAD ni un `assertPlanHead` fallido.
|
|
174
|
+
const aHead = makeTrackHead('a', 'shared.txt', 'A-version\n');
|
|
175
|
+
const bHead = makeTrackHead('b', 'shared.txt', 'B-version\n');
|
|
176
|
+
const refA = trackRef('a', { frozenHeadSha: aHead });
|
|
177
|
+
const refB = trackRef('b', { frozenHeadSha: bHead });
|
|
178
|
+
declareCohort([refA, refB]);
|
|
179
|
+
const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
180
|
+
// Fase 1: converger A por el camino normal (limpio) hasta que quede
|
|
181
|
+
// MERGED_UNVERIFIED y B tenga su propio JOIN_INTENT persistido —
|
|
182
|
+
// ambos pasos vía el driver real, sin inyectar nada todavía.
|
|
183
|
+
let s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
184
|
+
for (let i = 0; i < 20; i++) {
|
|
185
|
+
const a = s.tracks.find((t) => t.trackId === 'a');
|
|
186
|
+
const b = s.tracks.find((t) => t.trackId === 'b');
|
|
187
|
+
if (a.phase === 'MERGED_UNVERIFIED' && b.phase === 'JOIN_INTENT' && b.joinIntent !== undefined)
|
|
188
|
+
break;
|
|
189
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
|
|
190
|
+
}
|
|
191
|
+
const aAfter = s.tracks.find((t) => t.trackId === 'a');
|
|
192
|
+
const bAfter = s.tracks.find((t) => t.trackId === 'b');
|
|
193
|
+
expect(aAfter.phase).toBe('MERGED_UNVERIFIED');
|
|
194
|
+
expect(bAfter.phase).toBe('JOIN_INTENT');
|
|
195
|
+
const intentB = bAfter.joinIntent;
|
|
196
|
+
// --- Simular el crash: el merge de B YA se intentó de verdad (fuera
|
|
197
|
+
// de `reconcileTracks`, misma técnica que los otros puntos) y
|
|
198
|
+
// produjo un conflicto REAL — el proceso murió antes de observarlo.
|
|
199
|
+
expect(() => (0, git_1.mergeFrozenTrack)(planRoot, intentB)).toThrow();
|
|
200
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBe(bHead);
|
|
201
|
+
expect((0, git_1.dirtyPaths)(planRoot).length).toBeGreaterThan(0);
|
|
202
|
+
// --- "Restart": UN tick real debe abortar el merge propio (mergeHead
|
|
203
|
+
// coincide con nuestro propio `expectedTrackHeadSha`) y dejar el
|
|
204
|
+
// índice limpio — B queda reintentable (JOIN_INTENT), NUNCA se marca
|
|
205
|
+
// MERGED_UNVERIFIED con un conflicto sin resolver.
|
|
206
|
+
const afterAbort = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
|
|
207
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
|
|
208
|
+
expect((0, git_1.dirtyPaths)(planRoot)).toEqual([]);
|
|
209
|
+
const bFinal = afterAbort.tracks.find((t) => t.trackId === 'b');
|
|
210
|
+
expect(['JOIN_INTENT', 'BLOCKED']).toContain(bFinal.phase);
|
|
211
|
+
expect(mergeCommitCountFor(planRoot, bHead)).toBe(0); // jamás se aplicó
|
|
212
|
+
expect(mergeCommitCountFor(planRoot, aHead)).toBe(1); // A no se re-tocó
|
|
213
|
+
// Un segundo ciclo completo (reintento -> conflicto de nuevo -> abort
|
|
214
|
+
// de nuevo) tampoco corrompe nada ni duplica el merge de A: la
|
|
215
|
+
// convergencia real de B requeriría resolución humana del conflicto,
|
|
216
|
+
// pero el mecanismo nunca dobla-mergea ni deja basura.
|
|
217
|
+
const afterRetryConflict = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, afterAbort, runtime, 2)).state;
|
|
218
|
+
const afterSecondAbort = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, afterRetryConflict, runtime, 2)).state;
|
|
219
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
|
|
220
|
+
expect((0, git_1.dirtyPaths)(planRoot)).toEqual([]);
|
|
221
|
+
expect(mergeCommitCountFor(planRoot, bHead)).toBe(0);
|
|
222
|
+
expect(mergeCommitCountFor(planRoot, aHead)).toBe(1);
|
|
223
|
+
const bSecond = afterSecondAbort.tracks.find((t) => t.trackId === 'b');
|
|
224
|
+
expect(['JOIN_INTENT', 'BLOCKED']).toContain(bSecond.phase);
|
|
225
|
+
});
|
|
226
|
+
// --- Post-review fix (Finding 1, revisión de Task 11): `ensureIntegrationLock`
|
|
227
|
+
// y el intento de merge/abort son DOS fronteras `reconcileTracks()`
|
|
228
|
+
// distintas — antes, un `ensureIntegrationLock` que hacía trabajo real
|
|
229
|
+
// (primer tick de un proceso vivo) seguía, en el MISMO call, a intentar
|
|
230
|
+
// el merge/abort (dos mutaciones reales en una sola invocación). Estos
|
|
231
|
+
// tests usan un `TrackRuntime` fake para `ensureIntegrationLock`
|
|
232
|
+
// (controlando directamente 'acquired'/'already-held') pero delegan
|
|
233
|
+
// `mergeFrozenTrack`/`abortOwnedMerge` al `defaultTrackRuntime` REAL —
|
|
234
|
+
// mismo criterio de mezcla real/fake que `buildRuntime` en
|
|
235
|
+
// `track-bootstrap-crash.test.ts`. No se reimplementa la reclamación de
|
|
236
|
+
// identidad muerta acá (eso ya lo prueba `join.test.ts`/Task 10 en
|
|
237
|
+
// aislamiento, con `ProcessRef`s fabricados) — lo que se prueba es la
|
|
238
|
+
// consecuencia en `runMergeTrack`: NUNCA fundir "lock recién adquirido"
|
|
239
|
+
// con "intento de merge" en un mismo call, sin importar cuántas veces un
|
|
240
|
+
// restart real haya tenido que re-adquirir de verdad.
|
|
241
|
+
describe('runMergeTrack — adquisición del lock y merge son fronteras separadas (Finding 1)', () => {
|
|
242
|
+
function fakeMergeRuntime(lockResult, calls) {
|
|
243
|
+
const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
244
|
+
const unexpected = (what) => () => {
|
|
245
|
+
throw new Error(`no debería llamarse: ${what} (fixture ya en JOIN_INTENT, solo merge-track en juego)`);
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
addWorktree: unexpected('addWorktree'),
|
|
249
|
+
initTrackJournal: unexpected('initTrackJournal'),
|
|
250
|
+
spawnSupervisor: unexpected('spawnSupervisor'),
|
|
251
|
+
observeSupervisor: unexpected('observeSupervisor'),
|
|
252
|
+
stopOwnSupervisor: async () => unexpected('stopOwnSupervisor')(),
|
|
253
|
+
removeOwnedWorktree: unexpected('removeOwnedWorktree'),
|
|
254
|
+
removeOwnedBranch: unexpected('removeOwnedBranch'),
|
|
255
|
+
emitFreezeRequest: unexpected('emitFreezeRequest'),
|
|
256
|
+
mergeFrozenTrack(repo, intent) {
|
|
257
|
+
calls.merge.push(intent.expectedTrackHeadSha);
|
|
258
|
+
real.mergeFrozenTrack(repo, intent);
|
|
259
|
+
},
|
|
260
|
+
abortOwnedMerge(repo, intent) {
|
|
261
|
+
calls.abort.push(intent.expectedTrackHeadSha);
|
|
262
|
+
real.abortOwnedMerge(repo, intent);
|
|
263
|
+
},
|
|
264
|
+
async ensureIntegrationLock() {
|
|
265
|
+
calls.lock += 1;
|
|
266
|
+
return lockResult;
|
|
267
|
+
},
|
|
268
|
+
pauseControllerGeneration: async () => unexpected('pauseControllerGeneration')(),
|
|
269
|
+
releaseIntegrationLockIfHeld: unexpected('releaseIntegrationLockIfHeld'),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function declareJoinIntentCohort(aHead, bHead) {
|
|
273
|
+
const refA = trackRef('a', { frozenHeadSha: aHead });
|
|
274
|
+
const refB = trackRef('b', { frozenHeadSha: bHead });
|
|
275
|
+
const intentA = { expectedPlanHeadSha: baseSha, expectedTrackHeadSha: aHead, strategy: 'no-ff' };
|
|
276
|
+
const s0 = declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
|
|
277
|
+
return { s0, intentA };
|
|
278
|
+
}
|
|
279
|
+
test('un call que adquiere el lock por primera vez NO intenta el merge en ese mismo call (R6.2/C7)', async () => {
|
|
280
|
+
const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
|
|
281
|
+
const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
|
|
282
|
+
const { s0 } = declareJoinIntentCohort(aHead, bHead);
|
|
283
|
+
const calls = { lock: 0, merge: [], abort: [] };
|
|
284
|
+
const runtime = fakeMergeRuntime('acquired', calls);
|
|
285
|
+
const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
|
|
286
|
+
expect(calls.lock).toBe(1);
|
|
287
|
+
expect(calls.merge).toEqual([]);
|
|
288
|
+
expect(calls.abort).toEqual([]);
|
|
289
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
|
|
290
|
+
expect(mergeCommitCountFor(planRoot, aHead)).toBe(0);
|
|
291
|
+
const a = result.state.tracks.find((t) => t.trackId === 'a');
|
|
292
|
+
expect(a.phase).toBe('JOIN_INTENT'); // sin cambios: el intento de merge queda para el PRÓXIMO call
|
|
293
|
+
});
|
|
294
|
+
test('una vez el lock está confirmado held ("already-held"), el PRÓXIMO call sí intenta el merge (R6.2/C7)', async () => {
|
|
295
|
+
const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
|
|
296
|
+
const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
|
|
297
|
+
const { s0 } = declareJoinIntentCohort(aHead, bHead);
|
|
298
|
+
const calls = { lock: 0, merge: [], abort: [] };
|
|
299
|
+
const runtime = fakeMergeRuntime('already-held', calls);
|
|
300
|
+
const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
|
|
301
|
+
expect(calls.lock).toBe(1);
|
|
302
|
+
expect(calls.merge).toEqual([aHead]);
|
|
303
|
+
const a = result.state.tracks.find((t) => t.trackId === 'a');
|
|
304
|
+
expect(a.phase).toBe('MERGED_UNVERIFIED');
|
|
305
|
+
});
|
|
306
|
+
test('crash justo tras adquirir el lock + restart: el proceso nuevo tampoco funde adquisición y merge, y converge sin re-adquirir de más una vez confirmado held (Finding 1)', async () => {
|
|
307
|
+
const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
|
|
308
|
+
const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
|
|
309
|
+
const { s0 } = declareJoinIntentCohort(aHead, bHead);
|
|
310
|
+
let s = s0;
|
|
311
|
+
// "Proceso 1" (antes del crash): primer tick de un proceso vivo —
|
|
312
|
+
// `ensureIntegrationLock` hace trabajo real y devuelve 'acquired'.
|
|
313
|
+
// El "crash" ocurre justo acá: cero merges intentados todavía.
|
|
314
|
+
const callsP1 = { lock: 0, merge: [], abort: [] };
|
|
315
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, fakeMergeRuntime('acquired', callsP1), 2)).state;
|
|
316
|
+
expect(callsP1.merge).toEqual([]);
|
|
317
|
+
expect(s.tracks.find((t) => t.trackId === 'a').phase).toBe('JOIN_INTENT');
|
|
318
|
+
// "Proceso 2" (restart): closure de runtime NUEVA — el journal
|
|
319
|
+
// sigue mostrando JOIN_INTENT (nada se persiste sobre el lock en
|
|
320
|
+
// sí, por diseño: es un primitivo de filesystem ajeno al
|
|
321
|
+
// journal). Su PROPIO primer tick también adquiere de verdad
|
|
322
|
+
// (reclamo de identidad muerta del proceso 1 — esa lógica en
|
|
323
|
+
// aislamiento ya la prueba `join.test.ts`/Task 10) y por eso
|
|
324
|
+
// TAMBIÉN corta acá — nunca funde su propia adquisición con un
|
|
325
|
+
// intento de merge en el mismo call.
|
|
326
|
+
const callsP2a = { lock: 0, merge: [], abort: [] };
|
|
327
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, fakeMergeRuntime('acquired', callsP2a), 2)).state;
|
|
328
|
+
expect(callsP2a.merge).toEqual([]);
|
|
329
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
|
|
330
|
+
expect(mergeCommitCountFor(planRoot, aHead)).toBe(0);
|
|
331
|
+
expect(s.tracks.find((t) => t.trackId === 'a').phase).toBe('JOIN_INTENT');
|
|
332
|
+
// Recién ahora, con el lock confirmado held DENTRO del proceso 2
|
|
333
|
+
// (misma memoización por closure que la implementación real), el
|
|
334
|
+
// SIGUIENTE call avanza al intento de merge — una única vez, sin
|
|
335
|
+
// re-adquirir ni doble-adquirir.
|
|
336
|
+
const callsP2b = { lock: 0, merge: [], abort: [] };
|
|
337
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, fakeMergeRuntime('already-held', callsP2b), 2)).state;
|
|
338
|
+
expect(callsP2b.lock).toBe(1);
|
|
339
|
+
expect(callsP2b.merge).toEqual([aHead]);
|
|
340
|
+
expect(mergeCommitCountFor(planRoot, aHead)).toBe(1);
|
|
341
|
+
expect(s.tracks.find((t) => t.trackId === 'a').phase).toBe('MERGED_UNVERIFIED');
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
// --- Post-review fix (Finding 2, revisión de Task 11): el mensaje de una
|
|
345
|
+
// excepción capturada de `mergeFrozenTrack`/`abortOwnedMerge` JAMÁS decide
|
|
346
|
+
// nada (la relectura vía `gatherJoinObservation` sigue siendo la única
|
|
347
|
+
// fuente de verdad, R6.8) — pero ahora SÍ queda como `detail` diagnóstico
|
|
348
|
+
// en el evento correspondiente, para que un operador pueda distinguir un
|
|
349
|
+
// conflicto benigno de un repo estructuralmente roto.
|
|
350
|
+
describe('runMergeTrack — el mensaje de una excepción capturada queda como detail diagnóstico, nunca decide (Finding 2)', () => {
|
|
351
|
+
function readEvents() {
|
|
352
|
+
let raw = '';
|
|
353
|
+
try {
|
|
354
|
+
raw = fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8');
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
return raw.split('\n').filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
360
|
+
}
|
|
361
|
+
function runtimeWithThrowingMerge(errorMessage) {
|
|
362
|
+
const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
363
|
+
return {
|
|
364
|
+
addWorktree: real.addWorktree, initTrackJournal: real.initTrackJournal,
|
|
365
|
+
spawnSupervisor: real.spawnSupervisor, observeSupervisor: real.observeSupervisor,
|
|
366
|
+
stopOwnSupervisor: real.stopOwnSupervisor, removeOwnedWorktree: real.removeOwnedWorktree,
|
|
367
|
+
removeOwnedBranch: real.removeOwnedBranch, emitFreezeRequest: real.emitFreezeRequest,
|
|
368
|
+
// Deliberadamente NUNCA llama a `real.mergeFrozenTrack`: el repo
|
|
369
|
+
// real queda intacto, así la relectura post-catch ve EXACTAMENTE
|
|
370
|
+
// lo mismo que `before` — la única forma honesta de probar que
|
|
371
|
+
// el mensaje de la excepción no cambia la decisión.
|
|
372
|
+
mergeFrozenTrack() { throw new Error(errorMessage); },
|
|
373
|
+
abortOwnedMerge() { throw new Error(errorMessage); },
|
|
374
|
+
async ensureIntegrationLock() { return 'already-held'; },
|
|
375
|
+
async pauseControllerGeneration() { return false; },
|
|
376
|
+
releaseIntegrationLockIfHeld() { },
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
test('retry-merge: fallo simulado ("disco lleno") queda como detail en track-merge-attempted, decisión sigue siendo retry (R6.8)', async () => {
|
|
380
|
+
const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
|
|
381
|
+
const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
|
|
382
|
+
const refA = trackRef('a', { frozenHeadSha: aHead });
|
|
383
|
+
const refB = trackRef('b', { frozenHeadSha: bHead });
|
|
384
|
+
const intentA = { expectedPlanHeadSha: baseSha, expectedTrackHeadSha: aHead, strategy: 'no-ff' };
|
|
385
|
+
const s0 = declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
|
|
386
|
+
const runtime = runtimeWithThrowingMerge('fallo simulado: disco lleno');
|
|
387
|
+
const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
|
|
388
|
+
// Fail-safe: NUNCA se marca fundido por confiar en la excepción —
|
|
389
|
+
// el repo real nunca se tocó, así que la relectura post-catch
|
|
390
|
+
// sigue viendo "no fundido todavía" y el track queda reintentable.
|
|
391
|
+
expect(result.state.tracks.find((t) => t.trackId === 'a').phase).toBe('JOIN_INTENT');
|
|
392
|
+
expect(mergeCommitCountFor(planRoot, aHead)).toBe(0);
|
|
393
|
+
// Fail-LOUD: el mensaje SÍ quedó como rastro diagnóstico del evento.
|
|
394
|
+
const attempted = readEvents().filter((e) => e.kind === 'track-merge-attempted');
|
|
395
|
+
expect(attempted).toHaveLength(1);
|
|
396
|
+
expect(attempted[0].detail).toBe('fallo simulado: disco lleno');
|
|
397
|
+
});
|
|
398
|
+
test('abort-own-merge: fallo simulado queda como detail en track-merge-aborted, decisión sigue siendo abort-own-merge (R6.8)', async () => {
|
|
399
|
+
// Conflicto REAL (misma técnica que 'during-conflict' arriba):
|
|
400
|
+
// necesitamos un MERGE_HEAD real apuntando a nuestro propio
|
|
401
|
+
// `expectedTrackHeadSha` para que `decideJoinReconciliation`
|
|
402
|
+
// devuelva 'abort-own-merge'.
|
|
403
|
+
const aHead = makeTrackHead('a', 'shared.txt', 'A-version\n');
|
|
404
|
+
const bHead = makeTrackHead('b', 'shared.txt', 'B-version\n');
|
|
405
|
+
const refA = trackRef('a', { frozenHeadSha: aHead });
|
|
406
|
+
const refB = trackRef('b', { frozenHeadSha: bHead });
|
|
407
|
+
declareCohort([refA, refB]);
|
|
408
|
+
const realRuntime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
409
|
+
let s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
410
|
+
for (let i = 0; i < 20; i++) {
|
|
411
|
+
const a = s.tracks.find((t) => t.trackId === 'a');
|
|
412
|
+
const b = s.tracks.find((t) => t.trackId === 'b');
|
|
413
|
+
if (a.phase === 'MERGED_UNVERIFIED' && b.phase === 'JOIN_INTENT' && b.joinIntent !== undefined)
|
|
414
|
+
break;
|
|
415
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, realRuntime, 2)).state;
|
|
416
|
+
}
|
|
417
|
+
const intentB = s.tracks.find((t) => t.trackId === 'b').joinIntent;
|
|
418
|
+
expect(() => (0, git_1.mergeFrozenTrack)(planRoot, intentB)).toThrow();
|
|
419
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBe(bHead);
|
|
420
|
+
const runtime = runtimeWithThrowingMerge('fallo simulado: git binario roto');
|
|
421
|
+
const afterAbortAttempt = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
|
|
422
|
+
// Fail-safe: el fake NUNCA corrió `git merge --abort` de verdad
|
|
423
|
+
// (el MERGE_HEAD sigue siendo nuestro), así que la relectura
|
|
424
|
+
// post-catch decide 'abort-own-merge' de nuevo — el track sigue
|
|
425
|
+
// en JOIN_INTENT, jamás se asume abortado por la excepción sola.
|
|
426
|
+
expect((0, git_1.readMergeHead)(planRoot)).toBe(bHead);
|
|
427
|
+
expect(afterAbortAttempt.tracks.find((t) => t.trackId === 'b').phase).toBe('JOIN_INTENT');
|
|
428
|
+
// Fail-LOUD: el mensaje quedó como detail diagnóstico.
|
|
429
|
+
const aborted = readEvents().filter((e) => e.kind === 'track-merge-aborted');
|
|
430
|
+
expect(aborted).toHaveLength(1);
|
|
431
|
+
expect(aborted[0].detail).toBe('fallo simulado: git binario roto');
|
|
432
|
+
// Limpieza real: el índice sigue con el conflicto real dejado por
|
|
433
|
+
// el `mergeFrozenTrack` de arriba — un abort real (fuera del
|
|
434
|
+
// driver, mismo patrón que el resto de este archivo) lo deja
|
|
435
|
+
// limpio para no ensuciar los tests siguientes.
|
|
436
|
+
(0, child_process_1.execFileSync)('git', ['merge', '--abort'], { cwd: planRoot, stdio: 'pipe' });
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
// Task 8 post-review items 1 (CRITICAL) + 4: cobertura con git REAL de
|
|
7
|
+
// `defaultTrackRuntime` — la única implementación que toca git/worktrees/
|
|
8
|
+
// gitignore de verdad. Todos los demás tests de `reconcileTracks` usan un
|
|
9
|
+
// `TrackRuntime` fake; este archivo es el que habría atrapado el bug crítico
|
|
10
|
+
// original (chequear `.gitignore` contra el HEAD vivo del plan en vez de
|
|
11
|
+
// contra el worktree recién creado, checkeado en `baseSha`).
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const os_1 = __importDefault(require("os"));
|
|
15
|
+
const child_process_1 = require("child_process");
|
|
16
|
+
const git_fixture_1 = require("../../helpers/git-fixture");
|
|
17
|
+
const tracks_1 = require("../../../src/commands/watch/tracks");
|
|
18
|
+
const git_1 = require("../../../src/core/tracks/git");
|
|
19
|
+
function trackRef(worktreePath, overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
trackId: 'x', worktreePath, branch: 'awm-track/x',
|
|
22
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
23
|
+
fencingToken: 'f'.repeat(32), phase: 'PREPARE_INTENT', readinessNonce: 'r'.repeat(32),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
describe('defaultTrackRuntime — integración con git real (R4.1, R4.6, C2, post-review CRITICAL fix)', () => {
|
|
28
|
+
let planRoot;
|
|
29
|
+
let worktreePath;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
planRoot = (0, git_fixture_1.initRepo)();
|
|
32
|
+
// `git worktree add` exige un destino que no exista o esté vacío: se
|
|
33
|
+
// reserva el nombre con mkdtemp y se libera antes de usarlo, para que
|
|
34
|
+
// el path sea único pero no un directorio ya ocupado por mkdtemp.
|
|
35
|
+
worktreePath = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-runtime-wt-'));
|
|
36
|
+
fs_1.default.rmdirSync(worktreePath);
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
try {
|
|
40
|
+
(0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
|
|
41
|
+
}
|
|
42
|
+
catch { /* planRoot puede ya no ser un repo válido */ }
|
|
43
|
+
fs_1.default.rmSync(planRoot, { recursive: true, force: true });
|
|
44
|
+
fs_1.default.rmSync(worktreePath, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
test('happy path: crea el worktree real cuando `.awm` ya está ignorado en baseSha', () => {
|
|
47
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
48
|
+
const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido'); // baseSha SÍ incluye el .gitignore commiteado arriba
|
|
49
|
+
const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
|
|
50
|
+
const ref = trackRef(worktreePath);
|
|
51
|
+
expect(() => runtime.addWorktree(planRoot, ref, baseSha)).not.toThrow();
|
|
52
|
+
expect(fs_1.default.existsSync(path_1.default.join(worktreePath, 'a.txt'))).toBe(true);
|
|
53
|
+
expect(fs_1.default.existsSync(path_1.default.join(worktreePath, '.gitignore'))).toBe(true);
|
|
54
|
+
// `toContain` sobre la salida cruda compara STRINGS: git imprime separadores POSIX y
|
|
55
|
+
// el nombre largo, mientras `worktreePath` viene del tmpdir en 8.3 con backslashes.
|
|
56
|
+
// Se afirma la propiedad real — git registra ESTE worktree en ESTA branch — con la
|
|
57
|
+
// misma función que usa el producto.
|
|
58
|
+
expect((0, git_1.ownedWorktreeExists)(planRoot, worktreePath, 'awm-track/x')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
test('CRITICAL fix: baseSha SIN .gitignore de .awm pero el HEAD vivo del plan SÍ lo tiene — la comprobación vieja (contra planRoot) mentía "ignorado"; la nueva (contra el worktree) detecta que NO lo está y limpia el worktree que acaba de crear (C2)', () => {
|
|
61
|
+
// baseSha = el commit ANTES de que el plan declare `.awm/` ignorado.
|
|
62
|
+
const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido');
|
|
63
|
+
// El HEAD VIVO del plan avanza y agrega el .gitignore DESPUÉS de baseSha.
|
|
64
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
65
|
+
// Demuestra la premisa exacta del bug: preguntarle al repo del plan en
|
|
66
|
+
// su HEAD vivo dice "ignorado" — pero el worktree que se va a crear
|
|
67
|
+
// está checkeado en `baseSha`, que NO tiene ese `.gitignore`.
|
|
68
|
+
expect((0, git_1.isAwmGitignored)(planRoot)).toBe(true);
|
|
69
|
+
const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
|
|
70
|
+
const ref = trackRef(worktreePath);
|
|
71
|
+
expect(() => runtime.addWorktree(planRoot, ref, baseSha)).toThrow(/no está gitignoreado/);
|
|
72
|
+
// C2: el worktree es NUESTRO (lo acabamos de crear en esta misma
|
|
73
|
+
// llamada) — se descarta, nunca queda vivo e inseguro.
|
|
74
|
+
expect(fs_1.default.existsSync(worktreePath)).toBe(false);
|
|
75
|
+
const list = (0, child_process_1.execFileSync)('git', ['worktree', 'list'], { cwd: planRoot, encoding: 'utf8' });
|
|
76
|
+
expect(list).not.toContain(worktreePath);
|
|
77
|
+
// Prueba directa de que el worktree recién creado (checkeado en
|
|
78
|
+
// baseSha) efectivamente NO tenía el .gitignore, confirmando que la
|
|
79
|
+
// única forma honesta de responder es mirar el worktree, no el plan.
|
|
80
|
+
const probe = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-runtime-probe-'));
|
|
81
|
+
fs_1.default.rmdirSync(probe);
|
|
82
|
+
try {
|
|
83
|
+
(0, child_process_1.execFileSync)('git', ['worktree', 'add', probe, baseSha], { cwd: planRoot, stdio: 'pipe' });
|
|
84
|
+
expect((0, git_1.isAwmGitignored)(probe)).toBe(false);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
try {
|
|
88
|
+
(0, child_process_1.execFileSync)('git', ['worktree', 'remove', '--force', probe], { cwd: planRoot, stdio: 'pipe' });
|
|
89
|
+
}
|
|
90
|
+
catch { /* best-effort */ }
|
|
91
|
+
fs_1.default.rmSync(probe, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
test('destino no vacío nunca se adopta (R4.6): addOwnedWorktree real rechaza sin siquiera llegar al chequeo de gitignore', () => {
|
|
95
|
+
const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido');
|
|
96
|
+
fs_1.default.mkdirSync(worktreePath, { recursive: true });
|
|
97
|
+
fs_1.default.writeFileSync(path_1.default.join(worktreePath, 'foreign.txt'), 'ajeno');
|
|
98
|
+
const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
|
|
99
|
+
const ref = trackRef(worktreePath);
|
|
100
|
+
expect(() => runtime.addWorktree(planRoot, ref, baseSha)).toThrow(/no vacío/);
|
|
101
|
+
expect(fs_1.default.existsSync(path_1.default.join(worktreePath, 'foreign.txt'))).toBe(true); // nunca tocado
|
|
102
|
+
const list = (0, child_process_1.execFileSync)('git', ['worktree', 'list'], { cwd: planRoot, encoding: 'utf8' });
|
|
103
|
+
expect(list).not.toContain(worktreePath);
|
|
104
|
+
});
|
|
105
|
+
test('initTrackJournal real: crea journal + descriptor en el worktree, y jamás pisa un trackContext ajeno preexistente (R4.6)', () => {
|
|
106
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
107
|
+
const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido');
|
|
108
|
+
const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
|
|
109
|
+
const ref = trackRef(worktreePath, { branch: 'awm-track/x' });
|
|
110
|
+
runtime.addWorktree(planRoot, ref, baseSha);
|
|
111
|
+
const context = { trackId: 'x', taskIds: [], planDigest: '', baseSha, planJournalId: 'j-plan-1' };
|
|
112
|
+
expect(() => runtime.initTrackJournal(ref, context)).not.toThrow();
|
|
113
|
+
expect(fs_1.default.existsSync(path_1.default.join(worktreePath, '.awm', 'track.json'))).toBe(true);
|
|
114
|
+
// Un trackContext AJENO preexistente (otro trackId) nunca se sobreescribe.
|
|
115
|
+
const foreignRef = trackRef(worktreePath, { trackId: 'y', branch: 'awm-track/x' });
|
|
116
|
+
const foreignContext = { trackId: 'y', taskIds: [], planDigest: '', baseSha, planJournalId: 'j-plan-1' };
|
|
117
|
+
expect(() => runtime.initTrackJournal(foreignRef, foreignContext)).toThrow(/pertenece a otro track/);
|
|
118
|
+
});
|
|
119
|
+
});
|