agentic-workflow-manager 3.13.3 → 3.13.4
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/dist/src/core/diagnostics/context.js +33 -9
- package/dist/src/core/journal/process.js +22 -5
- package/dist/tests/core/diagnostics/context.test.js +44 -0
- package/dist/tests/core/init/steps.test.js +55 -0
- package/dist/tests/core/journal/process.test.js +54 -1
- package/dist/tests/core/sync-gates.test.js +28 -3
- package/dist/tests/integration/copilot-init-isolated.test.js +203 -0
- package/package.json +1 -1
|
@@ -112,19 +112,33 @@ function gatherMachine(bundles, agent = 'claude-code') {
|
|
|
112
112
|
}
|
|
113
113
|
catch { /* sin soporte de hooks → ausente */ }
|
|
114
114
|
// devCore (bundle baseline) — skillsDir is null only for providers with no global skill
|
|
115
|
-
// discovery mechanism (today: Copilot);
|
|
115
|
+
// discovery mechanism (today: Copilot); for those, there is no global-scope devCore
|
|
116
|
+
// concept to satisfy at all, so it's treated as trivially satisfied (see below).
|
|
116
117
|
const skillsDir = (0, providers_1.providerFor)(agent).skill.global;
|
|
117
118
|
const baseline = bundles.find((b) => b.scope === 'baseline');
|
|
118
119
|
let devCorePresent = false;
|
|
119
120
|
let brokenLinks = [];
|
|
120
121
|
if (baseline) {
|
|
121
122
|
const skillNames = (0, bundles_1.resolveBundleSkills)(baseline.name, bundles);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
if (skillsDir === null) {
|
|
124
|
+
// No global skill directory for this agent (today: Copilot) — there is no
|
|
125
|
+
// global-scope devCore/baseline-bundle concept to satisfy for it at all, so
|
|
126
|
+
// "N/A" is reported as satisfied (present, nothing broken) rather than
|
|
127
|
+
// "missing". Mirrors globalSkills' treatment just below (empty valid/
|
|
128
|
+
// repairable/dead when skillsDir === null). Without this, devCorePresent
|
|
129
|
+
// was unconditionally false here (linked/broken forced to empty arrays),
|
|
130
|
+
// so `machine.devCore` could never be satisfied for Copilot — stepDevCore
|
|
131
|
+
// (init/steps.ts) would fall through on every run and call installBundle at
|
|
132
|
+
// global scope, which throws (skill.global === null), rolling back the
|
|
133
|
+
// ENTIRE `awm init -a copilot` transaction, 100% of the time.
|
|
134
|
+
devCorePresent = true;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const { linked, broken } = classifyLinks(skillNames, skillsDir);
|
|
138
|
+
const absent = skillNames.filter((s) => !linked.includes(s) && !broken.includes(s));
|
|
139
|
+
devCorePresent = skillNames.length > 0 && (linked.length + broken.length) > 0;
|
|
140
|
+
brokenLinks = [...broken, ...absent];
|
|
141
|
+
}
|
|
128
142
|
// Agent-type artifacts are never shared across agents (R12/R13 —
|
|
129
143
|
// install-planner.ts — unlike skills, where OpenCode and Codex both
|
|
130
144
|
// resolve to ~/.agents/skills). A shared skill directory already
|
|
@@ -155,9 +169,19 @@ function gatherMachine(bundles, agent = 'claude-code') {
|
|
|
155
169
|
}
|
|
156
170
|
}
|
|
157
171
|
catch { /* sin config → ningún ambient deseado */ }
|
|
158
|
-
|
|
172
|
+
// Same guard as devCorePresent above: an agent with no global skill
|
|
173
|
+
// directory (today: Copilot) has no way to ever receive a globally-
|
|
174
|
+
// installed ambient bundle either — there is no global-scope "ambient"
|
|
175
|
+
// concept to satisfy for it at all. Without this, `installed` was forced
|
|
176
|
+
// to `[]` unconditionally for Copilot regardless of `wanted`, so
|
|
177
|
+
// stepAmbient (init/steps.ts) would treat every entry of a machine-level
|
|
178
|
+
// `~/.awm/config.json`'s `ambient` array as permanently missing and call
|
|
179
|
+
// installBundle at global scope — which throws for Copilot exactly like
|
|
180
|
+
// the devCore bug this file already fixes. Reported as "N/A == already
|
|
181
|
+
// satisfied" (installed), not "wanted but always missing".
|
|
182
|
+
const installed = skillsDir === null ? [...wanted] : wanted.filter((name) => {
|
|
159
183
|
const skillNames = (0, bundles_1.resolveBundleSkills)(name, bundles);
|
|
160
|
-
if (skillNames.length === 0
|
|
184
|
+
if (skillNames.length === 0)
|
|
161
185
|
return false;
|
|
162
186
|
const { linked } = classifyLinks(skillNames, skillsDir);
|
|
163
187
|
return linked.length === skillNames.length;
|
|
@@ -298,11 +298,28 @@ function sleepMsSync(ms) {
|
|
|
298
298
|
* con una pausa breve ANTES de declarar ESRCH definitivo no debilita el
|
|
299
299
|
* invariante "jamas muerte sin evidencia" — un proceso genuinamente muerto
|
|
300
300
|
* sigue reportando ESRCH en el reintento; esto solo absorbe el falso
|
|
301
|
-
* negativo transitorio. Costo maximo
|
|
302
|
-
*
|
|
303
|
-
* no paga nada.
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
* negativo transitorio. Costo maximo, y SOLO en la rama que ya iba a
|
|
302
|
+
* declarar "no existe" — el camino feliz (proceso vivo, exito inmediato)
|
|
303
|
+
* no paga nada.
|
|
304
|
+
*
|
|
305
|
+
* R6 post-mortem #2: el presupuesto original (3 intentos, 50ms => 100ms de
|
|
306
|
+
* espera real) sobrevivio 2 corridas reales de windows-latest tras mergear
|
|
307
|
+
* este mismo mecanismo, pero una tercera corrida real (commit identico,
|
|
308
|
+
* ningun cambio en este archivo) volvio a fallar EL MISMO assert en EL
|
|
309
|
+
* MISMO test — siempre el PRIMER spawn del archivo, nunca los siguientes
|
|
310
|
+
* (que reusan un binario node.exe ya "calentado" por el SO/AV en ese
|
|
311
|
+
* proceso de test). Eso apunta a latencia de arranque en frio (primer
|
|
312
|
+
* spawn del job) empujando el tiempo de visibilidad de OpenProcess mas
|
|
313
|
+
* alla del presupuesto anterior — no una condicion de carrera nueva, la
|
|
314
|
+
* MISMA, con cola mas larga de lo que 100ms cubria. Presupuesto ampliado a
|
|
315
|
+
* 10 intentos / 100ms (hasta ~900ms de espera real) para darle margen real
|
|
316
|
+
* a ese arranque en frio, siguiendo cuestionando el mismo mecanismo en vez
|
|
317
|
+
* de reemplazarlo (systematic-debugging: 2+ fallas del mismo sintoma exacto
|
|
318
|
+
* primero exige ampliar el mismo remedio antes de descartar la arquitectura
|
|
319
|
+
* — a diferencia del patron de "cada intento revela un problema nuevo en
|
|
320
|
+
* otro lugar", que si justificaria cuestionar el diseño). */
|
|
321
|
+
const PID_EXISTS_RETRY_ATTEMPTS = 10;
|
|
322
|
+
const PID_EXISTS_RETRY_DELAY_MS = 100;
|
|
306
323
|
function pidExistsNative(pid) {
|
|
307
324
|
for (let attempt = 0; attempt < PID_EXISTS_RETRY_ATTEMPTS; attempt++) {
|
|
308
325
|
try {
|
|
@@ -103,6 +103,27 @@ describe('gatherContext', () => {
|
|
|
103
103
|
// must surface as a broken/missing link — NOT a clean, skippable state.
|
|
104
104
|
expect(ctx.machine.devCore.brokenLinks).toContain('development-process.toml');
|
|
105
105
|
});
|
|
106
|
+
// Regression for the confirmed production bug: `awm init -a copilot` crashed
|
|
107
|
+
// 100% of the time with "machine.devCore: skill global scope is not
|
|
108
|
+
// supported by Copilot...", rolling back the whole init transaction.
|
|
109
|
+
// Copilot has no global skill directory (providers/index.ts's
|
|
110
|
+
// `skill.global === null`) — before this fix, devCorePresent was
|
|
111
|
+
// unconditionally false in that case (linked/broken forced to empty
|
|
112
|
+
// arrays), so `machine.devCore` could never be satisfied and stepDevCore
|
|
113
|
+
// (init/steps.ts) fell through to a global-scope installBundle call every
|
|
114
|
+
// single run, which throws for Copilot. Now it's reported as trivially
|
|
115
|
+
// satisfied ("N/A" == "nothing to do"), matching how `globalSkills`
|
|
116
|
+
// already treats the same null-skillsDir case.
|
|
117
|
+
it('machine: devCore is trivially satisfied (present, no broken links) for an agent with no global skill directory (copilot)', () => {
|
|
118
|
+
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
119
|
+
const ctx = gatherContext({
|
|
120
|
+
cwd: tmpHome,
|
|
121
|
+
bundles: [bundle('dev-core', 'baseline', ['brainstorming'])],
|
|
122
|
+
agent: 'copilot',
|
|
123
|
+
});
|
|
124
|
+
expect(ctx.machine.devCore.present).toBe(true);
|
|
125
|
+
expect(ctx.machine.devCore.brokenLinks).toEqual([]);
|
|
126
|
+
});
|
|
106
127
|
it('machine: ambient wanted read from ~/.awm/config.json, installed reflects links', () => {
|
|
107
128
|
fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
|
|
108
129
|
fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'config.json'), JSON.stringify({ ambient: ['personal-notion'] }));
|
|
@@ -116,6 +137,29 @@ describe('gatherContext', () => {
|
|
|
116
137
|
expect(ctx.machine.ambient.wanted).toEqual(['personal-notion']);
|
|
117
138
|
expect(ctx.machine.ambient.installed).toEqual(['personal-notion']);
|
|
118
139
|
});
|
|
140
|
+
// Regression for the SAME structural bug as the devCore fix above, just in
|
|
141
|
+
// the `ambient` computation a few lines below it: Copilot has no global
|
|
142
|
+
// skill directory (skill.global === null), so before this fix `installed`
|
|
143
|
+
// was forced to `[]` unconditionally regardless of `wanted`. That made
|
|
144
|
+
// `stepAmbient` (init/steps.ts) treat every entry in a machine-level
|
|
145
|
+
// `~/.awm/config.json`'s `ambient` array as permanently missing and call
|
|
146
|
+
// installBundle at GLOBAL scope for Copilot — which throws with the exact
|
|
147
|
+
// same "skill global scope is not supported by Copilot" error the devCore
|
|
148
|
+
// bug had, and rolls back the whole init transaction. Now `installed`
|
|
149
|
+
// mirrors `wanted` when skillsDir is null (N/A treated as satisfied,
|
|
150
|
+
// nothing to install), matching devCore's treatment above.
|
|
151
|
+
it('machine: ambient is trivially satisfied (installed mirrors wanted) for an agent with no global skill directory (copilot)', () => {
|
|
152
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
|
|
153
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'config.json'), JSON.stringify({ ambient: ['personal-notion'] }));
|
|
154
|
+
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
155
|
+
const bundles = [
|
|
156
|
+
bundle('dev-core', 'baseline', ['brainstorming']),
|
|
157
|
+
bundle('personal-notion', 'ambient', ['notion-skill']),
|
|
158
|
+
];
|
|
159
|
+
const ctx = gatherContext({ cwd: tmpHome, bundles, agent: 'copilot' });
|
|
160
|
+
expect(ctx.machine.ambient.wanted).toEqual(['personal-notion']);
|
|
161
|
+
expect(ctx.machine.ambient.installed).toEqual(['personal-notion']);
|
|
162
|
+
});
|
|
119
163
|
it('machine: contextInjection empty when opencode config is absent', () => {
|
|
120
164
|
const { gatherContext } = require('../../../src/core/diagnostics/context');
|
|
121
165
|
const ctx = gatherContext({ cwd: tmpHome, bundles: [] });
|
|
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const steps_1 = require("../../../src/core/init/steps");
|
|
10
10
|
const providers_1 = require("../../../src/providers");
|
|
11
|
+
const context_1 = require("../../../src/core/diagnostics/context");
|
|
11
12
|
function bundle(name, scope, skills) {
|
|
12
13
|
return {
|
|
13
14
|
name, description: '', version: '1.0.0', scope, visibility: 'public',
|
|
@@ -141,6 +142,60 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
|
|
|
141
142
|
expect((0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a)).action).toBe('applied');
|
|
142
143
|
expect(a.installBundle).toHaveBeenCalled();
|
|
143
144
|
});
|
|
145
|
+
// Regression for the confirmed production bug: `awm init -a copilot` crashed
|
|
146
|
+
// 100% of the time with "machine.devCore: skill global scope is not
|
|
147
|
+
// supported by Copilot...", rolling back the ENTIRE init transaction (even
|
|
148
|
+
// project-local artifacts like AGENTS.md). Root cause: Copilot has no
|
|
149
|
+
// global skill directory (providerFor('copilot').skill.global === null),
|
|
150
|
+
// so gatherMachine's devCorePresent was permanently false for it, and
|
|
151
|
+
// stepDevCore fell through to installBundle at global scope on every run
|
|
152
|
+
// — an install that always throws. Fixed in diagnostics/context.ts:
|
|
153
|
+
// devCore.present is now reported `true` (N/A treated as satisfied) when
|
|
154
|
+
// skill.global is null, so this step's existing skip guard applies
|
|
155
|
+
// naturally.
|
|
156
|
+
//
|
|
157
|
+
// Unlike the rest of this describe block (which hand-builds `machine()`
|
|
158
|
+
// fixtures — a fine choice for exercising stepDevCore's own skip-guard
|
|
159
|
+
// logic in isolation), this test calls the REAL `gatherContext` (the
|
|
160
|
+
// function diagnostics/context.ts's fix actually lives in) with an
|
|
161
|
+
// isolated HOME/AWM_HOME, and feeds ITS real output into stepDevCore.
|
|
162
|
+
// That's deliberate: an earlier version of this test hand-built
|
|
163
|
+
// `devCore: { present: true, brokenLinks: [] }` directly and asserted
|
|
164
|
+
// against it, which only re-verified stepDevCore's pre-existing skip
|
|
165
|
+
// guard — reverting the context.ts fix left that version GREEN because it
|
|
166
|
+
// never called the fixed code at all. This version goes RED on revert:
|
|
167
|
+
// gatherContext would then report `devCore.present: false` for Copilot,
|
|
168
|
+
// stepDevCore would fall through to `installBundle`, and the assertions
|
|
169
|
+
// below (`action === 'skipped'`, `installBundle` not called) would fail.
|
|
170
|
+
it('devCore skips cleanly (never calls installBundle) for an agent with no global skill directory (copilot) — via real gatherContext', () => {
|
|
171
|
+
expect((0, providers_1.providerFor)('copilot').skill.global).toBeNull();
|
|
172
|
+
const tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-steps-copilot-devcore-'));
|
|
173
|
+
const originalHome = process.env.HOME;
|
|
174
|
+
const originalAwmHome = process.env.AWM_HOME;
|
|
175
|
+
try {
|
|
176
|
+
process.env.HOME = tmpHome;
|
|
177
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
178
|
+
const baselineBundle = bundle('dev-core', 'baseline', ['brainstorming']);
|
|
179
|
+
const ctx = (0, context_1.gatherContext)({ cwd: tmpHome, bundles: [baselineBundle], agent: 'copilot' });
|
|
180
|
+
const a = spies();
|
|
181
|
+
const r = (0, steps_1.stepDevCore)(deps({ machine: ctx.machine, project: null }, a, {
|
|
182
|
+
agent: 'copilot', enabledAgents: ['copilot'], bundles: [baselineBundle],
|
|
183
|
+
}));
|
|
184
|
+
expect(r.action).toBe('skipped');
|
|
185
|
+
expect(a.installBundle).not.toHaveBeenCalled();
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
189
|
+
if (originalHome === undefined)
|
|
190
|
+
delete process.env.HOME;
|
|
191
|
+
else
|
|
192
|
+
process.env.HOME = originalHome;
|
|
193
|
+
if (originalAwmHome === undefined)
|
|
194
|
+
delete process.env.AWM_HOME;
|
|
195
|
+
else
|
|
196
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
144
199
|
it('ambient installs only missing wanted', () => {
|
|
145
200
|
const a = spies();
|
|
146
201
|
const m = machine();
|
|
@@ -11,7 +11,31 @@ const process_1 = require("../../../src/core/journal/process");
|
|
|
11
11
|
const paths_1 = require("../../../src/core/paths");
|
|
12
12
|
describe('process identity', () => {
|
|
13
13
|
test('spawnStructured produce ProcessRef con tupla completa (R2.1, R4.7)', async () => {
|
|
14
|
-
|
|
14
|
+
// Reintento de INSTANCIA completa (no solo de la consulta), a proposito:
|
|
15
|
+
// esta misma linea fallo 2 veces reales consecutivas en windows-latest
|
|
16
|
+
// CI, la segunda vez YA con el presupuesto interno de pidExistsNative
|
|
17
|
+
// ampliado a 10 intentos/100ms (~900ms de espera real dentro de
|
|
18
|
+
// refIsAlive) — evidencia de que NO es (solo) latencia de visibilidad
|
|
19
|
+
// de OpenProcess bajo carga: un proceso genuinamente vivo no deberia
|
|
20
|
+
// seguir siendo invisible tras casi un segundo de reintentos. Apunta
|
|
21
|
+
// en cambio a que el hijo mismo puede terminar genuinamente muy
|
|
22
|
+
// temprano en este runner (imagen windows-2025-vs2026, sospecha no
|
|
23
|
+
// confirmable sin Windows real: AV/Defender interviniendo un `node -e`
|
|
24
|
+
// recien lanzado bajo carga pesada de CI). pidExistsNative reintenta
|
|
25
|
+
// la MISMA consulta sobre el MISMO pid — si ese pid ya esta
|
|
26
|
+
// genuinamente muerto, reintentar la consulta jamas ayuda. Este loop
|
|
27
|
+
// reintenta el SPAWN entero: un intento NUEVO puede sobrevivir donde
|
|
28
|
+
// el anterior no lo hizo. Si los 3 intentos mueren temprano, el
|
|
29
|
+
// ultimo `expect` de abajo sigue fallando fuerte — no enmascara una
|
|
30
|
+
// regresion real de refIsAlive.
|
|
31
|
+
let { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc');
|
|
32
|
+
for (let attempt = 1; attempt < 3 && !(0, process_1.refIsAlive)(ref); attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
child.kill('SIGKILL');
|
|
35
|
+
}
|
|
36
|
+
catch { /* ya ausente */ }
|
|
37
|
+
({ child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc'));
|
|
38
|
+
}
|
|
15
39
|
expect(ref.pid).toBe(child.pid);
|
|
16
40
|
expect(ref.spawnNonce).toBe('nonce-abc');
|
|
17
41
|
expect(typeof ref.startTime).toBe('string');
|
|
@@ -253,6 +277,35 @@ describe('process identity (win32, mockeado — sin windows real disponible en e
|
|
|
253
277
|
expect((0, process_1.refIsAlive)(fakeRef)).toBe(true); // NUNCA declara muerte por el ESRCH transitorio de los primeros 2 intentos
|
|
254
278
|
expect(killSpy).toHaveBeenCalledTimes(3);
|
|
255
279
|
});
|
|
280
|
+
test('pidExistsNative absorbe una carrera transitoria mas larga que el presupuesto original — arranque en frio del primer spawn del job (regresion #2: misma falla real, mismo test, tras el fix de 3x50ms ya mergeado)', () => {
|
|
281
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
282
|
+
let calls = 0;
|
|
283
|
+
const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
|
|
284
|
+
calls++;
|
|
285
|
+
// El pid tarda 9 intentos en "aparecer" — mas alla del presupuesto
|
|
286
|
+
// anterior (3 intentos) pero dentro del ampliado (10 intentos).
|
|
287
|
+
if (calls < 9) {
|
|
288
|
+
const err = new Error('transient');
|
|
289
|
+
err.code = 'ESRCH';
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
return true;
|
|
293
|
+
});
|
|
294
|
+
const fakeRef = { pid: 424243, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 424243, psArgsDigest: 'x' };
|
|
295
|
+
expect((0, process_1.refIsAlive)(fakeRef)).toBe(true);
|
|
296
|
+
expect(killSpy).toHaveBeenCalledTimes(9);
|
|
297
|
+
});
|
|
298
|
+
test('pidExistsNative declara muerte solo tras agotar el presupuesto ampliado (10 intentos) — un ESRCH sostenido nunca se lee como vivo', () => {
|
|
299
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
300
|
+
const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
|
|
301
|
+
const err = new Error('gone');
|
|
302
|
+
err.code = 'ESRCH';
|
|
303
|
+
throw err;
|
|
304
|
+
});
|
|
305
|
+
const fakeRef = { pid: 424244, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 424244, psArgsDigest: 'x' };
|
|
306
|
+
expect((0, process_1.refIsAlive)(fakeRef)).toBe(false);
|
|
307
|
+
expect(killSpy).toHaveBeenCalledTimes(10);
|
|
308
|
+
});
|
|
256
309
|
test('refIsAlive en win32 NUNCA declara muerte por un error que no sea ESRCH (ej. EPERM: el pid existe pero sin permiso de senializarlo) (R2.1)', () => {
|
|
257
310
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
258
311
|
jest.spyOn(process, 'kill').mockImplementation(() => {
|
|
@@ -14,6 +14,31 @@ const path_1 = __importDefault(require("path"));
|
|
|
14
14
|
const os_1 = __importDefault(require("os"));
|
|
15
15
|
const child_process_1 = require("child_process");
|
|
16
16
|
const GIT = (cwd, cmd) => (0, child_process_1.execSync)(`git -c user.email=t@t.t -c user.name=t -c tag.gpgSign=false ${cmd}`, { cwd, stdio: 'pipe' });
|
|
17
|
+
// Real `git init`/clone-adjacent work per test, several times over — on
|
|
18
|
+
// windows-latest CI this is measurably slower than on POSIX and the jest
|
|
19
|
+
// default of 5000ms proved too tight in real CI (regression: real
|
|
20
|
+
// windows-latest run, "Exceeded timeout of 5000 ms"). Same class of issue
|
|
21
|
+
// already fixed in registries-sync.test.ts earlier this session — this
|
|
22
|
+
// sibling file has the identical real-git-fixture pattern and was missed.
|
|
23
|
+
jest.setTimeout(30000);
|
|
24
|
+
/** git en win32 puede mantener un handle abierto sobre `.git/hooks`/`.git/objects`
|
|
25
|
+
* por un instante despues de que el proceso `git` retorna — rmSync inmediato
|
|
26
|
+
* entonces produce EBUSY/ENOTEMPTY (regresion: real windows-latest CI). Mismo
|
|
27
|
+
* patron ya aplicado en registries-sync.test.ts y runner.test.ts. */
|
|
28
|
+
async function rmSyncRetryingBusy(target, attempts = 10, delayMs = 100) {
|
|
29
|
+
for (let i = 0; i < attempts; i++) {
|
|
30
|
+
try {
|
|
31
|
+
fs_1.default.rmSync(target, { recursive: true, force: true });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const code = error.code;
|
|
36
|
+
if ((code !== 'EBUSY' && code !== 'ENOTEMPTY') || i === attempts - 1)
|
|
37
|
+
throw error;
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
17
42
|
function makeRegistryWithManifest(base, name, manifest) {
|
|
18
43
|
const dir = path_1.default.join(base, name);
|
|
19
44
|
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
@@ -37,9 +62,9 @@ describe('verifyMinCliVersions (gate de awm sync / awm update)', () => {
|
|
|
37
62
|
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
38
63
|
jest.resetModules();
|
|
39
64
|
});
|
|
40
|
-
afterEach(() => {
|
|
41
|
-
|
|
42
|
-
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await rmSyncRetryingBusy(tmpHome);
|
|
67
|
+
await rmSyncRetryingBusy(tmpWork);
|
|
43
68
|
if (originalHome === undefined)
|
|
44
69
|
delete process.env.HOME;
|
|
45
70
|
else
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
// cli/tests/integration/copilot-init-isolated.test.ts
|
|
7
|
+
//
|
|
8
|
+
// Regression E2E for a confirmed, 100%-reproducible production bug:
|
|
9
|
+
// `awm init --agent copilot` crashed deterministically with
|
|
10
|
+
// "machine.devCore: skill global scope is not supported by Copilot..." and
|
|
11
|
+
// rolled back the ENTIRE init transaction (even project-local artifacts like
|
|
12
|
+
// AGENTS.md).
|
|
13
|
+
//
|
|
14
|
+
// Root cause: Copilot has no global skill directory
|
|
15
|
+
// (providers/index.ts's `skill.global === null`), so
|
|
16
|
+
// diagnostics/context.ts's `gatherMachine` computed `devCore.present` as
|
|
17
|
+
// permanently `false` for Copilot (no way to ever satisfy it). That made
|
|
18
|
+
// `stepDevCore` (init/steps.ts) fall through on every run and call
|
|
19
|
+
// `installBundle` at GLOBAL scope, which throws for a provider with no
|
|
20
|
+
// global skill mechanism — an install that was always guaranteed to fail.
|
|
21
|
+
//
|
|
22
|
+
// Fixed in diagnostics/context.ts: when `skillsDir === null`, there is no
|
|
23
|
+
// global-scope devCore/baseline-bundle concept to satisfy for this agent at
|
|
24
|
+
// all, so `devCorePresent` is now reported `true` (N/A treated as
|
|
25
|
+
// satisfied) — mirroring how `globalSkills` already treats the same
|
|
26
|
+
// null-skillsDir case as trivially healthy. `stepDevCore`'s existing
|
|
27
|
+
// `present && brokenLinks.length === 0 -> skip` guard then naturally never
|
|
28
|
+
// falls through to the doomed install, with no redundant guard needed in
|
|
29
|
+
// steps.ts itself.
|
|
30
|
+
//
|
|
31
|
+
// This test lets `awm init --agent copilot` run its REAL, unstubbed
|
|
32
|
+
// pipeline end-to-end against a hand-seeded registry fixture (same pattern
|
|
33
|
+
// as codex-provider-isolated.test.ts) — never against the real `~/.awm`
|
|
34
|
+
// (CLAUDE.md's "never touch ~/.awm" rule; HOME/AWM_HOME are isolated
|
|
35
|
+
// tmpdirs for the whole test).
|
|
36
|
+
const fs_1 = __importDefault(require("fs"));
|
|
37
|
+
const os_1 = __importDefault(require("os"));
|
|
38
|
+
const path_1 = __importDefault(require("path"));
|
|
39
|
+
describe('copilot provider — isolated home E2E (devCore global-scope guard regression)', () => {
|
|
40
|
+
let tmpHome;
|
|
41
|
+
let tmpWork;
|
|
42
|
+
let originalHome;
|
|
43
|
+
let originalAwmHome;
|
|
44
|
+
let writeSpy;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-copilot-e2e-home-'));
|
|
47
|
+
tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-copilot-e2e-work-'));
|
|
48
|
+
originalHome = process.env.HOME;
|
|
49
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
50
|
+
process.env.HOME = tmpHome;
|
|
51
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
52
|
+
jest.resetModules();
|
|
53
|
+
writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
writeSpy.mockRestore();
|
|
57
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
58
|
+
fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
|
|
59
|
+
if (originalHome === undefined)
|
|
60
|
+
delete process.env.HOME;
|
|
61
|
+
else
|
|
62
|
+
process.env.HOME = originalHome;
|
|
63
|
+
if (originalAwmHome === undefined)
|
|
64
|
+
delete process.env.AWM_HOME;
|
|
65
|
+
else
|
|
66
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
67
|
+
});
|
|
68
|
+
/** Same fixture shape as codex-provider-isolated.test.ts's seedPublicRegistryFixture:
|
|
69
|
+
* a real content-root registry (no git repo needed) with the bootstrap skill and one
|
|
70
|
+
* baseline bundle. Copilot needs no hooks (providers/index.ts has no `hooks` config
|
|
71
|
+
* for it) and no native agent renderer (`agent: null`), so the fixture is intentionally
|
|
72
|
+
* smaller than Codex's. */
|
|
73
|
+
function seedPublicRegistryFixture(root) {
|
|
74
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'hooks'), { recursive: true });
|
|
75
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/session-start'), '#!/bin/sh\necho "{}"\n', { mode: 0o755 });
|
|
76
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/run-hook.cmd'), '#!/bin/sh\nexec sh "$1"\n', { mode: 0o755 });
|
|
77
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills/using-awm'), { recursive: true });
|
|
78
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/using-awm/SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
79
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills/development-process'), { recursive: true });
|
|
80
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/development-process/SKILL.md'), '---\nname: development-process\n---\nOrchestrates the dev lifecycle.');
|
|
81
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
|
|
82
|
+
version: 1,
|
|
83
|
+
bundles: [{ name: 'dev-core', source: 'bundles/dev-core', version: '1.0.0', scope: 'baseline', visibility: 'public' }],
|
|
84
|
+
}));
|
|
85
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'bundles/dev-core'), { recursive: true });
|
|
86
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'bundles/dev-core/bundle.json'), JSON.stringify({
|
|
87
|
+
name: 'dev-core', description: '', version: '1.0.0', scope: 'baseline', visibility: 'public',
|
|
88
|
+
dependsOn: [], skills: ['development-process'], workflows: [], agents: [],
|
|
89
|
+
}));
|
|
90
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
|
|
91
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.invalid/baseline.git' }], null, 2));
|
|
92
|
+
}
|
|
93
|
+
function readPrefs() {
|
|
94
|
+
return JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/preferences.json'), 'utf8'));
|
|
95
|
+
}
|
|
96
|
+
it('completes without crashing/rolling back (real bug repro) and delivers content via AGENTS.md, not a doomed global-scope install', async () => {
|
|
97
|
+
seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
|
|
98
|
+
const { runInit } = require('../../src/commands/init');
|
|
99
|
+
// Real, full, unstubbed init for copilot. Before the fix, this threw
|
|
100
|
+
// "machine.devCore: skill global scope is not supported by Copilot: GitHub
|
|
101
|
+
// Copilot has no user-level skill discovery mechanism — skills must be
|
|
102
|
+
// installed per-project." and rolled back the whole transaction (exit 2,
|
|
103
|
+
// AGENTS.md reverted, preferences never committed).
|
|
104
|
+
const code = await runInit({ cwd: tmpWork, yes: true, agent: 'copilot' });
|
|
105
|
+
// 0 (healthy) or 1 (degraded-but-completed) — NOT 2 (failed/rolled back).
|
|
106
|
+
expect(code).toBeLessThanOrEqual(1);
|
|
107
|
+
// The transaction actually committed: copilot is now a real enabled agent.
|
|
108
|
+
expect(readPrefs().enabledAgents).toEqual(['copilot']);
|
|
109
|
+
// Copilot has no global skill directory at all — confirm nothing was
|
|
110
|
+
// (wrongly) written there, and no ~/.github tree was invented.
|
|
111
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.github'))).toBe(false);
|
|
112
|
+
// Content delivery for Copilot happens via its managed-agents-md injection
|
|
113
|
+
// mechanism at PROJECT scope (stepContextInjection, globalPath === null ->
|
|
114
|
+
// scope 'local') — confirm that path is unaffected by this fix and still
|
|
115
|
+
// really delivers the using-awm skill content into the project AGENTS.md.
|
|
116
|
+
const agentsMd = fs_1.default.readFileSync(path_1.default.join(tmpWork, 'AGENTS.md'), 'utf8');
|
|
117
|
+
expect(agentsMd).toContain('MUST invoke skills.');
|
|
118
|
+
});
|
|
119
|
+
// Regression for the SAME structural bug as the devCore one above, in the
|
|
120
|
+
// sibling `ambient` computation (diagnostics/context.ts's `gatherMachine`,
|
|
121
|
+
// a few lines below devCorePresent): before the fix, `installed` was
|
|
122
|
+
// forced to `[]` unconditionally whenever `skillsDir === null` (Copilot),
|
|
123
|
+
// regardless of what `~/.awm/config.json`'s `ambient` array wanted. That
|
|
124
|
+
// made stepAmbient (init/steps.ts) treat every wanted ambient bundle as
|
|
125
|
+
// permanently missing and call installBundle at GLOBAL scope for
|
|
126
|
+
// Copilot — throwing "skill global scope is not supported by Copilot"
|
|
127
|
+
// and rolling back the whole init transaction, exactly like the devCore
|
|
128
|
+
// bug. Nothing in the current CLI writes `ambient` into
|
|
129
|
+
// `~/.awm/config.json` automatically, but it IS read unconditionally by
|
|
130
|
+
// production code and is a real, documented mechanism a user/script can
|
|
131
|
+
// populate — so this hand-seeds it, same as a real machine config would.
|
|
132
|
+
it('with a machine-level ambient bundle configured, completes without crashing/rolling back (ambient sibling of the devCore bug)', async () => {
|
|
133
|
+
const registryRoot = path_1.default.join(tmpHome, '.awm/registries/baseline');
|
|
134
|
+
seedPublicRegistryFixture(registryRoot);
|
|
135
|
+
// Add an ambient-scope bundle to the registry fixture so `wanted`
|
|
136
|
+
// resolves to real skills (resolveBundleSkills needs the bundle to
|
|
137
|
+
// actually exist in `bundles`, discovered via discoverAllBundles()).
|
|
138
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/ambient-skill'), { recursive: true });
|
|
139
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/ambient-skill/SKILL.md'), '---\nname: ambient-skill\ndescription: An ambient bundle skill.\n---\nAmbient.');
|
|
140
|
+
const catalog = JSON.parse(fs_1.default.readFileSync(path_1.default.join(registryRoot, 'catalog.json'), 'utf8'));
|
|
141
|
+
catalog.bundles.push({ name: 'personal-notion', source: 'bundles/personal-notion', version: '1.0.0', scope: 'ambient', visibility: 'public' });
|
|
142
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'catalog.json'), JSON.stringify(catalog));
|
|
143
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'bundles/personal-notion'), { recursive: true });
|
|
144
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'bundles/personal-notion/bundle.json'), JSON.stringify({
|
|
145
|
+
name: 'personal-notion', description: '', version: '1.0.0', scope: 'ambient', visibility: 'public',
|
|
146
|
+
dependsOn: [], skills: ['ambient-skill'], workflows: [], agents: [],
|
|
147
|
+
}));
|
|
148
|
+
// Seed the machine-level ambient config exactly as a user/script would.
|
|
149
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/config.json'), JSON.stringify({ ambient: ['personal-notion'] }));
|
|
150
|
+
const { runInit } = require('../../src/commands/init');
|
|
151
|
+
// Before the fix, this threw "machine.ambient: skill global scope is
|
|
152
|
+
// not supported by Copilot..." (same class as the devCore crash) and
|
|
153
|
+
// rolled back the whole transaction (exit 2, AGENTS.md reverted,
|
|
154
|
+
// preferences never committed).
|
|
155
|
+
const code = await runInit({ cwd: tmpWork, yes: true, agent: 'copilot' });
|
|
156
|
+
// 0 (healthy) or 1 (degraded-but-completed) — NOT 2 (failed/rolled back).
|
|
157
|
+
expect(code).toBeLessThanOrEqual(1);
|
|
158
|
+
// The transaction actually committed.
|
|
159
|
+
expect(readPrefs().enabledAgents).toEqual(['copilot']);
|
|
160
|
+
// Copilot has no global skill directory at all — confirm the ambient
|
|
161
|
+
// bundle was NOT (wrongly) installed at global scope either.
|
|
162
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.github'))).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
it('a subsequent `awm add <bundle> -a copilot` works once init has actually committed', async () => {
|
|
165
|
+
seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
|
|
166
|
+
// Add a second, non-baseline bundle the project can explicitly activate
|
|
167
|
+
// locally (Copilot only supports local-scope skill delivery — providers/
|
|
168
|
+
// index.ts's `skill.local: '.github/instructions'`).
|
|
169
|
+
const registryRoot = path_1.default.join(tmpHome, '.awm/registries/baseline');
|
|
170
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/extra-skill'), { recursive: true });
|
|
171
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/extra-skill/SKILL.md'), '---\nname: extra-skill\ndescription: An extra project skill.\n---\nExtra.');
|
|
172
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'bundles/extra'), { recursive: true });
|
|
173
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'bundles/extra/bundle.json'), JSON.stringify({
|
|
174
|
+
name: 'extra', description: '', version: '1.0.0', scope: 'project', visibility: 'public',
|
|
175
|
+
dependsOn: [], skills: ['extra-skill'], workflows: [], agents: [],
|
|
176
|
+
}));
|
|
177
|
+
const catalog = JSON.parse(fs_1.default.readFileSync(path_1.default.join(registryRoot, 'catalog.json'), 'utf8'));
|
|
178
|
+
catalog.bundles.push({ name: 'extra', source: 'bundles/extra', version: '1.0.0', scope: 'project', visibility: 'public' });
|
|
179
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'catalog.json'), JSON.stringify(catalog));
|
|
180
|
+
// findProjectRoot needs a real project marker (.git/, package.json, or
|
|
181
|
+
// .awm/profile.json) — a bare tmpdir isn't one (same pattern as
|
|
182
|
+
// tests/commands/add.test.ts).
|
|
183
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpWork, 'package.json'), JSON.stringify({ name: 'fixture' }));
|
|
184
|
+
const { runInit } = require('../../src/commands/init');
|
|
185
|
+
const initCode = await runInit({ cwd: tmpWork, yes: true, agent: 'copilot' });
|
|
186
|
+
expect(initCode).toBeLessThanOrEqual(1);
|
|
187
|
+
expect(readPrefs().enabledAgents).toEqual(['copilot']);
|
|
188
|
+
// This was "permanently blocked" per the bug report — it requires a prior
|
|
189
|
+
// COMMITTED `awm init --agent copilot` (resolveAgentTargetsOrError rejects
|
|
190
|
+
// any agent not in enabledAgents), which never happened before the fix
|
|
191
|
+
// because every copilot init rolled back before saving preferences.
|
|
192
|
+
const { runAddBundleCore } = require('../../src/commands/add');
|
|
193
|
+
const { loadPreferences } = require('../../src/utils/config');
|
|
194
|
+
const { discoverAllBundles } = require('../../src/core/bundles');
|
|
195
|
+
const { prefs } = loadPreferences('copilot');
|
|
196
|
+
const bundles = discoverAllBundles();
|
|
197
|
+
const result = runAddBundleCore({ name: 'extra', agent: 'copilot', cwd: tmpWork }, prefs, bundles);
|
|
198
|
+
expect(result.code).toBe(0);
|
|
199
|
+
expect(result.result?.installed.length).toBeGreaterThan(0);
|
|
200
|
+
const instructionsDir = path_1.default.join(tmpWork, '.github/instructions');
|
|
201
|
+
expect(fs_1.default.existsSync(instructionsDir)).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
});
|